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

nordisk/skade

.nornir/cdc-stream-design.md

CDC · streaming · lineage — design

Class: design · Function: cdc-stream · Uses: store, commit, read-path, catalog. Only the shape that runs; the why + the ecosystem survey (Fluss/Paimon/Hudi/Iceberg-CDC) is in cdc-streaming-lineage.md and streaming-composability-lineage-proposal.md; the underlying read_delta diff is in incremental-read.md.

skade turns one durable commit chokepoint into a change-data-capture stream, a push consume API, and a lineage feed — four thin layers stacked on the redb catalog, each additive and independently feature-gated. Nothing here is a new scan path: it is re-tagging and broadcasting over the primitives that already exist (group_commit, record_commit, read_delta).

   catalog commit (group_commit)                      src/store.rs
        │  one redb txn, one fsync
        ├─▶ TABLES pointer advance + L1 mirror         (source of truth)
        ├─▶ COMMIT_LOG row  (durable, commit_seq-keyed)   ← durable replay
        └─▶ CommitEvent  ─broadcast──────────────┐        ← sub-ms hint
                                                 ▼
   Phase 1  read_changelog(from,to) ── _change_type-tagged windows   skade/src/read.rs
                                                 ▼
   Phase 2  ChangeStream::changes(from) ── live BoxStream, exactly-once cursor   skade/src/stream.rs
                                                 ▼
   Phase 3  LineageSink ── WarehouseLineageSink / HttpLineageSink   skade/src/lineage.rs
   Phase 4  Table::lookup(key, as_of) ── embedded delta-join probe  skade/src/read.rs

f0 — why this is cheap

Every catalog mutation already funnels through one durable chokepoint with a monotonic, gapless cursor: group_commit (store.rs:371) coalesces concurrent writes into a single redb transaction, and record_commit (store.rs:211) bumps META_COMMIT_SEQ (store.rs:169) and appends the immutable COMMITS log inside that same txn. The streaming spine is two additions to that chokepoint — a durable seq-keyed index and an in-memory broadcast — so it costs no extra fsync and the default (poll-only) build pays nothing.

f1 — Phase 0: the commit-broadcast spine (src/store.rs, src/catalog.rs)

The keystone every later layer subscribes to. A commit publishes a [CommitEvent] (store.rs:39) after the redb txn lands and the L1 mirror is updated:

field meaning
table_key internal "{catalog}\x1f{ns}\x1f{table}" (compute via commit_table_key)
snapshot_id Some for a data commit, None for a create / metadata-only pointer move
metadata_location where the pointer now points
commit_seq durable, gapless, monotonic cursor — the exactly-once resume key
ts_micros event time, stamped in the txn

Two durable/transient halves, written in the same write txn as the pointer:

Every pointer-advancing path publishes: group_commit (single-table), atomic_release / atomic_release_raw (atomic.rs, one event per advanced table), and the non-group_commit create_table / register_table paths (catalog.rs, fire their own event). record_commit now returns RecordedCommit { seq, event } (store.rs:197); the CommitOutcome (store.rs:103) carries the event to publish post-commit.

Public consumer surface on RedbCatalog:

method source behaviour
subscribe_commits() catalog.rs:108 (cursor, Receiver); subscribes before reading the cursor, so a commit racing the subscribe is delivered (and de-duped by > cursor) rather than lost
commits_since(after) catalog.rs:121 durable replay: range-scan COMMIT_LOG for commit_seq > after, oldest-first, gapless — the catch-up path after a Lagged
commit_table_key(ident) catalog.rs:94 the table_key for a TableIdent, so a consumer filters the broadcast without reaching into the crate-private key encoding
commit_seq() catalog.rs:79 the current global cursor

f2 — Phase 1: the changelog encoder (skade/src/read.rs)

A re-tagging layer over read_delta + read_equality_deletes, not a new scan. read_changelog(table, from, to) (read.rs:850) returns a [ChangelogBatch] (read.rs:788) whose every row carries a _change_type Utf8 op tag.

Table::read_changelog (table.rs:407) is the handle convenience.

f3 — Phase 2: push consume, ChangeStream (skade/src/stream.rs, feature stream)

Inverts CDC from poll (commit_seq() then read_delta) to push. ChangeStream::changes(from) (stream.rs:67, impl stream.rs:115) returns a 'static BoxStream<Result<ChangelogBatch>>:

  1. subscribe to subscribe_commits() before reading the current snapshot (no gap);
  2. from = Some(s) first yields a catch-up window read_changelog(s, current), then tails; from = None tails only future commits;
  3. on each broadcast event, filter to this table (commit_table_key), skip snapshot-less / already-seen events, and yield read_changelog(last, event.snapshot_id).

A producer→consumer hop is sub-millisecond and in-process — no Kafka, no network.

f4 — Phase 3: lineage emission (skade/src/lineage.rs, skade/src/warehouse.rs)

The event model + sink trait for "who did what, to which tables, between which systems, when". The types are always compiled (serde + async-trait, already deps); only the automatic emit hook is feature-gated.

