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

nordisk/skade

.nornir/incremental-read.md

skade incremental read (read_delta) — the snapshot diff

Table::read_delta(from, to) reads only the rows appended between two snapshots — not the whole table. skade/src/read.rs.

Diagram: lineage walk → per-snapshot op guard → manifest-list classify (Data→inserts / EqualityDeletes→identity-deletes / PositionDeletes→full-reload) → DeltaPlan → consumers polling commit_seq() then read_delta(cursor, latest).

Why it exists

iceberg-rust 0.9.1 (what skade pins) has no incremental / changelog scan. TableScanBuilder only offers snapshot_id(id) (time-travel to one snapshot) — there is no from_snapshot_id/to_snapshot_id and no changelog procedure (that is Spark-only). So an Iceberg consumer that wants a streaming delta (read each new snapshot's new rows, not re-scan everything) has nothing to call. read_delta fills that gap, built from the manifest layer, pure Rust.

The first consumer is knut-bifrost's Iceberg → FalkorDB streaming ingest: poll the table, read_delta(cursor, latest), inject only the new rows into the graph, advance the cursor. The second is modgunn (Móðguðr), whose Engine B world-delta watcher polls commit_seq() then read_delta(cursor, latest) over the shared warehouse to track every change to the dependency world — modgunn is a leaf consumer of skade (never of nornir), so this is its only change-sensing path. (modgunn/.nornir/modgunn-master-plan.md §1 CDC seam.)

How it works (manifest diff)

  1. Lineage walk — from to, follow parent_snapshot_id() back to from (exclusive), collecting the new snapshot ids oldest-first.

  2. Operation guard — if any new snapshot's summary().operation is not Append (overwrite/delete/replace), set needs_full_reload and return no batches; an append-only file delta would leave stale rows behind, so the caller re-reads to in full.

  3. Operation set — Append (inserts) and Delete (identity/equality deletes) are both expressible as an additive delta. Overwrite/Replace rewrite rows in place — those set needs_full_reload.

  4. Manifest-list scan — load to's manifest list. Each ManifestFile has added_snapshot_id + content. For manifests added by a snapshot in the new set, parse the avro and classify each Added entry by its data-file content type:

    • Data (in a Data manifest) → added_files (rows to insert).
    • EqualityDeletes → delete_files (one EqualityDeleteFile { path, equality_ids }; the identity columns that define the deleted rows).
    • PositionDeletes → needs_full_reload (delete-by-offset can't be mapped to a graph identity, so re-read).
  5. Read — added_files → insert RecordBatches. delete_files are read on demand by read_equality_deletes(table, plan) → (equality_ids, batches) per file (the deleted-row identities). Both through the table's FileIO.

Returns (Vec<RecordBatch>, DeltaPlan); the DeltaPlan { snapshots, added_files, delete_files, needs_full_reload } lets a caller see/assert what the delta resolved to — inserts and deletes.

CDC deletes — both sides, pure Rust

iceberg-rust 0.9.1's Transaction only implements fast_append, which rejects non-Data content (validate_added_data_files), so it cannot write delete files. skade now writes them at the metadata layer instead:

Table::delete_equality(keys, equality_columns) (skade/src/delete.rs) writes an equality-delete parquet (the identity columns of the removed rows) + a v2 Deletes manifest + a manifest list that carries the existing manifests forward, then commits an AddSnapshot (operation = delete) + SetSnapshotRef through RedbCatalog::commit_table. The result is a real Iceberg equality-delete snapshot, read back by read_delta as a delete_file — a complete CDC delete with no Spark.

Limitations (honest)

Tests

skade/tests/delta.rs (no container, real local warehouse):

cargo test --test delta            # in skade/skade

SnapshotUtil parity in the vendored fork (vendor/iceberg-arrow58)

read_delta above resolves its snapshot lineage by delegating to the vendored iceberg-arrow58 fork's TableMetadata::snapshots_added_between (swarm #38, 2026-07-29) — it no longer reimplements the ancestor/parent-chain walk inline. The fork carries the general snapshot-range resolution as first-class TableMetadata methods — the metadata-layer primitives Java's SnapshotUtil exposes and that stock iceberg-rust does not ship in a released form:

method Java parity what it returns
snapshot_ancestor_ids(id) SnapshotUtil.ancestorIds ancestor ids newest→oldest, inclusive of id; cycle-guarded; unknown id → empty
snapshots_added_between(from, to) SnapshotUtil.ancestorsBetween the (from, to] ids oldest→newest (application order); errors if from is not on to's lineage
is_append_only_between(from, to) BaseIncrementalAppendScan precondition true iff every snapshot in (from, to] is Operation::Append

Tested in vendor/iceberg-arrow58/src/spec/table_metadata.rs (snapshot_range_resolves_append_lineage, overwrite_in_range_is_not_append_only) — an append lineage resolves, an overwrite spliced into the range flips the append-only check to false, and both guards (unknown to, a from off the lineage) are asserted.

Wiring (swarm #38 — dedup collapsed). read_delta now calls metadata.snapshots_added_between(from, to) for the (from, to] chain instead of walking parent_snapshot_id itself; this gives the primitive its first production caller and removes the duplicate lineage walk (one codepath). read_delta's needs_full_reload gate stays a local explicit loop over the resolved chain — it is Delete-tolerant (Append | Delete are both expressible as an additive/CDC delta; only Overwrite/Replace force a full reload) and is therefore deliberately wider than the fork's is_append_only_between (Append-ONLY). Wiring that stricter primitive into the gate would wrongly full-reload a pure-Delete window and shift observable behaviour for the knut-bifrost + modgunn CDC consumers, so it is intentionally not used there. The public signature of read_delta, the DeltaPlan shape, and read_changelog are unchanged; the delta suite proves the observable result is identical (read_delta_delegates_to_snapshots_added_between asserts plan.snapshots == snapshots_added_between(...) across an append range and an overwrite range).

Behaviour-identity note / two edge-case refinements. For every input a real CDC consumer passes (from = a prior snapshot that IS an ancestor of to on the branch, no expired ancestor mid-window) the delegation is byte-identical to the old inline walk. It refines exactly two unreachable-in-practice edges: a from that is not an ancestor of to now errors (the old walk silently returned the whole lineage — the #2337 footgun, latent re-stream-everything bug), and a dangling (expired) parent now truncates the walk rather than erroring. Both match the fork's Java-parity semantics and are not exercised by any consumer or test. Flagged for Rickard as a (strictly-more-correct) design decision, not a forced behaviour change.

Consumer verification (swarm #38). modgunn builds green against the changed skade (native path = "../skade/skade" dep) and its delta suite passes (cdc_delta_scans_only_newly_added_components et al. drive table_delta → read_delta). knut-bifrost consumes the published skade by deliberate policy (the removed [patch.crates-io] skade note in knut/Cargo.toml); a local path-patch to verify against the changed skade is blocked by an unrelated dual-iceberg-arrow58 version collision (knut pins published 0.9.8, the local fork vendors 0.9.1) — not a semantic shift. Since read_delta's public API/behaviour is unchanged, knut-bifrost is unaffected.

Why the fork carries this — the upstream iceberg-rust assessment (2026-07-29)

Checked apache/iceberg-rust before implementing (the "check upstream first" rule):

Fork plan / what's genuinely missing (reported, not yet in the fork):

  1. File-planning IncrementalAppendScan — turning the resolved id range into a planned set of added data files (the scan layer above these metadata primitives). skade already has an equivalent at its own layer in read_delta's manifest walk; upstreaming/adopting #2337's scan once it merges is the consolidation path.
  2. Changelog / net-changes scan (#1636) — CDC over the changelog view (INSERT/UPDATE_BEFORE/UPDATE_AFTER/DELETE). skade covers the append+equality- delete slice today via read_delta + delete_equality; the full changelog procedure is Spark-only upstream and remains a frontier for the fork.

The must-have for the Iceberg↔Spark streaming gap's native incremental path — resolve a correct, lineage-guarded snapshot range and know whether it is append-only — is the piece implemented here.