skade — Streaming Feedback, Zero-Copy Efficiency, a Composable Trait, and OpenLineage
Status: design proposal (no code changed). Author: research agent, 2026-06-17. Scope:
skade-katalog(the embedded redb Iceberg catalog) +skade(the Arrow/Parquet data plane) + their integration with nornir-server's gRPC, knut's Flight SQL / Spark Connect, and OpenLineage→OpenMetadata. Decision owner: Rickard. Every claim below is grounded infile:line; the four workstreams are independent enough to land in any order, but a recommended sequencing is in the final section.Consumer: the streaming-feedback workstream below is the client-side "signal" for korp's ingest profiles — see
korp/.nornir/collectors.md§Deployment profiles (esp. P3korp-landing, Signal vs poll). Spark Connect is pull-only / Structured Streaming is Spark-side polling; skade streaming-on-commit is how a Rust client reacts to new data without polling.
Executive summary
-
Streaming feedback (replace polling). Every durable catalog mutation already funnels through one chokepoint —
Store::group_commit(src/store.rs:233) andrecord_commit(src/store.rs:111), which advance a monotoniccommit_seq. Add atokio::sync::broadcastof a smallCommitEvent { table, snapshot_id, commit_seq, ts }published after the redb commit lands and the L1 mirror is updated. Surface it over nornir-server as a new server-streaming RPCViz.SubscribeCommits, modelled exactly on the existingRelease.Progressstream (nornir-server.rs:917, protonornir.proto:287). The nornir vizbench_livepane stops polling every 1.5 s (viz/bench_live.rs:46,268) and instead repaints when abench_telemetry/bench_runscommit event arrives. Late subscribers get a resume cursor (currentcommit_seqsnapshot, then deltas), so this degrades cleanly to polling when no subscriber is attached. -
Zero-copy / zero-allocation. The read path is already excellent —
recast(skade/src/bridge.rs:101) is genuinely zero-copy on the matching-schema fast path, and L0/L1 hand backArc<TableMetadata>/Arc<str>(src/meta_cache.rs:75,src/pointer_cache.rs:80). The real, worth-it wins are on the write/commit path:record_commitallocates a fresh keyVecper commit (src/store.rs:99),persist_commitdoes ~5Stringclones per commit (src/catalog.rs:323-337), andread_deltacollects whole files intoVec<u8>then re-parses (skade/src/read.rs:165,205). The biggest single read-path win is adopting knut's Arrow C Data Interface zero-copy bridge (graphar-flight/src/export.rs:340) as skade's first-class output so a scan can cross an Arrow-major boundary (57↔56) without a buffer copy. A concrete ranked list (with "not worth it" honesty) is in §2. -
A composable
skadetrait. Introduce a small trait set —IcebergSource(scan → Arrow stream +subscribe()to commit events from §1),IcebergSink(append/commit → returns theCommitEvent), and aLineageobserver (§4) — implemented once forskade::Table. The same traits serve viz, knut-bifrost (which already polls Iceberg snapshots and bridges Arrow zero-copy,knut-bifrost/src/source.rs:651), and a Flight SQL / Arrow Flight front so Spark 4.2 SDP + Iceberg 1.11.0 can read/write skade tables. Version reality: Spark SDP enforces Spark Connect and is Spark-4.1+ / 4.2-preview (knut already speaks it,knut-pipelines/src/client.rs:199); the JVM engine talks to skade as an Iceberg REST catalog (the bench already has a REST shim,bench/src/rest_shim.rs:33) over a shared object store — not via the Rust trait. The Rust trait is for the Rust consumers (viz, knut, Flight). -
OpenLineage → OpenMetadata. A skade commit is a natural OpenLineage
RunEvent: table = dataset (namespace = "skade.<catalog>.<ns>",name = "<table>"), commit = run (runId= UUIDv7, COMPLETE on success),outputscarriesschema+outputStatistics{rowCount}facets, and column-level lineage comes from the composable trait's transform metadata when present. Emit at the same hook as §1 (post-commit), POST JSON to an OpenLineage HTTP endpoint (Marquez or OpenMetadata's OpenLineage ingestion). Sketch payloads in §4.
Single unifying insight: all four workstreams hang off one hook — the post-commit point inside group_commit/atomic_release. Build that hook once (§1) and §3/§4 are mostly wiring.
Workstream 1 — Streaming feedback when data lands
1.1 Current state (file:line)
skade has exactly one durable-commit chokepoint, and it is already instrumented with a monotonic sequence number:
Store::group_commit(src/store.rs:233-308) coalesces concurrent table-pointer advances into one redb write txn + one fsync. Afterwrite.commit()succeeds (src/store.rs:263) it publishes each op's mirror update to the lock-free L1 pointer cache (src/store.rs:267-272) and wakes the caller'soneshot(src/store.rs:274). This is the exact point a commit becomes visible.record_commit(src/store.rs:111-131) runs inside that txn, appends the immutable(table_key, snapshot_id) → metadata_locationrow toCOMMITS(src/store.rs:118), and bumpsMETA_COMMIT_SEQ(src/store.rs:122) — a durable, monotonic, never-reset commit counter.commit_seq()reads it back (src/catalog.rs:79).- The two non-
group_commitdurable paths that also need to fire events:create_table(src/catalog.rs:767-779),register_table(src/catalog.rs:916-927),atomic_release/atomic_release_raw(src/atomic.rs:121-139,src/atomic.rs:259-274). All callrecord_commitand then the L1 mirror write-through, so they share the same "event-worthy" moment. persist_commit(src/catalog.rs:317-369) is whatcommit_tableandupdate_tableroute through, and it ends ingroup_commit— so wiring the event intogroup_commitcovers single-table commits for free.
On the consumer side, the nornir viz polls:
viz/bench_live.rs:46—const RELOAD_EVERY: Duration = Duration::from_millis(1500);viz/bench_live.rs:268-276— the draw loop re-reads on a timer: local mode reads thebench_telemetry+bench_runswarehouse tables, remote mode calls the unaryViz.BenchTelemetryRPC (nornir.proto:799-846, all unary today), thenrequest_repaint_after(400ms).
And nornir-server already has the streaming primitive we need:
Release.Progressserver-streaming RPC — handlernornir-server.rs:917-943, protonornir.proto:287(rpc Progress (Empty) returns (stream ProgressEvent);). It builds atokio::sync::mpsc::channel(64), spawns a tailer, and returnsReceiverStream::new(rx)boxed as the response stream. Auth is a bearer-token interceptor (nornir-server.rs:2916); workspace routing is thenornir-workspacemetadata header.
So the streaming alternative is idiomatic for this codebase — we are not inventing a transport, only a new event source and one new RPC.
1.2 Proposed design
(a) skade-katalog: a commit-event broadcast.
Add an optional broadcast sender to Store, published from the same post-commit moment that already updates the L1 mirror. Use tokio::sync::broadcast (not watch): a watch only keeps the latest value and would coalesce a burst of commits into one observed state, which loses per-snapshot deltas that the viz/CDC consumers need. broadcast is lossy under slow consumers (it drops oldest), which is the correct backpressure policy here — combined with the resume cursor below, a lagging subscriber recovers by re-reading from commit_seq.
// src/store.rs — new
/// A durable commit became visible. Cheap to clone (Arc-backed strings).
#[derive(Debug, Clone)]
pub struct CommitEvent {
pub table_key: Arc<str>, // "{catalog}\x1f{ns}\x1f{table}" (internal key)
pub snapshot_id: Option<i64>, // None for snapshot-less commits (rare)
pub metadata_location: Arc<str>,
pub commit_seq: u64, // durable monotonic cursor (META_COMMIT_SEQ)
pub ts_micros: i64, // event publish time (post-fsync)
}
pub(crate) struct Store {
// ...existing fields...
/// `None` until a subscriber attaches; lazily created so the default
/// (no-subscriber) path pays nothing but one `Option` check.
events: Arc<ArcSwapOption<broadcast::Sender<CommitEvent>>>,
}
record_commit already returns the new commit_seq (src/store.rs:130) — thread it out of the CommitFn so group_commit can stamp it onto the event. In group_commit, immediately after self.pointers.insert(...) (src/store.rs:268), if events is Some, let _ = tx.send(event); (a dropped event on no-receiver is fine). Do the same in create_table/register_table/atomic_release.
Public catalog API (on RedbCatalog, mirroring commit_seq):
impl RedbCatalog {
/// Subscribe to commit events. The returned `commit_seq` is the cursor at
/// subscribe time: events with `commit_seq > cursor` are guaranteed to flow
/// through the receiver (subject to broadcast lag). A consumer that wants
/// "current state then deltas" reads everything up to `cursor` once (e.g.
/// resolve_many / a table scan), then drains the receiver.
pub async fn subscribe_commits(&self) -> (u64, broadcast::Receiver<CommitEvent>) { ... }
/// Replay durable commit events with `commit_seq` in `(after, now]` by
/// scanning the immutable COMMITS log — the catch-up path for a late or
/// lagged subscriber. (COMMITS is keyed by snapshot_id, so this adds a
/// small `commit_seq → (table_key, snapshot_id)` secondary index, work
/// item below, OR scans COMMITS and filters — acceptable for modest tails.)
pub async fn commits_since(&self, after: u64) -> Result<Vec<CommitEvent>> { ... }
}
Durability / replay. The broadcast channel is in-memory and lossy by design. Durability is already provided by the COMMITS table + META_COMMIT_SEQ (src/store.rs:77,82). The contract is: the broadcast is a low-latency hint; the durable log is the source of truth. A subscriber that receives RecvError::Lagged(n) calls commits_since(last_seen_seq) to backfill, then resumes the live stream. This is the standard "snapshot + tail" pattern and it is why we expose the cursor from subscribe_commits.
Replay needs a
commit_seq-ordered read.COMMITSis keyed by(table_key, snapshot_id_be)(src/store.rs:99), not bycommit_seq, socommits_sincecan't range-scan by sequence directly. Two options: (1) add a second redb tablecommit_log: u64_be(commit_seq) → (table_key, snapshot_id)appended inrecord_commit(one extra insert per commit, ~no fsync cost since it's in the same txn), or (2) for small tails, scanCOMMITSand filter on a per-entry seq if we also store it. Recommend (1) — it's the clean cursor and it's the same shape the RagnarStaticIndexcould later accelerate.
Backpressure & multi-writer. Multi-writer is already serialized by redb's file lock + group_commit coalescing, so events are naturally totally-ordered by commit_seq. Backpressure: broadcast drops oldest for slow consumers (bounded memory), and the cursor recovers correctness. No writer ever blocks on a slow subscriber — send on a broadcast never awaits.
Degrade to polling. If no one ever calls subscribe_commits, events stays None and the only cost is a relaxed Option load per commit. Consumers that prefer polling keep calling commit_seq() (already exists) as a cheap change detector — the design is strictly additive.
(b) nornir-server: a server-streaming RPC.
Add to nornir.proto, modelled on Release.Progress:
service Viz {
// ...existing unary RPCs...
// Server-streaming: push one CommitEvent per table commit landing in the
// workspace's warehouse. Replaces bench_live's 1.5s poll.
rpc SubscribeCommits (SubscribeCommitsRequest) returns (stream CommitEvent);
}
message SubscribeCommitsRequest {
string workspace = 1;
uint64 after_commit_seq = 2; // 0 = from-now; >0 = replay then tail
repeated string tables = 3; // optional filter (empty = all)
}
message CommitEvent {
string table = 1; // "ns.table" (de-keyed for the wire)
int64 snapshot_id = 2;
uint64 commit_seq = 3;
int64 ts_micros = 4;
int64 row_delta = 5; // see "row delta" note below
}
Handler shape is a copy of nornir-server.rs:917-943: resolve workspace from metadata, call catalog.subscribe_commits() (+ commits_since if after > 0), adapt the broadcast::Receiver into a ReceiverStream/BroadcastStream, map CommitEvent → proto CommitEvent, box it. Auth/interceptor unchanged.
Row delta. The catalog event has snapshot_id but not row count. Two ways to fill row_delta: cheap (read the snapshot summary's added-records from the new metadata, which skade already has parsed in L0) — recommended; or precise (sum record_count of the data files in the snapshot, which read_delta already computes, skade/src/read.rs:204). Use the summary; it's free.
(c) the viz use case, closed.
bench_live (viz/bench_live.rs) replaces its timer with: on pane open, call Viz.SubscribeCommits{ tables: ["bench_telemetry","bench_runs"], after: 0 }; on each event, request_repaint() and re-read just those tables (or, later, carry the delta rows in the event). Mid-run telemetry rows light up the UI within broadcast-latency (sub-ms locally) instead of up to 1.5 s. The same RPC serves the test pane (test_results table). This is the literal use case the prompt names.
1.3 Effort / risk
- skade-katalog broadcast + cursor: ~1 day. Low risk (additive, lossy channel, no hot-path cost when unsubscribed). The
commit_logsecondary table is the only on-disk change; it's append-only in the existing txn. - nornir-server RPC + viz rewire: ~1 day. Low risk;
Release.Progressis the proven template. - Tests (LAW: inject + assert real values): a test that commits N tables, has a subscriber, and asserts it observes N events in
commit_seqorder with the rightsnapshot_ids; a lag/replay test that drops the receiver, commits, thencommits_sincereturns the missed events. These go intests/commit_log.rs/ a newtests/events.rs, and feedfunctional_statusrows under--features testmatrix(matchingsrc/store.rs:434).
Workstream 2 — Zero-copy & zero-allocation efficiency review
I read every hot path. skade is already well-tuned; I rank opportunities by real payoff and call out the ones that are not worth it.
2.1 Read path — current state
- L0 metadata cache hands back
Arc<TableMetadata>on a hit (src/meta_cache.rs:75) — zero-copy, ~100-300 ns, single-flight on miss. The only allocs are on a miss (fileio.clone()+loc.to_string(),src/meta_cache.rs:83-84), explicitly justified as dwarfed by the I/O. Leave it. - L1 pointer mirror returns
Arc<str>clones (src/pointer_cache.rs:53,80) and usesimbl::HashMapO(1) structural-sharing clones on write (src/pointer_cache.rs:111). Leave it. - Ragnar
StaticIndexstoresArc<[Entry]>/Arc<[i64]>(src/static_index.rs:49-50); lookups return refs. Butresolve_location_atandresolve_manythen.to_string()themetadata_location(src/catalog.rs:157,230,253). ThatStringis then handed to the L0 cache which keys byStringanyway, so the alloc is unavoidable given moka's owned-key API. Marginal — skip. recast(skade/src/bridge.rs:101-110) is the model citizen: itArc::clones columns whose type already matches the target and only invokesarrow_cast::caston genuine type changes. On the common path (caller built the batch against the table's field-id schema) it is fully zero-copy — it re-stamps the schema over the same buffers. Exemplary; leave it.
2.2 Read path — the one real win: a zero-copy Arrow output bridge
read_all/read_delta return Vec<RecordBatch> in skade's Arrow-57 world. The moment a consumer in a different Arrow major needs them — and knut is pinned to Arrow 56 (knut/Cargo.toml:25-28) — a naive bridge copies every buffer. knut solved this with the Arrow C Data Interface zero-copy bridge:
graphar-flight/src/export.rs:329-364—bridge_56_to_57(batch) -> RecordBatch:arrow_array::ffi::to_ffi(&col.to_data())exports to the stable#[repr(C)]FFI_ArrowArray/FFI_ArrowSchema, thenstd::mem::transmuteto the other arrow major's identically-laid-out FFI structs (the C ABI is fixed across versions), thenfrom_ffiimports — buffer pointers transferred, zero copy, with thereleasecallback travelling along so memory frees exactly once.- The reverse
batch_57_to_56lives inknut-bifrost/src/source.rs:728-757and is already how knut ingests skade Iceberg reads into the graph (knut-bifrost/src/source.rs:651,685).
Proposal: make this bridge a first-class, owned part of skade rather than something each consumer re-implements against knut's copy. Add an opt-in arrow-bridge feature to the skade crate exposing to_ffi_batch(&RecordBatch) -> (FFI_ArrowArray, FFI_ArrowSchema) per column (or a RecordBatch-level helper), documented as "cross an Arrow-major boundary zero-copy; transmute on the consumer side per the C ABI." This is the canonical model the prompt asked for, and it makes the §3 Flight path zero-copy end-to-end. Effort: ~0.5 day (lift + test); risk: low but the unsafe transmute must stay gated behind the documented ABI invariant (the SAFETY comment at export.rs:342-346 is the contract to copy).
2.3 Write / commit path — the allocation hotspots (ranked)
-
persist_commitstring churn (highest-frequency, per-commit).src/catalog.rs:323-337clones the staged location and ident repeatedly:staged_metadata_locationis.to_string()'d (:323), thenstaged_loc = staged_metadata_location.clone()(:337),conflict_ident = ident.clone()(:336), plusbase_metadata_location: Stringflows in. For a high-rate ingest committing every few files (ingest_pipelined,skade/src/write.rs:461), this is a handful ofStringallocs per commit. Proposed change: movestaged_metadata_location/baseinto the closure by value (they're not needed after), and key the L1 mirror + commit onArc<str>so the post-commitpointers.insert(src/store.rs:267) doesn't re-Arc::froma fresh&str(src/pointer_cache.rs:94allocatesArc::from(loc)every insert). Threading anArc<str>frompersist_committhroughCommitOutcometo the mirror removes one alloc per commit. Effort: ~0.5 day; payoff: real under high commit rate. -
commit_keyper-commitVec(per-commit).src/store.rs:99-105allocates a freshVec<u8>(with_capacity, then two extends) on everyrecord_commit. It's small and short-lived. Proposed change (only if profiling shows it): aSmallVec<[u8; 64]>or a reused thread-local scratch buffer, sincegroup_commitruns commits sequentially inside one txn. Honest verdict: borderline. The redb insert that follows dominates; do this only if a commit-rate bench flags it. List it, don't rush it. -
record_commitdouble-geton META (per-commit).src/store.rs:121-129opensMETAand does twoget+ twoinsert. Fine — redb in-txn ops are cheap and it's correct. Not worth changing. -
read_deltawhole-file buffering (per-delta-read, potentially large).skade/src/read.rs:165(file_io...read().await→ whole manifest into aBytes/Vec) and:205(whole data file into memory, thenParquetRecordBatchReaderBuilder::try_new(bytes)). For large data files this materializes the entire file before decode. Proposed change: stream via the async Parquet reader (ParquetRecordBatchStreamBuilderover theFileIOinput's async read) so only row-group-sized buffers are live. This also alignsread_deltawith the windowed reader skade already has (parquet_io.rs:read_row_groups). Effort: ~0.5 day; payoff: memory, not CPU — matters for big CDC deltas, neutral for small ones. -
ingest_parallelholds all encoded files in memory (by design).skade/src/write.rs:315-323collects every encoded Parquet file before writing. This is documented as the trade vsingest_pipelined(write.rs:388), which already bounds memory via a channel. Not a bug — already has the zero-barrier GATLING alternative. Leave both; the law about GATLING save-stage is satisfied byingest_pipelined's single sequential writer (write.rs:435-465).
2.4 Not worth it (be honest)
- Eliminating the L0 miss-path clones (
meta_cache.rs:83) — dwarfed by I/O. StaticIndexBox<str>→Arc<str>(static_index.rs:36-37) — built once per compaction, read-only after; theBox<str>is fine.- Trying to avoid
Table::build()'s per-callObjectCachealloc — it's iceberg-rust-internal (plan.md:105), not skade's to fix; skade already routes metadata-only callers around it viaresolve_metadata(src/catalog.rs:99).
2.5 Summary table
| # | Path | File:line | Change | Payoff | Worth it? |
| 1 | commit | catalog.rs:323-337, pointer_cache.rs:94 |
thread Arc<str> loc through CommitOutcome, drop per-commit String/Arc::from |
per-commit allocs | Yes |
| 2 | read→export | skade (new) ← export.rs:340 |
first-class Arrow C-Data-Interface bridge (feature arrow-bridge) |
zero-copy cross-major output | Yes |
| 3 | CDC read | read.rs:165,205 |
async Parquet stream instead of whole-file Vec<u8> |
bounded memory on big deltas | Yes (memory) |
| 4 | commit | store.rs:99 |
SmallVec/scratch for commit_key |
tiny | Only if bench flags |
| 5 | read | meta_cache.rs:83, static_index.rs:36 |
— | negligible | No |
Workstream 3 — A composable skade trait (Spark 4.2 + Iceberg 1.11.0 via SDP + structured streaming)
3.1 Current state & version reality (file:line)
- skade is already a source/sink, just without a unifying trait: read =
Table::read/read_columns/read_delta(skade/src/table.rs:156-182), write =Table::append/ingest*(skade/src/table.rs:80-153), incremental =read_delta+DeltaPlan(skade/src/read.rs:105). The catalog is a realiceberg::Catalog(src/catalog.rs:408) plus the multi-tableatomic_release(src/atomic.rs:46) and a rawcommit_tablefor REST callers (src/catalog.rs:272). - Versions skade targets today:
iceberg = "0.9.1",arrow-* = "57",parquet = "57",datafusion = "52.2"(skade/Cargo.toml:23-29,60), tables written as FormatVersion::V3 (skade/src/warehouse.rs:19). - Versions knut targets today: Arrow/Parquet 56 (
knut/Cargo.toml:25-28), Spark 4.2.0-preview5 + Iceberg-Java 1.11.0 + Iceberg-Spark runtime 4.1 (knut/containers/spark-iceberg/Containerfile:16-19), Spark Connect via vendored protos + tonic 0.14 (knut-pipelines/build.rs:56-61,knut-pipelines/Cargo.toml:51). -
Web-confirmed version facts (2026):
- Iceberg 1.11.0 released 2026-05-19; stabilizes V3 (deletion vectors via Roaring bitmaps, Variant, native geospatial, ns timestamps), adds server-side scan planning in the REST catalog, defaults to Spark 4.1 / Flink 2.1.
- Spark 4.2 is in preview4/preview5; SDP requires Spark Connect ("API mode enforced to Spark Connect, requires a remote origin"). SDP datasets/flows are the declarative graph Spark executes.
- iceberg-rust public latest is 0.8.0 (2026-02); schema-evolution
TransactionActions only partially exposed. skade's"0.9.1"pin is ahead of the public crate — it resolves a local/sibling build (the workspace[patch.crates-io]for iceberg-datafusion atskade/Cargo.toml:103confirms local patching is in play). Flag: confirm whether iceberg-rust 0.9.1 is a real upstream tag or a local fork; the trait must not assume APIs the public crate lacks.
The reality gap, stated plainly: Spark 4.2 (JVM) does not link a Rust trait. SDP + structured streaming reach skade through (i) an Iceberg REST catalog skade serves (the bench rest_shim.rs:33 is the seed; Iceberg 1.11's server-side scan planning makes this even more attractive) backed by a shared object store (skade already supports S3/MinIO, skade/src/warehouse.rs:82, skade/Cargo.toml:44), and optionally (ii) Arrow Flight SQL for live push/pull. The Rust trait below is for the Rust consumers (viz, knut-bifrost, a Flight server); Spark interop is a deployment (REST + S3), not a trait impl.
3.2 Proposed trait set
Keep it small, async, and composable. Put it in skade (the data plane), not the catalog crate, so it can name Arrow/DeltaPlan types.
/// A readable Iceberg dataset: scan to Arrow, incremental delta, and a live
/// subscription to commits (from Workstream 1). Object-safe via boxed streams.
#[async_trait]
pub trait IcebergSource: Send + Sync {
fn ident(&self) -> &TableIdent;
fn arrow_schema(&self) -> Result<ArrowSchemaRef>;
fn current_snapshot_id(&self) -> Option<i64>;
/// Full or projected scan as an Arrow batch stream (back-pressured).
async fn scan(&self, projection: &[&str])
-> Result<BoxStream<'static, Result<RecordBatch>>>;
/// Incremental: only rows added between `from` and `to` (+ the plan).
async fn scan_delta(&self, from: Option<i64>, to: i64)
-> Result<(BoxStream<'static, Result<RecordBatch>>, DeltaPlan)>;
/// Live commit subscription for this table (Workstream 1). Yields the
/// snapshot id + cursor each time this table advances; the consumer then
/// calls `scan_delta(last, snapshot_id)`. `None` if the backend can't push.
async fn subscribe(&self) -> Option<BoxStream<'static, CommitEvent>>;
}
/// A writable Iceberg dataset: append/ingest Arrow, returning the commit event
/// so a caller can chain lineage (Workstream 4) or wake a downstream source.
#[async_trait]
pub trait IcebergSink: Send + Sync {
fn ident(&self) -> &TableIdent;
/// Append one snapshot; returns the CommitEvent it produced.
async fn append(&mut self, batches: &[RecordBatch]) -> Result<CommitEvent>;
/// Bulk ingest (the GATLING pipeline); returns stats + the final event.
async fn ingest(&mut self, groups: Vec<Vec<RecordBatch>>, files_per_commit: usize)
-> Result<(IngestStats, CommitEvent)>;
/// CDC equality-delete (skade already has this: delete.rs).
async fn delete_equality(&mut self, keys: &[RecordBatch], equality_cols: &[&str])
-> Result<CommitEvent>;
}
/// Optional: a transform that carries column-lineage metadata, so a
/// source→sink hop can emit column-level OpenLineage (Workstream 4).
pub trait Transform: Send + Sync {
fn apply(&self, batch: &RecordBatch) -> Result<RecordBatch>;
/// output col name -> input col names it derives from (None = opaque).
fn column_lineage(&self) -> Option<HashMap<String, Vec<String>>>;
}
impl IcebergSource + IcebergSink for skade::Table is a thin wrapper over the existing methods (table.rs:80-204) — scan wraps table.scan().to_arrow() (read.rs:22), append wraps append_with and reads back the new snapshot id for the CommitEvent. This is mechanical discoverability in the spirit of the project's Facet law: every readable/writable surface implements the trait, so a test matrix and a registry can enumerate them.
3.3 How it plugs into SDP + Spark Connect + Flight (and knut)
- knut-bifrost already consumes exactly this shape:
TableSource::fetch_new() -> Vec<RecordBatch>(knut-bifrost/src/source.rs:18-25) polls Iceberg snapshots and bridges Arrow zero-copy (source.rs:651,685,728). Re-expressIcebergSource::subscribe()so bifrost replaces its poll loop with the §1 push — same trait, now event-driven. - Flight SQL / Arrow Flight front (new, optional crate
skade-flight). knut'sCypherFlightService(graphar-flight/src/service.rs:113, serverserver.rs:186) is the precedent: a tonic Flight service that streamsRecordBatches. Askade-flightservice implementsdo_get=IcebergSource::scan/scan_delta, anddo_put=IcebergSink::append(knut already doesdo_put_statement_updategraph-ingest,service.rs:376). Because skade owns the §2.2 C-Data-Interface bridge, batches cross to Arrow-56 consumers zero-copy and to Arrow-Flight IPC without an extra copy. This is the composable hub: one trait, three transports (in-process Rust, Flight, REST). - Spark 4.2 SDP / structured streaming. SDP defines
Dataset/Flowgraphs (knut models them:DataflowGraph/Dataset/Flow,knut-pipelines/src/model.rs:88-200) that Spark executes via Spark Connect (knut-pipelines/src/client.rs:199-222). Spark reads/writes Iceberg through its Iceberg-Spark 4.1 runtime against a catalog. skade participates by serving an Iceberg REST catalog (productionizebench/src/rest_shim.rsinto askade-restcrate; Iceberg 1.11 server-side scan planning is a natural extension point) over shared S3/MinIO (skade/src/warehouse.rs:82). A Spark SDPDataset { format: "iceberg" }that reads/writes a skade table then works unchanged — and structured streaming's Iceberg source consumes the same snapshots skade'sread_deltaalready produces. knut-pipelines, which today drops result batches (knut-pipelines/src/client.rs:473), could instead point a Flow's output at a skade Flight sink for live capture.
3.4 Effort / risk
- Trait +
impl for Table: ~1 day, low risk (wraps existing methods). skade-flightcrate: ~2-3 days, medium risk (Flight protocol surface; but knut's service is a close template).skade-rest(productionized shim): ~2-3 days, medium risk (Iceberg REST conformance for the endpoints Spark exercises; shim already covers config/ns/ table CRUD + commit,rest_shim.rs:35-46). Server-side scan planning is a follow-on.- Version risk (the real one): confirm the iceberg-rust 0.9.1 pin vs public 0.8.0; confirm Spark 4.2 GA timing (preview today). The trait itself is version-agnostic; the Flight/REST fronts must track Iceberg-rust's evolving write API (deletion vectors, schema evolution actions land across 0.9→0.10).
Workstream 4 — OpenLineage → OpenMetadata
4.1 Grounding (web-confirmed spec)
A RunEvent (OpenLineage OpenLineage.json): top-level eventType (START|RUNNING|COMPLETE|ABORT|FAIL|OTHER), eventTime, run { runId: UUID(v7), facets }, job { namespace, name, facets }, inputs[], outputs[] (each a dataset { namespace, name, facets }), producer, schemaURL. Standard facets: schema (SchemaDatasetFacet: column names+types), columnLineage (ColumnLineageDatasetFacet: per-output-field inputFields[]), dataSource, output outputStatistics{rowCount,size}, run nominalTime/parent. Sending at least a START and a terminal (COMPLETE/FAIL) event is required. OpenMetadata, Marquez, DataHub all ingest these via HTTP or Kafka.
4.2 skade → OpenLineage mapping
| OpenLineage | skade concept | Source (file:line) |
Dataset namespace |
"skade://<warehouse_uri>" or "skade.<catalog>" |
warehouse.rs:57 / catalog.rs:51 |
Dataset name |
"<ns>.<table>" |
TableIdent, catalog.rs |
Dataset schema facet |
table current schema (Arrow/Iceberg) | read.rs:15 arrow_schema_of |
| RunEvent (COMPLETE) | a successful commit | store.rs:263 post-write.commit() |
run.runId |
UUIDv7 per commit (one append = one run) | new |
output outputStatistics.rowCount |
snapshot added-records (or sum data files) |
snapshot summary / read.rs:204 |
columnLineage facet |
Transform::column_lineage() when present |
§3.2 trait |
| inputs[] | upstream tables in a transform/atomic_release | atomic.rs:46 (multi-table) |
A commit = a RunEvent. For a plain append, inputs are empty and the output is the appended table (an ingest from outside the lake). For a transform hop (source table → Transform → sink table) inputs = the source dataset(s), outputs = the sink, and columnLineage is filled from Transform::column_lineage(). For atomic_release (multi-table release, atomic.rs:46), emit one run with all N tables in outputs[] — the atomic batch is one logical job run, which is semantically perfect for OpenLineage.
4.3 Emit point & transport
Hook: the exact §1 post-commit point. A Lineage sink trait (mirroring knut's PipelineEventSink, knut-pipelines/src/viewer.rs) subscribes to CommitEvents (it's just another consumer of the broadcast — no extra coupling) and POSTs JSON.
#[async_trait]
pub trait LineageSink: Send + Sync {
async fn emit(&self, event: &RunEvent) -> Result<()>;
}
/// HTTP transport to Marquez / OpenMetadata OpenLineage endpoint.
pub struct HttpLineageSink { endpoint: String, http: reqwest::Client }
Wire it as a background task: let (cursor, mut rx) = catalog.subscribe_commits(); then for each event build a RunEvent and sink.emit(). Because it rides the broadcast, lineage emission never blocks the writer and a lagged sink backfills via commits_since (same durability story as §1). Transport options: direct HTTP to OpenMetadata's OpenLineage ingestion endpoint, or to Marquez (the reference store) which OpenMetadata can also read. Producer string: "skade/<version>".
4.4 Example payload (a single-table append, COMPLETE)
{
"eventType": "COMPLETE",
"eventTime": "2026-06-17T12:00:00.123Z",
"producer": "https://codeberg.org/nordisk/skade/0.4.10",
"schemaURL": "https://openlineage.io/spec/1-0-5/OpenLineage.json",
"run": {
"runId": "018f...-7b2c",
"facets": { "nominalTime": { "nominalStartTime": "2026-06-17T12:00:00Z" } }
},
"job": { "namespace": "skade", "name": "append:main.bench_telemetry" },
"inputs": [],
"outputs": [{
"namespace": "skade://file:///var/lib/nornir/warehouse",
"name": "main.bench_telemetry",
"facets": {
"schema": { "fields": [
{ "name": "run_id", "type": "long" },
{ "name": "repo", "type": "string" },
{ "name": "cpu_pct","type": "double" }
]},
"outputStatistics": { "rowCount": 512 }
}
}]
}
For a transform hop, inputs[] names the source dataset(s) and the output gains a columnLineage facet: { "fields": { "cpu_pct": { "inputFields": [ { "namespace": "...", "name": "main.raw_samples", "field": "cpu_ns" } ] } } }.
4.5 Effort / risk
LineageSink+HttpLineageSink+ RunEvent builder: ~1.5 days. Low risk (additive observer of §1; no hot-path coupling). Needsreqwest(optional featurelineage) — keep it off the default build likesql.- Column lineage: only as good as
Transform::column_lineage(); for plain appends/ingests it's correctly omitted (spec allows). Marquez/OpenMetadata both accept dataset-level-only events. - Tests: commit a table with a subscribed
LineageSinkcapturing to aVec, assert a START+COMPLETE pair with the right dataset name androwCount(inject-and-assert per the LAW); a transform test asserting thecolumnLineagefacet.
Consolidated roadmap
graph TD
P1["Phase 1 — Commit-event hook (the spine)"] --> P1a["broadcast CommitEvent + commit_log cursor table (skade-katalog)"]
P1a --> P1b["subscribe_commits / commits_since API"]
P1b --> P2["Phase 2 — Streaming RPC (viz)"]
P2 --> P2a["Viz.SubscribeCommits server-stream (model: Release.Progress)"]
P2a --> P2b["bench_live: drop 1.5s poll, subscribe"]
P1b --> P4["Phase 4 — OpenLineage (rides the same broadcast)"]
P4 --> P4a["LineageSink + HttpLineageSink + RunEvent builder"]
Z["Phase 0 — Zero-copy quick wins (parallel, no deps)"] --> Z1["Arc<str> through CommitOutcome (catalog.rs:323)"]
Z --> Z2["first-class Arrow C-Data bridge (feature arrow-bridge)"]
Z --> Z3["read_delta async Parquet stream"]
P1b --> P3["Phase 3 — Composable trait + transports"]
Z2 --> P3
P3 --> P3a["IcebergSource/IcebergSink/Transform + impl for Table"]
P3a --> P3b["skade-flight (Flight SQL front; knut precedent)"]
P3a --> P3c["skade-rest (productionize rest_shim; Iceberg 1.11 server-side planning)"]
P3b --> P3d["Spark 4.2 SDP/structured-streaming via REST+S3, capture via Flight"]
Recommended order:
- Phase 0 (zero-copy, parallel, no dependencies): the
Arc<str>commit win and the first-class Arrow C-Data bridge. Independent, low-risk, and the bridge unblocks Phase 3's Flight path. - Phase 1 (the spine): the commit-event broadcast + cursor. Everything else hangs off this. Smallest change with the widest leverage.
- Phase 2 (viz): the streaming RPC +
bench_liverewire. Highest visible payoff, smallest incremental effort once Phase 1 exists. This is the use case the prompt centers on. - Phase 4 (lineage): add the OpenLineage observer — it's just another subscriber to Phase 1's broadcast, so it's cheap once the spine is in.
- Phase 3 (composability): the trait set, then the Flight and REST fronts. Largest surface, most external-version risk; do it last and let it consume the bridge (Phase 0) and the subscription (Phase 1).
Estimated total: Phase 0 ~1d, Phase 1 ~1.5d, Phase 2 ~1d, Phase 4 ~1.5d, Phase 3 ~5-7d. The first four phases (~5 days) deliver streaming + lineage + the zero-copy bridge; Phase 3 is the bigger, version-sensitive interop investment.
Cross-cutting LAWs to honor (from project memory):
- Every change adds a real inject-and-assert
#[test]to the matrix (tests/*.rs+--features testmatrixfunctional_statusrows, cf.src/store.rs:434). - Each feature ships
.nornirdesign + guide docs (this doc is the design; guides follow per phase). - Telemetry/events persist to the warehouse, not
/tmp; the durableCOMMITS+commit_logtables already satisfy this — the broadcast is the hint, the log is the truth. - GATLING zero-copy on produce→save is already met by
ingest_pipelined(write.rs:435); the Arrow bridge extends zero-copy to the output edge.
Open questions for the owner
- iceberg-rust version truth. Is
iceberg = "0.9.1"(skade/Cargo.toml:23) a real upstream tag, a local fork, or a pre-release? Public latest is 0.8.0 (2026-02). The Flight/REST write surface in Phase 3 depends on which writeTransactionActions exist. Please confirm the source of the 0.9.1 pin. - broadcast vs. an explicit ring buffer.
tokio::broadcastis lossy under lag (recovered by the cursor). Acceptable, or do you want a bounded durable ring in redb that guarantees no consumer ever needs the catch-up scan? (More work, more I/O; I recommend broadcast + cursor.) - Event granularity for the wire. Should
Viz.SubscribeCommitscarry the delta rows themselves (server readsread_deltaand ships batches) or just the snapshot id (client pulls)? Carrying rows is fewer round-trips but couples the stream to a row format; I lean id-only for the viz, rows for the Flight sink. Your call on the viz. - REST vs Flight as the Spark on-ramp. For Spark 4.2 SDP, do you want skade to serve an Iceberg REST catalog (Spark's native, supports server-side scan planning in 1.11) or push Spark toward Arrow Flight for live capture? They're complementary; which do we build first?
- Arrow major. skade is Arrow-57, knut is Arrow-56. The C-Data bridge handles the hop, but should skade also offer a native Arrow-56 build (feature flag) to avoid the bridge in pure-knut deployments, or is the zero-copy bridge always the answer?
- Lineage namespace scheme. Is
"skade://<warehouse_uri>"the right dataset namespace for OpenMetadata's taste, or do you want it to match an existing OpenMetadata service name so datasets land under the right service node?
Appendix — key file:line index used in this proposal
- Commit chokepoint:
src/store.rs:233(group_commit),:111(record_commit),:99(commit_key),:77/:82(COMMITS/META_COMMIT_SEQ). - Commit API:
src/catalog.rs:79(commit_seq),:272(commit_table),:317(persist_commit),:767/:916(create/register),src/atomic.rs:46/:157. - Read accel:
src/meta_cache.rs:75(L0),src/pointer_cache.rs:80(L1),src/static_index.rs:42(Ragnar),.nornir/plan.md:13-117. - Data plane:
skade/src/read.rs:22/105/165/205,skade/src/write.rs:158/272/388,skade/src/bridge.rs:101(zero-copy recast),skade/src/table.rs:80-204,skade/src/warehouse.rs:19/57/82,skade/Cargo.toml:23-29/60/103. - REST shim:
bench/src/rest_shim.rs:33-46/207. - nornir streaming precedent:
nornir-server.rs:917(Release.Progress),:2916(auth),proto/nornir.proto:287; viz pollviz/bench_live.rs:46/268. - knut zero-copy bridge:
graphar-flight/src/export.rs:329-364(bridge_56_to_57),knut-bifrost/src/source.rs:651/685/728; Flightgraphar-flight/src/service.rs:113,server.rs:186; knut Arrow 56knut/Cargo.toml:25-28. - knut-pipelines (SDP/Spark Connect):
client.rs:199/367/473,model.rs:88-200,progress.rs:21-208,build.rs:56-61,containers/spark-iceberg/Containerfile:16-19.