f5 — Phase 4: the point lookup (skade/src/read.rs) + the eq-delete field-id fix

lookup(table, key, as_of) (read.rs:470, Table::lookup table.rs:455) resolves the single current row whose equality-key column(s) match, pinned to a snapshot (as_of = Some, time-travel) or the latest (None). It runs through the engine's merge-on-read path, so an upsert (delete-old + append-new) resolves to the new row and a deleted key resolves to None; the key is both pushed down (file/row-group pruning) and applied as an exact row filter. This is the Fluss/Flink delta-join idea — "externalize the join state, probe the latest at emit" — done embedded: skade's catalog is the point-lookup store, with no external state service.

The lookup surfaced a latent bug it also fixes: delete_equality (delete.rs:40) now stamps each equality-delete parquet column with its Iceberg field-id (PARQUET_FIELD_ID_META_KEY). Without it, a merge-on-read scan over an eq-delete table called arrow_schema_to_schema on a file whose columns lacked ids and errored "Field id not found in metadata" — a panic deep in the vendored engine's delete loader. Writing the ids at the write side is both the correct Iceberg on-disk form and the fix; skade is the canonical publisher of the fork, so it lands here.

Cycle-4 perf (bloom fast-path lookup_gatling). The append-only single- schema no-delete fast path was profiled (examples/lookup_bloom_bench.rs). The recorded target — the per-row-group bloom-probe loop — turned out to be a red herring: the Sbbf read + hash-check is ~31µs for 200 row groups (~3% of a lookup), and fanning it regressed by ~150× (a fresh gatling scope per lookup dwarfs µs of work). The genuine single-CPU costs, and their byte-identical fixes:

A/B on the light (contended 12-core) bench, 200 probes over 32 appends: ~2288ms → ~455ms (~5×), cores_busy 0.93 → ~7.5 (LOOKUP_CORES_FLOOR=1.3 is the RED-when-broken manifest-fan floor, asserted at ≥8 manifests). 1-file case ~175ms → ~158ms (pruning still trims the out-of-range probes). Byte-identity is asserted every probe in the bench, plus guards lookup_prunes_out_of_range_files_via_bounds (pruning skips the read: row_groups_seen == 0) and lookup_gatling_multi_manifest_byte_identical (the parallel manifest merge resolves the exact row per file). Authoritative HEAVY numbers are still owed on a quiet box.

Cycle-5 perf (target d — cross-probe caching + byte-range reads). Cycle 4 fanned the manifest parse_avro across cores, but a delta-join emit loop probing many keys against ONE snapshot still re-read + re-parsed every manifest and slurped every surviving data file whole per probe. The light bench's telemetry confirmed the manifest re-parse dominated (462ms/200 probes at cores_busy ~7.4 — the fan was firing on every probe). Both are now amortised, all byte-identical:

A/B on the light (contended 12-core) bench, 200 probes over 32 appends: ~463ms → ~5.7ms (~81×), 432 → 35 276 lookups/s, ~22.9 KB read/probe (a whole-file slurp is ~100 KB+/probe). cores_busy legitimately drops 7.4 → ~0.9 — the parallel parse now runs once on the cold probe, not per probe. Guard swap: the RED-when-broken invariant is now a bytes-read-per-probe ceiling (LOOKUP_BYTES_CEIL=49152, proven to fire) — a regression to a whole-file slurp or a defeated cache blows it; the old LOOKUP_CORES_FLOOR is retained but OFF by default (the hot loop is no longer parse-bound). Byte-identity is asserted every probe in the bench; the unit guards (lookup_gatling_skips_row_group_via_bloom, lookup_prunes_out_of_range_files_via_bounds, lookup_gatling_multi_manifest_byte_identical) all still pass unchanged. Authoritative HEAVY numbers are still owed on a quiet box.

Cycle-6 perf (target e — fd handle reuse + coalesced bloom reads). Cycle 5 killed the manifest re-parse and the whole-file slurp, leaving a hot probe's residual cost as per-probe fd setup. First verified (the recorded discipline) via new opens/probe + gets/probe telemetry on the light bench that the residual is genuinely setup / round-trip count, not I/O: a probe reads only ~23 KB yet issued ~0.75 fd opens + ~6.9 byte-range reads per probe, and on the local backend each FileRead::read is a seek+read syscall (~few µs) — so ~9.6 syscalls ≈ the whole ~27 µs/probe. Two byte-identical fixes:

