Streaming Parquet windows — the OSM/GeoParquet reuse surface
Class:
design· Function:parquet-stream· Uses:parquet_io,bridge. Only the shape that runs. This is the single-call, bounded-memory streaming read that a map/geo consumer (korp'sMapLayerOSM source) binds instead of re-parsing OSM/GeoParquet files.
Why this exists (consolidation law)
Per .nornir/consolidation-panels-maps-design.md §3: "OSM source → a MapLayer reading OSM/PMTiles (the constellation already decodes these: osm-katana / znippy-zoomies geo path, skade osm parquet). Reuse, don't re-parse."
skade already decodes OSM/GeoParquet at the record-holding ingest rates the Loki bencher measures (osm_ingest_skade_nvme ~1077 MB/s / 103M rows/s; zstd_decode_sys_zerocopy ~16058 MB/s @ 12/12 cores). korp must NOT grow a second parquet decoder — it binds skade's.
Before this change the reuse required a three-call dance (parquet_layout → rowgroup_windows → loop read_row_groups). That is the right primitive set for skade's own ingest, but it is not a clean seam for a downstream consumer: korp would have to re-implement the windowing loop, which is where a duplicate slowly grows. ParquetWindows collapses the dance into one iterator so the seam is a single bind.
The shape (one iterator, bounded memory)
// skade::ParquetWindows — public, in skade/src/parquet_io.rs
let (ice_schema, arrow_schema, windows) =
skade::ParquetWindows::open(path, window_rows, max_rows, rows_per_batch, threads)?;
for window in windows { // ExactSizeIterator: count known up front
let batches: Vec<RecordBatch> = window?; // ONE window, all-core (gatling) decoded
// korp: project geometry cols, build DrawFeatures for the viewport, drop.
// Peak memory is one window — never the whole nodes.parquet.
}
openinspects the file (no row data read), derives the Iceberg + Arrow schemas, and precomputes the windows. Returns them up front so the consumer can build its column projection / draw-feature mapping before streaming.- Each
next()decodes exactly one ~window_rows-sized window across up tothreadsno-barrier workers of the ONE fork-join engine (znippy-zoomiesgatling_forkjoin) — the SAME hot pathread_row_groupsuses, so it inherits skade's benched throughput with zero added copy.ParquetWindowsis a thin composition; it changes no hot code. max_rowscaps the stream (whole-row-group granularity) for a bounded viewport prefetch;usize::MAXstreams the whole file.- Batches keep the file's own Arrow schema — call
skade::recastbefore ingesting into a field-id Iceberg table. A pure map consumer that only draws geometry does not needrecast.
The footer is parsed once, not once per worker
read_row_groups fans a window's row groups across up to threads no-barrier gatling workers. Each worker decodes only its bucket's row groups, but every worker needs the file's Parquet footer/metadata (schema + row-group offsets) to build a reader. The naive shape — ParquetRecordBatchReaderBuilder::try_new per worker — re-opens the file and re-reads + re-parses the footer once per bucket (threads parses for a single window). On the bandwidth / light-CPU windowed decode that repeated parse is pure overhead: it buys nothing, since the metadata is identical for every worker.
The shape that runs parses the footer exactly once up front (ArrowReaderMetadata::load), then hands each worker the shared, Arc-backed metadata via ParquetRecordBatchReaderBuilder::new_with_metadata:
let meta = ArrowReaderMetadata::load(&File::open(path)?, Default::default())?; // ONCE
// per worker (own fd for an independent seek cursor; footer reused, not re-parsed):
let rdr = ParquetRecordBatchReaderBuilder::new_with_metadata(File::open(path)?, meta.clone())
.with_row_groups(bucket).with_batch_size(rpb).build()?;
Each worker still opens its own file descriptor (an independent seek cursor for its lazy per-row-group page reads), so peak memory stays one window — the file is never slurped whole. A single-bucket read (small input / bandwidth-bound) skips the fork-join fan-out entirely and streams on the calling thread. The decode itself is byte-identical; a white-box unit test asserts the footer is parsed once regardless of bucket count (FOOTER_PARSES == 1) and that the fan-out returns the exact same rows as a lean single stream (RED-when-broken).
What korp binds (and what it must NOT do)
| korp needs | binds | must not |
stream OSM node/way geometry from nodes.parquet |
skade::ParquetWindows::open |
re-open the file with its own ParquetRecordBatchReaderBuilder |
| all-core decode without a private thread pool | inherited (gatling) | spawn its own rayon/thread pool (rayon-free law) |
| bounded memory over billions of rows | window-at-a-time iteration | collect the whole file into one Vec<RecordBatch> |
| file schema before drawing | open's returned (Schema, ArrowSchemaRef) |
guess column layout |
korp's MapLayer::features(view) maps each streamed RecordBatch window into Vec<DrawFeature> for the viewport. The renderer stays source-agnostic; the OSM source is just "stream windows from skade + project to draw primitives". Another agent owns korp's map — this doc only guarantees the seam is clean and stable.
Deferred / noted
- Column projection.
ParquetWindowscurrently decodes all columns of the selected row groups (asread_row_groupsdoes). A map consumer often wants onlyid, lon, lat, tags. Awith_projection(&[&str])builder onParquetWindows(feedingProjectionMaskinto the row-group reader) is the natural next additive step; it is NOT built here because it touches the decode hot path and would need a HEAVY before/after bench. Filed for the korp-map agent to request once the seam is in use. - Duplicate primitive.
bench/src/data.rscarries its own byte-identicalparquet_layout/rowgroup_windows/read_row_groups(the bench crate is a detached workspace that predates theskadelib extraction;skade/src/parquet_io.rsis the "lifted" copy). Not consolidated here (the bench crate does not depend on theskadelib, and rewiring it risks perturbing the benched numbers) — noted so a future pass can point the bench atskade::ParquetWindowsand delete the twin. The parse-once optimization above was applied to both copies so theosm_ingest_*bench (data::read_row_groups) measures the improved path; the bench arm'sread_rows_per_secsub-metric is the observable and needs the Loki serial HEAVY pass (OSM GeoParquet file) to record the before/after.