Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/skade

.nornir/read-scan-decode-design.md

Full-scan decode — the read_all fast path

Class: design · Function: read · Uses: read.rs, warehouse, znippy-zoomies/gatling_forkjoin, parquet. Only the shape that runs. This is how Table::read (read_all → read_all_gatling) turns a plain append-only snapshot into Arrow batches without paying the Iceberg engine's per-file async to_arrow pipeline — and, as of the local-fd change, without slurping the whole dataset into memory.

The three-way decode

read_all picks the cheapest correct decode for the current snapshot:

  1. Fast gatling path (read_all_gatling, the common case) — a plain append-only, single-schema table with no delete files. The data-file set is resolved from the manifest list, then decoded straight to Arrow across all cores via the ONE fork-join engine (gatling), one worker per file, batches kept in file order. Bypasses the engine's per-file async to_arrow.
  2. Engine fallback — schema evolution (field-id remap) or any delete file (row-level merge-on-read). read_all_gatling returns None and read_all falls back to scan().select_all().to_arrow(). Correctness first: the fast path never drops a deleted row or mis-maps an evolved schema.
  3. Empty — no current snapshot ⇒ zero batches.

Inside the fast path: local-fd streaming vs whole-Bytes slurp

Once the data-file set is known, the decode itself has two byte/row-identical strategies — same Parquet reader, same default batch size, same per-file gatling fan-out, same file order — chosen by whether the FileIO backend is local:

read_all_gatling
  ├─ resolve data files (walk manifest list + parse manifests)
  ├─ all_local_paths(files)?              # every file a file:// / absolute path that exists
  │     ├─ Some(paths) → decode_data_local(paths)     # LOCAL  — stream from fd
  │     └─ None        → decode_data_bytes(file_io, …) # REMOTE — whole-Bytes via FileIO
  └─ batches (file order)

The gate is all-or-nothing (all_local_paths returns None if any file isn't a local file that exists), so a single scan never mixes the two strategies.

Why the local path exists (the waste it removes)

The pre-change fast path did decode_data_bytes for every backend: it read all data files' whole bytes into a Vec<Bytes> before decoding a single row — so peak RSS was the entire dataset, and the I/O was a serial pre-read in front of the decode. Against raw Parquet (which streams pages lazily from the fd) this both lost throughput and spiked memory on a full scan of a big local table. For a local warehouse — where the files are right there on disk — reading whole bytes into the heap first is pure waste. decode_data_local removes it while staying byte/row-identical to the fallback, so the S3 path is untouched.

The data-file resolution (manifest-list walk + Avro manifest parse) is inherent — a full scan must learn which files the snapshot points at — and is unchanged.

Correctness / equivalence

The two decoders emit the identical batch sequence (schema + values + order), guarded RED-when-broken by local_streamed_decode_is_byte_identical_to_bytes (streamed decode == the Bytes reference over a multi-file, multi-row-group fixture; reversing the file order fails it) and read_all_gatling_uses_local_streamed_path (a local warehouse takes the streamed path — LOCAL_STREAMED_READS counter — with no Bytes-slurp fallback, all rows intact). The read_all ↔ engine-fallback equivalence is covered by the existing roundtrip / data-skip suites.

Reuse (L5)

The file:///absolute → local-path rule is warehouse::local_path_of (pub(crate)), consumed by both the compaction data-dir logic and this read fast path — never re-derived. The all-core decode is the ONE fork-join engine (gatling), the same one read_delta / read_row_groups use — no private thread pool.