A/B on the light (contended 12-core) bench, 200 probes over 32 appends: ~5.9ms → ~4.15ms (~1.42×), ~33.8k → ~47k lookups/s; opens/probe 0.75 → 0.000, gets/probe 6.86 → 1.555 (~4.4× fewer fd round-trips), bytes/probe 22 911 → 22 911 (byte-for-byte identical fetch). The modest wall-time gain is expected on a local page-cache bench (each read is a cheap syscall); the headline is the round-trip collapse, which on a real object store (each range GET = a network RTT) is the dominant latency. Byte-identity is asserted every probe in the bench; the RED-when-broken guards are extended with a per-probe GETs ceiling (LOOKUP_GETS_CEIL_X1000=3500, proven to fire — a lost-coalescing regression hits ~6.9) and a per-probe opens ceiling (LOOKUP_OPENS_CEIL_X1000=250, proven to fire — a defeated fd cache hits ~0.75), alongside the retained cycle-5 bytes ceiling. Unit guards (lookup_gatling_skips_row_group_via_bloom, lookup_prunes_out_of_range_files_via_bounds, lookup_gatling_multi_manifest_byte_identical, table_lookup_uses_bloom_fast_path) all pass unchanged. Authoritative HEAVY numbers are still owed on a quiet box.

Cycle-7 outcome (target f — predicate pushdown evaluated, NOT shipped; the point lookup is essentially optimal). With fd opens + bloom round-trips gone, a present probe was down to ~2 fd reads and the recorded hypothesis was that the pure-CPU decode+scan of the one bloom-surviving ~1000-row row group now dominates, to be cut by pushing the equality predicate into the reader (parquet RowFilter / RowSelection) so only the matching row materializes. Both halves were measured on the light bench (200 probes / 32 appends) with a new LOOKUP_ROWS_DECODED metric + a decode-vs-read split timer:

The point-lookup path is now essentially optimal: a present probe = ~2 fd reads

f6 — the OpenLineage HTTP sink (skade/src/lineage.rs, feature lineage-http)

HttpLineageSink (lineage.rs:500) maps each LineageEvent to an OpenLineage RunEvent (run_event, lineage.rs:548, public so it is testable without a live POST) and POSTs it JSON to a collector (Marquez/OpenLineage), so skade commits land in the same lineage graph as Spark/Airflow runs. eventType is COMPLETE (a skade commit is a completed run); run.runId is a deterministic UUID from the event id (run_id_uuid, two FNV-1a passes, no uuid crate); eventTime is RFC-3339 from ts_micros (rfc3339_from_micros, civil-from-days, no time-zone crate); datasets are versioned by snapshot id; commit_seq rides as a run facet. The blocking ureq call is fenced onto tokio::task::spawn_blocking so it never stalls the async runtime. Its own feature keeps ureq + serde_json out of every other build; enabling it does not imply the lineage emit hook.

The comparison — and skade's edge

capability Fluss Paimon Hudi Iceberg (Spark CDC) skade
changelog verbs (INSERT/UPDATE_*/DELETE) ✅ ✅ ✅ ✅ (create_changelog_view) ✅ same strings (change_type)
Log vs PrimaryKey table ✅ (2 table types) ✅ (2 table types) ✅ (COW/MOR) one table type ✅ derived from identifier-field-ids, one table
push consume Flink source Flink/Spark Flink/Spark Spark micro-batch in-process broadcast, sub-ms, ChangeStream
exactly-once cursor Flink checkpoint offset commit time snapshot id commit_seq / to_snapshot, ConsumerCursor
point lookup / delta-join external state svc LSM lookup index — embedded MOR probe (lookup)
lineage — — — OpenLineage (Spark integration) native emit + lineage_events table + OpenLineage POST
runtime JVM + coordinator JVM JVM/Spark JVM/Spark pure Rust, in-process, one file
state store RocksDB/LSM LSM index files — the redb catalog itself

skade's edge: the "log" and the "table" are the same embedded, single-file ACID Iceberg catalog. There is no separate log service (Fluss's coordinator, a Kafka/Debezium tier) and no separate state store (RocksDB/LSM) — the durable commit_seq is the log offset, the redb pointer is the current-state store, and the broadcast is an in-process sub-ms hint rather than a network hop. Everything is pure Rust, runs inside the consumer's process, and ships as one file on disk.

Public API surface + feature flags

skade-katalog (always on): CommitEvent, COMMIT_EVENT_BUFFER; RedbCatalog::{subscribe_commits, commits_since, commit_table_key, commit_seq}.

skade (types always on): read_changelog, ChangelogBatch, TableKind, change_type, lookup, DeltaPlan, EqualityDeleteFile, Scalar; Table::{read_changelog, read_delta, read_equality_deletes, lookup}; lineage LineageEvent, LineageSink, CapturingSink, DatasetRef, SystemRef, Operation, WarehouseLineageSink, lineage_events_schema, emit_release, LINEAGE_EVENTS_TABLE; Warehouse::lineage_sink.

feature default adds
stream off ChangeStream, ConsumerCursor, Table::{changes, changes_polling}
lineage off the automatic emit hooks on append/ingest*/delete_equality; Table::{with_lineage_sink, with_actor}, Warehouse::atomic_release_with_lineage
lineage-http off HttpLineageSink (OpenLineage POST); does not imply lineage

All three are purely additive; the default build compiles none of them and pays nothing on the write path beyond the one durable COMMIT_LOG row (no extra fsync).