A demo lakehouse for order-book data, built on the Apache Iceberg table format with an Apache Polaris REST catalog and MinIO (S3-compatible) object storage. The application layer is a Scala 2.13 project using Apache Spark (local mode) to read/write Iceberg tables.
Status: infrastructure bootstrapped, Spark wired to the Polaris REST catalog. The
bronze/silver/goldnamespaces exist and table reads/writes are verified end to end — the ingestion/transform jobs themselves are the next step.
┌──────────────┐ Iceberg REST ┌──────────────┐ S3 API ┌──────────────┐
│ Spark job │ ──────────────────────► │ Polaris │ ───────────────► │ MinIO │
│ (Iceberg │ catalog + metadata │ REST catalog│ data + metadata│ object store│
│ client) │ │ :8181 │ │ :9000/:9001 │
└──────────────┘ └──────────────┘ └──────────────┘
- MinIO — S3-compatible object storage that backs the Iceberg warehouse
(bucket
orderbook-warehouse). - Polaris — Iceberg REST catalog that manages namespaces, tables, and metadata, storing data files in MinIO.
- Scala/Spark app — connects to Polaris over the Iceberg REST protocol, using Spark SQL to
read/write tables (see
example.PolarisSpark).
- Docker + Docker Compose
- JDK 17+ (tested on 21) and sbt (for the Scala/Spark app) —
Spark needs
--add-opensJVM flags to run on Java 17+, whichbuild.sbtsets up viafork
Bring up the full stack:
docker compose up -dCompose starts the services in order and runs two one-shot init containers:
| Service | Purpose |
|---|---|
minio |
S3 object store — API on :9000, web console on :9001 |
minio-init |
Creates the orderbook-warehouse bucket, then exits |
polaris |
Iceberg REST catalog + management API on :8181, health :8182 |
polaris-init |
Runs polaris-setup.sh to create the orderbook catalog, then exits |
When polaris-init prints
Polaris ready: catalog 'orderbook' -> s3://orderbook-warehouse (MinIO), the lakehouse is ready.
Check status and logs:
docker compose ps -a
docker compose logs polaris
docker compose logs polaris-initTear down (add -v to also drop the MinIO data volume):
docker compose down # keep warehouse data
docker compose down -v # wipe everything| What | Endpoint | Credentials |
|---|---|---|
| MinIO S3 API | http://localhost:9000 | admin / password |
| MinIO console | http://localhost:9001 | admin / password |
| Polaris catalog/mgmt | http://localhost:8181 | OAuth2 client credentials |
| Polaris health | http://localhost:8182/q/health | — |
| Iceberg warehouse | s3://orderbook-warehouse |
— |
Polaris root principal (realm default-realm): client id root, secret s3cr3t.
Get an access token:
curl -s -X POST http://localhost:8181/api/catalog/v1/oauth/tokens \
-H "Polaris-Realm: default-realm" \
-d grant_type=client_credentials \
-d client_id=root -d client_secret=s3cr3t \
-d scope=PRINCIPAL_ROLE:ALLRun automatically by the polaris-init container against the in-container Polaris
(http://polaris:8181):
- Requests an OAuth token as the
rootprincipal. - Creates the
orderbookcatalog (typeINTERNAL) pointing ats3://orderbook-warehouse, using MinIO as the S3 endpoint with path-style access. - Grants the auto-created
catalog_adminroleCATALOG_MANAGE_CONTENTand attaches it to theservice_adminprincipal role (whichrootholds), givingrootfull access to the catalog.
The Makefile wraps read-only ("get") queries against the running Polaris catalog. Each target
fetches a fresh OAuth token as root and pretty-prints the JSON response (requires curl + jq).
make help # list all targets
make health # Polaris health endpoint
make token # print an access token
make catalogs # list all catalogs
make catalog # get the orderbook catalog
make catalog-roles # list catalog roles
make principals # list principals
make principal-roles # list principal roles
make namespaces # list namespaces in the catalog
make tables NAMESPACE=bronze # list tables in a namespaceOverride defaults via variables, e.g. make catalog CATALOG=orderbook or
make catalogs POLARIS=http://localhost:8181.
sbt compile # build
sbt test # run tests (munit)Configuration lives in build.sbt (Scala 2.13.16, project orderbook-lakehouse).
-
example.ListCatalogs— connects to the Polaris management API and lists the registered catalogs. Usesrequests-scala+upicklefor HTTP/JSON.sbt "runMain example.ListCatalogs" # or via the Makefile (passes the demo config through as env vars): make scala-catalogs
Config is read from the environment:
POLARIS_URL,POLARIS_REALM,POLARIS_CLIENT_ID,POLARIS_CLIENT_SECRET(defaults match the demo stack).To override without exporting vars by hand, copy the sample env file —
make scala-catalogssources.env(if present) into the environment before running the job:cp .env.example .env # then edit as needed make scala-catalogs.envis gitignored. The JVM has no built-in.envsupport, so the Makefile's shell loads it; runningsbt "runMain example.ListCatalogs"directly won't pick it up unless you source it yourself (set -a; . ./.env; set +a). -
example.PolarisSpark— not a job itself, but the sharedSparkSessionfactory every Spark job builds on. Configures Spark's Iceberg REST catalog (spark.sql.catalog.orderbook) to talk to Polaris for metadata and directly to MinIO (viaS3FileIO) for data files. Config is read from the environment, extending theListCatalogsvars with:POLARIS_CATALOG,MINIO_ENDPOINT,MINIO_ACCESS_KEY,MINIO_SECRET_KEY,AWS_REGION. -
example.CreateNamespaces— sanity-check job for the Spark ↔ Polaris wiring: creates thebronze/silver/goldnamespaces the medallion pipeline will use and lists what's in the catalog afterward.sbt "runMain example.CreateNamespaces" # or: make spark-init-namespaces
-
example.SmokeTest— end-to-end check of the full data path: creates a real Iceberg table, writes rows, reads them back, then drops the table. This is what actually needed the storage/network fixes below —CreateNamespacesonly exercises catalog metadata, not MinIO.sbt "runMain example.SmokeTest" # or: make spark-smoke-test
-
example.OrderBookSchema— not a job, but the order-book event schema (Phase 1 ofdata_pipeline_plan.md): the raw feed's five event kinds (add,cancel,modify,trade,snapshot) and the bronzeStructTypefororderbook.bronze.raw_events(append-only, minimal typing — silver is where types get validated/cast, per Phase 4). -
example.CreateBronzeTable— createsorderbook.bronze.raw_eventsandorderbook.bronze.ingested_files(Phase 3's file-tracking table, seeIngestRawEventsbelow) with the schemas fromOrderBookSchema. Rerunnable (createOrReplace).sbt "runMain example.CreateBronzeTable" # or: make spark-create-bronze-table
-
example.GenerateSyntheticEvents— Phase 3's source for demo data: simulates a toy order book per instrument (addpushes a resting order onto a list;cancel/modify/tradepick a real resting order off that list, so the feed is internally consistent rather than pure random noise) and writes the result to a landing path/format — it doesn't touch the Iceberg table itself, the same as a historical replay dump wouldn't. Deterministic given a seed (default42L) and a fixed epoch anchor, not wall-clock time.sbt "runMain example.GenerateSyntheticEvents [count] [instruments] [path] [format]" # e.g.: sbt "runMain example.GenerateSyntheticEvents 5000 BTC-USD,ETH-USD data/raw_events json"
-
example.IngestRawEvents— Bronze ingestion (Phase 3 ofdata_pipeline_plan.md): reads raw feed files fromSOURCE_PATH/SOURCE_FORMAT(or CLI args), conforms them toOrderBookSchema.bronzeRawEvents(conform: keeps known columns, adds missing ones as null, casts to bronze types — tolerant of extra/missing columns and source-specific typing), and appends toorderbook.bronze.raw_events.Tracks which source files it's already ingested in
orderbook.bronze.ingested_files(one row per file path, from Spark'sinput_file_name()), so a run only reads/appends files not already recorded there (IngestRawEvents.newFilesOnly, a left-anti join against that table) — re-running against a landing directory that keeps growing (e.g. anotherGenerateSyntheticEventsbatch) doesn't re-append files already ingested, and a run with nothing new reports "nothing to do". This is file-level idempotency, on top of (not instead of) the row-level dedupe by(instrument, seq_no)that still happens downstream in silver (Phase 4) — that dedupe remains the correctness backstop for duplicate rows within or across files.sbt "runMain example.IngestRawEvents <path> [format]" # or: make spark-ingest-raw-events # or, as part of a full pipeline run: make ingest
-
example.CreateSilverTable— createsorderbook.silver.book_eventswith the schema fromOrderBookSchema, partitioned byinstrument/event_date. Rerunnable (createOrReplace).sbt "runMain example.CreateSilverTable" # or: make spark-create-silver-table
-
example.Watermark— not a job, but the incrementality primitive every gold/silver transform builds on (Phase 6 ofdata_pipeline_plan.md): records "last upstream snapshot id processed" as an Iceberg table property on the sink table (pipeline.watermark.<name>), so a job can read only the source rows appended since its last run (spark.read.format("iceberg").option("start-snapshot-id", ...).option("end-snapshot-id", ...)) instead of rescanning full history every time. Falls back to a full read when no watermark is recorded yet (first run). -
example.BuildSilverEvents— Silver transform (Phase 4 ofdata_pipeline_plan.md): readsorderbook.bronze.raw_events, drops malformed rows (unknownevent_type, missing required fields, an invalidside, or a missingprice/qtyon non-snapshotevents), dedupes on(instrument, seq_no), derivesevent_datefromtimestamp, andMERGE INTOs the result intoorderbook.silver.book_events— rerunning it never inserts an(instrument, seq_no)already present in silver. Aborts before the merge if more than half the batch is dropped as malformed (BuildSilverEvents.checkQuality). Incremental (Phase 6): only reads bronze rows appended since the last run, viaWatermarkrecorded on the silver table.sbt "runMain example.BuildSilverEvents" # or: make spark-build-silver-events # or, as part of a full pipeline run: make silver
-
example.CreateGoldTables— createsorderbook.gold.ohlcv_bars_1mandorderbook.gold.top_of_book_snapshots(partitioned byevent_date), plusorderbook.gold.book_state(unpartitioned running-book state, Phase 6), with the schemas fromOrderBookSchema. Rerunnable (createOrReplace).sbt "runMain example.CreateGoldTables" # or: make spark-create-gold-tables
-
example.BuildGoldAggregates— Gold transform (Phase 5 ofdata_pipeline_plan.md): readsorderbook.silver.book_eventsand writes two aggregates.ohlcvBarsbuilds one-minute OHLCV bars per instrument fromtradeevents.topOfBookSnapshotsreconstructs a running per-(instrument, side, price)level book fromadd/cancel/tradeevents (addadds qty,cancel/tradesubtract it) and samples the best bid/ask every minute, forward-filling a level's qty into windows where it isn't touched.modifyevents aren't netted into level totals — their row carries the order's new absolute qty, not a delta, and there's noorder_idin the schema to track an individual order across events, so there's no way to recover what changed.Incremental (Phase 6): only reads silver rows appended since the last run, via
Watermarkrecorded on the OHLCV table, and appends the new bars/snapshots rather than recomputing and overwriting the whole table. Top-of-book state carries forward across runs throughorderbook.gold.book_state, so a resting order posted in an earlier batch still counts toward later batches' top of book. This assumes each run covers a complete batch of new data — if a 1-minute window's events happened to arrive split across two separate runs, each run would append its own partial bar/snapshot for that window instead of merging into one, which is an accepted limitation for this batch-oriented pipeline rather than something engineered around (seedata_pipeline_plan.mdPhase 6).sbt "runMain example.BuildGoldAggregates" # or: make spark-build-gold-aggregates # or, as part of a full pipeline run: make gold
-
Pipeline-stage aliases (Phase 6) —
make ingest,make silver,make goldwrapexample.IngestRawEvents,example.BuildSilverEvents,example.BuildGoldAggregatesrespectively, so a full incremental run of the pipeline is:make ingest && make silver && make gold
-
example.MaintainTables— Table maintenance (Phase 7 ofdata_pipeline_plan.md): runs Iceberg's Spark maintenance procedures against every managed table (bronze.raw_events,silver.book_events,gold.ohlcv_bars_1m,gold.top_of_book_snapshots,gold.book_state), in the order Iceberg recommends — compact, then expire, then sweep orphans:CALL orderbook.system.rewrite_data_files(table => '...')— compacts the small files that Phase 6's frequent incremental appends/merges produce.CALL orderbook.system.expire_snapshots(table => '...', older_than => ..., retain_last => ...)— drops snapshots past a retention window (default 7 days, always keeping at least the most recent one), bounding storage/metadata growth from those same commits.CALL orderbook.system.remove_orphan_files(table => '...', older_than => ...)— sweeps files under the table's data directory that no live snapshot references (e.g. left behind by a failed write), past a conservative default 3-day cutoff so files from an in-flight commit aren't swept.
Retention windows are configurable via
MAINTENANCE_SNAPSHOT_RETENTION_HOURS(default168),MAINTENANCE_RETAIN_LAST_SNAPSHOTS(default1), andMAINTENANCE_ORPHAN_FILE_RETENTION_HOURS(default72).sbt "runMain example.MaintainTables" # or: make spark-maintain-tables # or: make maintain
No scheduler wired up yet, same as the Phase 6 pipeline jobs — run it by hand or cron it externally.
Polaris is configured for local/demo use only — see the polaris service environment in
docker-compose.yml:
polaris.persistence.type: in-memory— catalog state is lost on every restart, sopolaris-initrecreates theorderbookcatalog each time the stack comes up.SUPPORTED_CATALOG_STORAGE_TYPES: ["S3"]— only S3 storage is allowed. Polaris' production readiness checks reject theFILEstorage type as insecure and abort startup if it's enabled.SKIP_CREDENTIAL_SUBSCOPING_INDIRECTION: true— lets Polaris use ambient AWS credentials against MinIO without STS. Convenient for a demo, not safe for production.- RSA token-signing keys are auto-generated on startup rather than supplied.
These are fine for a demo but must be hardened before any real deployment. See Configuring Polaris for production.
Getting Spark to successfully commit an Iceberg table (not just list catalogs/namespaces) against Polaris + MinIO needed three non-obvious fixes, all present in this repo already but worth knowing about if you touch this config:
stsUnavailable: trueon the catalog'sstorageConfigInfo(set inpolaris-setup.sh). Without it, Polaris'SKIP_CREDENTIAL_SUBSCOPING_INDIRECTIONflag alone doesn't stop it from attempting STS-based credential vending (a known upstream limitation — apache/polaris#379).AWS_ENDPOINT_URL_S3/AWS_ENDPOINT_URL_STSenv vars on thepolarisservice, both pointing at MinIO. Polaris' own internal S3 client (used for server-side commit bookkeeping, separate from the per-catalog client config) otherwise defaults to real AWS and fails with403 The AWS Access Key Id you provided does not exist in our records.MINIO_DOMAINon MinIO + a network aliasorderbook-warehouse.minioon theminioservice. Even with the endpoint overrides above, Polaris' internal client addresses buckets virtual-hosted-style (<bucket>.<host>) regardless of the catalog'spathStyleAccesssetting.MINIO_DOMAINteaches MinIO to recognize that style, and the alias makesorderbook-warehouse.minioresolve to the MinIO container.
None of this affects how the Spark client talks to MinIO (that path already used path-style
access via s3.path-style-access=true, set in example.PolarisSpark) — it's specifically about
requests Polaris itself makes internally when committing a table.
Iceberg's Spark maintenance actions (Phase 7, example.MaintainTables) read/write table data via
S3FileIO like everything else — except remove_orphan_files, which lists the table's storage
location using Hadoop's FileSystem API instead, and failed with
UnsupportedFileSystemException: No FileSystem for scheme "s3" until example.PolarisSpark also
registered a FileSystem for that scheme: the hadoop-aws dependency (pinned to the Hadoop
version spark-sql already pulls in transitively) provides S3AFileSystem, wired up via
spark.hadoop.fs.s3.impl and fs.s3a.* config pointed at the same MinIO endpoint/credentials as
the S3FileIO config above. rewrite_data_files and expire_snapshots didn't need this — only
the orphan-file sweep touches Hadoop's filesystem layer.