Lake table formats stop at "here are the files." A resident serving engine then reads those files and rebuilds an in-RAM index — every restart, from scratch. This is a design for closing that gap: a snapshot that ships its own serving index, so the Flight server memory-maps the data and the index directly instead of re-deriving them. Startup collapses from tens of seconds to near-instant; the reload gap disappears. And EnergyScope already ships a working v0 of it. Update — v1–v4 all ran (§11): ~6× faster load, mmap ~4× less RAM than resident, reload gap 30,825 failures → 0, live base+delta updates at 0.57 ms, and a Vortex tail tier (ELEC 59.6 M rows → 374 MB) with 44 ms cross-tier gather. Validated end to end; only the production deploy remains.
The two claims the design flagged as uncertain are now measured on PET (MS-02), production untouched. Full detail in §11; reproducible scripts + record in experiments/serving-snapshot/.
| Claim | Result |
|---|---|
| v1 — mmap+sidecar load vs read+reindex | load ~6× faster (0.24 s vs 1.5 s) · RAM ~16× less (145 MB vs 2,314 MB) · query latency neutral |
| v2 — atomic swap vs unload→sleep→load | reload gap 30,825 failed queries → 0 · 1.2 s outage → a 169 ms latency ripple, no downtime |
| v3 — base+delta+merge-on-read liveness | new periods + revisions correct through compaction · merge cost 0.57 ms (only where a delta exists) · compaction 3.2 s |
| v4 — Vortex tail tier + cross-tier gather | ELEC 59.6 M rows → 374 MB Vortex (7× smaller) · both tiers = 778 MB RSS · cross-tier gather 44 ms/1,400 series, correct |
Verdict: project, not footnote — validated end to end (v1–v4). Every claim the design made is now measured. Remaining is only the production deploy.
An Iceberg or Delta snapshot answers one question perfectly: which files are the table right now? It says nothing about how to serve that table with sub-millisecond latency — because that was never its job.
To serve, EnergyScope's Flight server takes the files a snapshot names and does substantial work at startup:
series_index — series_id → a pre-sorted contiguous row range — with Polars.latest_index, the category tree, and precompute period_norm.That indexing is the price of O(1) serving — and it is rebuilt from scratch on every restart measured: the resident footprint is ~49 GB across 2.82M series; the live server reports ~4.3 s of eager index-build timing even with lazy year-partitioning deferring most data (the pre-lazy eager-load figure was ~22 s, itself down from 52 s) — and lazy loading only moves that cost, since the first query to a cold dataset then pays the load tax; the dict-encode step exists because raw string columns dwarf their encoded form (660 MB → 253 MB on the base dataset). Worse, the same work is partially redone on every dataset reload — and because there is no atomic handoff between "new files on disk" and "new index in RAM," a reload briefly unloads the dataset. That unload→load window is the serving gap called out in the code review.
The snapshot hands you files; the serving index is a separate, expensive, volatile derivative the server must rebuild every time. The idea here is simple: persist the index into the snapshot, so loading is a memory-map, not a rebuild.
This isn't a leap into the unknown. The recent-tier prewarm in the Flight server is, structurally, exactly this idea — built as a hot-path optimization, never framed as a serving snapshot.
Each dataset already has, on disk, next to its year partitions:
| File | Is | PET, measured |
|---|---|---|
recent.parquet | a single flat, sorted data file | 9.2 MB |
recent_index.json | the persisted serving index: series_id → [start_row, end_row] | 5.9 MB · 127,520 entries |
At query time the server reads the index and does table.slice(offset, length) — a zero-copy view, not a scan. Its own comment records the payoff measured: slice() is ~0 ms where take() costs ~600 ms on a 14.8M-row EUROSTAT extract, because slice respects the Arrow chunk structure instead of gathering across it.
Data file + a persisted series_id → (offset, length) index + an O(1) slice to serve. That is the serving snapshot, scoped to the recent tier. The whole design below is: generalize this from "the recent rows" to "the whole dataset," version it as a snapshot, and make it the primary load path — not a side cache.
The v0 even surfaces a design lesson. The sidecar is 64% the size of the data measured — absurd for an index — purely because it's JSON repeating the long series_id strings 127,520 times. A binary sidecar (an Arrow table of series_id dict-encoded + two int columns) would be a small fraction of that: the string keys, which dominate, dict-encode away. Lesson banked: the sidecar must be columnar/binary, not JSON.
A serving snapshot is a pair: an immutable, sorted data file, and a compact sidecar that is the index. A tiny manifest ties them together so the swap is atomic.
serving-snapshot/ (one immutable version of a dataset)
│
├─ manifest.json snapshot id · schema · sort key · pointers + checksums
│
├─ data.vortex rows SORTED by (series_id, period) → each series is
│ ONE contiguous range. mmap-friendly, ALP-compressed.
│
└─ index.arrow the serving index, columnar:
· series_id (dictionary)
· offset int64 — first row of this series
· length int32 — row count
· latest_period / latest_value — powers ES.Latest() with no scan
· period_min / period_max — date-range pruning per seriesTwo properties make the index trivially small and the load trivially cheap:
(series_id, period), a series isn't "scattered rows to gather" — it's a slice. The index degenerates to series_id → (offset, length), a few bytes per series. This is why the deferred date32 + sort schema migration is a prerequisite, not a parallel nicety: the sort is what makes the serving index a slice table instead of a gather map.series_index, latest_index, per-series date bounds — is written into index.arrow at snapshot-build time, once, by whoever cooks the data. The server never recomputes it.The whole value is in one substitution: replace "read files and rebuild the index" with "map files and read the index."
series_indexlatest_indexperiod_normmmap (or fast-open) data.vortexindex.arrow — it's already the indexseries_index at (offset, length)And because the new snapshot is built fully before the pointer flips, the load is atomic: the server serves the old snapshot until the new one is ready, then swaps. The unload→load reload gap is gone — not patched, structurally absent projected.
Serving latency itself doesn't need to change: a query is still index lookup → slice → filter → stream. What changes is that the index arrived ready-made, and (for the mmap'd portion) the data pages fault in from disk on demand rather than all sitting resident. That's the lever for the RAM reduction.
"Memory-map the data" is cleaner in the slogan than in reality. Whether a file is truly zero-copy-mappable, or fast-to-decode-but-not-mapped, splits the design into two tiers.
| Data file | Load mechanic | RAM | Trade |
|---|---|---|---|
| Arrow IPC | true mmap — the on-disk layout is the in-memory layout, zero-copy | pages fault on demand; OS reclaims under pressure | instant & zero-copy, but files are large (18× Parquet) |
| Vortex | read that decodes — small file, but ALP/FastLanes must decode to Arrow | decoded form is resident; ~10× smaller on disk | near-RAM decode speed, not literally zero-copy |
| Parquet | decode everything (dict + RLE + zstd) | full resident | tiny on disk, slowest to materialize — today's cost |
The honest reading of the Vortex benchmark: its reads are fast (60M-row selective query in 9–14 ms) but the write-up is careful to say "read to a materialised Arrow table" — i.e. a decode, not a raw mmap. So the pure zero-copy-map win belongs to Arrow IPC; Vortex's win is small-file + fast-decode + the index is still prebuilt.
Don't pick one — tier by heat. Arrow-IPC-mmap for the hottest resident series (the daily/weekly market core the desk actually trades: WTI, Brent, products — a few thousand series, small even uncompressed) → truly zero-copy, instant, always warm. Vortex for everything else served (EUROSTAT's 1.7M series, ELEC's 59.6M rows, and the broad middle) → 10× less disk, near-RAM decode on demand, and it need not all be resident. Both share one index.arrow. The serving snapshot's manifest just records which file each series lives in.
Vortex loses over object storage — its many small reads become network round-trips (measured 4.3× slower than Parquet over network S3). But EnergyScope serves from local NVMe (MS-02, VPS local disk); R2 is only the cold origin / distribution copy, never the serving path. So the one place Vortex is a bad choice is the one place EnergyScope doesn't serve from. On the local serving box, Vortex's object-storage weakness is irrelevant — which is why it isn't confined to "the tail" but is the default for served data at large, second only to Arrow-IPC on the zero-copy-hot slice.
Four layers, four formats, each chosen where its strengths line up. The manifest records which file a series lives in, so the query path is identical across them:
| Layer | Storage | Format | Why here |
|---|---|---|---|
| Zero-copy hot core | local NVMe | Arrow IPC (mmap) | the top market series (WTI, Brent, products) — small, always warm; needs true zero-copy, which only Arrow IPC gives |
| Served data at large | local NVMe | Vortex | EUROSTAT, ELEC, the broad middle — 10× less disk, near-RAM decode; local serving means the object-storage weakness never applies |
| Live delta buffer | RAM | Arrow (in-memory) | intra-day do_put rows — mutable, append-heavy; Vortex's write-once encoding tax is the wrong fit |
| Cold origin / distribution | R2 (object storage) | Parquet (under Iceberg) | portable, and the one place Vortex measurably loses — so the cold copy stays Parquet |
The dividing line isn't "big vs small" — it's what each layer needs: zero-copy (Arrow IPC), compact-fast-local (Vortex), mutable (in-RAM Arrow), or portable-over-network (Parquet). Three of the four live on the local serving box; only the cold copy is object storage, which is exactly why only it is Parquet.
A memory-mapped file wants to be read-only. do_put wants to mutate. Reconciling the two is the one genuinely hard design question — and its answer is a shape you'll recognize.
An immutable, sorted, mmap'd snapshot cannot accept a live row insert in place — inserting into the middle of a sorted file means rewriting it. So the update path can't touch the base snapshot. The resolution is standard database machinery:
do_put appends to a small, hot delta buffer — a plain in-RAM Arrow table (or Arrow-IPC append log), unsorted, cheap to grow. This is where today's data lands intra-day.index.arrow, atomic-swap, discard the delta. Nightly, or on a size threshold.Base + delta-log + periodic compaction, merged at read time, is exactly Hudi's Merge-on-Read and the LSM-tree Paimon is built on. The wishlist arrived at the same structure from the serving side that those formats arrived at from the ingestion side. That's reassuring: the hard part isn't unexplored — it's a well-trodden pattern, just applied to a resident server instead of a lake writer. It also means the delta-detection and revision semantics from the Iceberg deep-dive carry straight over.
The serving snapshot isn't a new fifth idea bolted on. It's the artifact that makes the other four earn their keep at once.
date32 + sort by (series_id, period) is the prerequisite — sorting is what turns the serving index into a slice table instead of a gather map.
the small, fast-decode file for the served tail — 10× less disk, near-RAM latency, no need to hold it all resident.
the atomic snapshot swap — build the new serving snapshot fully, flip one pointer, no torn read, no reload gap.
the one new piece of glue — index.arrow, the persisted derivative that no format ships today.
Read that list against the wishlist in the Iceberg doc: items 6 (pluggable hot/cold file encodings under one table), 7 (time-series-native primitives — the sort key, ALP, the composite key), and 8 (the serving-tier handoff) are not three separate asks — they're one artifact seen from three angles. The serving snapshot is the thing that, if built, closes all three. That is why this is the wishlist item worth prototyping: it's the keystone, and the other pieces already exist to support it.
The components are all off-the-shelf. The specific combination — a persisted serving index that a resident Flight server memory-maps from an open lake snapshot — appears to be the unclaimed seam. Here's the honest survey.
Not "nobody has ever persisted an index next to data" — Lance, DuckDB, and every DB do. The unclaimed part is the specific bridge: an open, mmap-able, time-series-sorted snapshot whose sidecar is precisely a resident Flight server's serving index, with a base+delta merge-on-read for liveness. EnergyScope is well-placed to build it because it's simultaneously a lakehouse store and a resident low-latency server — a combination that's rare enough that the bridge hasn't needed to exist. That makes it a credible small open-source contribution or write-up, not just an internal optimization.
Each stage is independently measurable and independently useful — you can stop at any rung and keep the gain. Measure before proceeding.
slice) vs load-by-reindex head-to-head on one dataset to bank the premise as a hard number.deliverable: a before/after table · effort: hoursindex.arrow sidecar. Add an mmap+sidecar load path to the Flight server behind a flag. Measure startup, RAM, and selective-query latency vs today.validates: the whole premise · effort: a weekenddo_put delta buffer + merge-on-read + nightly compaction for liveness.delivers: 10× RAM tail + live updatesThe section that opened these questions can now close most of them. Status after v1–v3 — resolved, partial, or still open:
| Question | Status | What we now know |
|---|---|---|
| Does mmap actually pay? | resolved | Direct A/B vs a resident Arrow table: mmap ~2× faster load (0.25 vs 0.55 s) and ~4.3× less RAM (171 vs 740 MB) even under a full scan — because mmap faults in only the columns and rows actually read. The central risk; settled yes. |
| Merge-on-read cost | resolved | v3: 0.57 ms (p99 0.65 ms) with a 5,002-row delta, paid only by series that have a pending update. Sub-ms at a realistic intra-day delta size. |
| Is the juice worth it? | resolved | The design hedged "if only one win lands…" — three landed: reload gap (30,825→0), load time (~6×), RAM (~4×), plus liveness. Not a marginal case. |
| Sidecar/data drift | approach validated | v2's build-then-atomic-swap is the mechanism (sidecar + data swapped together). Fully closing it needs a manifest checksum so a stale sidecar is detectable — a design requirement, not a measurement. |
| Cross-dataset queries | resolved | v4: a cross-tier query spanning PET (Arrow-IPC-mmap) + ELEC (Vortex) gathered 1,400 series in 44 ms, correct. The (offset,length) sidecar routes uniformly across tiers. |
| Vortex maturity | tier built & validated | v4: ELEC (59.6 M rows) as Vortex is 374 MB (7× smaller than Arrow-IPC) with cheap ranged slices via the sidecar; ~3× slower per-series than mmap (the decode cost — hence hot=Arrow-IPC, tail=Vortex). Still pre-1.0 / no .NET, but the server-side tail role is proven. |
The A/B ceiling (171 MB) reflects touching only the value column; real queries touch more. Quantify EnergyScope's true column-touch and series-access skew to predict the production saving.
v3's delta is a plain dict; concurrent do_put + reads need a lock or a concurrent structure. v3 was single-threaded — untested.
Puts arriving mid-compaction must route to a fresh delta while the old one is folded, then swap. The snapshot-the-delta-at-compaction-start step is untested.
period_norm is a hard shadow-key contractSurfaced by a real bug: the delta must key on period_norm, not raw period, and Dagster's do_put must supply it. Now baked into the code.
A half-written new snapshot must be ignorable — atomic rename / manifest pointer. The Iceberg atomic-commit point, again.
Uncompressed IPC is large (613 MB/PET). Bound the total, or reserve Arrow-IPC for only the true-hot core and use Vortex for the rest.
One weekend experiment decides whether this is a project or a footnote. Here's its exact spec.
(series_id, period), write one data.arrow (Arrow IPC) + one index.arrow (series_id dict, offset, length, latest_period, latest_value, period_min, period_max).mmaps data.arrow and reads index.arrow straight into series_index/latest_index — skipping the Polars build and dict-encode entirely.The beauty of starting here: it touches nothing in production (flagged, one dataset), reuses the sort the schema migration already wants, and turns the entire question from architecture debate into a table of three numbers. Measure, then build.
The experiment ran. Build: 174,749 series sorted → data.arrow 613 MB (uncompressed IPC) + index.arrow sidecar 6.7 MB binary (vs the JSON recent-index's bloat), in 2 s. Then old (read parquet + polars index + dict-encode) vs new (mmap + sidecar), each in its own process:
| Metric | OLD read+reindex | NEW mmap+sidecar | Δ |
|---|---|---|---|
| Load time (PET) | 1.5 s | 0.24 s | ~6× faster |
| Process RSS | 2,314 MB | 145 MB | ~16× less |
| 1-series query p50 | 0.001 ms | 0.001 ms | neutral |
| 1,433-series batch p50 | 1.4 ms | 2.1 ms | +0.7 ms (noise/faults) |
| RSS after querying 1,433 series | — | 148 MB | +3 MB — working set stays tiny |
Verdict: project, not footnote. Load ~6× faster and resident RAM ~16× lower, at neutral query latency — exactly the design's prediction. The underlying data is the same 613 MB either way; the win is that mmap faults pages on demand (querying 1,433 series grew the working set by only 3 MB) while the read-path holds it all resident plus ~1.7 GB of polars build-time allocator overhead. Honest framing: against OLD's true 613 MB of data the RAM win is ~4×; against its real process footprint it's ~16×. Both are real for a serving process.
A minimal Flight server loaded the PET serving snapshot via mmap; 8 concurrent workers hammered do_get(WTI) while a reload fired mid-flight — comparing the real server's unload → sleep(1) → load against build → atomic swap:
| Reload strategy | Queries failed | Max availability gap | ok-latency p50/p99 |
|---|---|---|---|
| OLD — unload → sleep → load | 30,825 | 1,185 ms | 5.8 / 13.6 ms |
| NEW — build → atomic swap | 0 | 169 ms | 6.0 / 14.2 ms |
The reload gap is gone — 30,825 failures → 0. OLD is a 1.2 s hard outage (queries fail with FlightUnavailableError for the whole unload→load window). NEW's 169 ms "gap" is not downtime — it's a single latency ripple as the fresh 613 MB file maps in; every query still succeeds. Steady-state latency is identical, so the swap costs nothing. This proves the operational win in a real server context (standalone, production untouched).
The last unproven piece: an mmap'd base can't take an in-place insert, so do_put appends to a small in-RAM delta (per-series, keyed by period_norm → free last-write-wins for revisions); a query merges base-slice + delta with the delta shadowing base; periodic compaction folds the delta into a new base and atomic-swaps (the v2 mechanism). All four checks passed:
| Check | Result |
|---|---|
| New period served | do_put of a new WTI period appears in the query ✓ |
| Revision shadows base | revise 2026-06-29 (71.87 → 170.87) — value changes, row count stable, no duplicate ✓ |
| Merge-on-read latency | series with delta 0.57 ms vs 0.012 ms fast-path (no delta) — 5,002-row delta ✓ |
| Compaction | folded 5,002-row delta into fresh 13.78 M-row base in 3.2 s, atomic-swap, delta cleared, revision persisted ✓ |
Liveness works, and the merge cost is paid only where it's due: the ~0.57 ms merge hits only series with a pending delta row; every other series takes the 0.012 ms fast path. Compaction (3.2 s for PET) is a background op off the serving path, run nightly or on a delta-size threshold — and correctness survives the swap. This is exactly the base + delta-log + compaction shape of Hudi's merge-on-read / an LSM, reached from the serving side.
The last two open items, closed together: a two-tier store — PET as Arrow-IPC (mmap, hot) + ELEC as Vortex (tail) — routed by the same (offset,length) sidecar, with a query gathering across both tiers.
| Result | Number |
|---|---|
| ELEC as Vortex (59.6 M rows, 765 K series) | 374 MB + 14.7 MB sidecar (~7× smaller than Arrow-IPC), built 14.3 s |
| RAM holding both tiers (~940 K series) | 778 MB — vs the production server holding it all resident in 49 GB |
| Per-series latency | PET arrow-mmap ~13 µs · ELEC vortex ~39 µs (3× — the decode cost) |
| Cross-tier gather (1,400 series, both tiers) | 44 ms, correct |
Both open items resolved. The Vortex tail is 7× lighter on disk with cheap ranged slices via the sidecar, and cross-tier gather works — the sidecar routes uniformly whether a series lives in the mmap'd Arrow-IPC hot tier or the Vortex tail. Vortex is ~3× slower per-series than mmap (decode vs zero-copy), which is exactly why the design puts the hot core on Arrow-IPC and the tail on Vortex. The design is now validated end to end (v1–v4) — every claim it made is measured. The only thing left is the production deploy: wiring the flagged mmap+sidecar load path into the live Flight server (:8815), a careful standalone step. Scripts + full record in experiments/serving-snapshot/.