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

nordisk/knut

.nornir/thund-reasoning.md

Þund — the Arrow-native streaming dataflow engine — reasoning

Class: reasoning. This file holds the why, the trade-offs, and the alternatives we rejected. The descriptive shape we actually run is in thund-design.md, which by rule mentions none of this. If you want to know "why not X?" — X is here. So is the honest status. Reuse: thund.uses = [pipelines] — the Spark backend reuses the knut-pipelines Spark Connect client; the IR is a strict superset of knut_pipelines::DataflowGraph.

Þund (Old Norse Þund, "the swelling/roaring river") is the river that flows in front of Valhöll — fitting for a streaming dataflow engine. It is the "Airflow killer" half of the pipelines story; knut-pipelines is the viewer, which Þund reuses and lowers onto. It sits alongside Bifröst (the table→graph fabric) as knut's second runtime.

The landscape — what we read, and what we steal from each

A wide read of the dataflow/orchestration world. The recurring finding: almost nobody is Arrow-native end to end — most engines touch Arrow only at the edges and pay a conversion tax. Only Arroyo and DataFusion carry RecordBatch internally, and each leaves a gap the other fills. That gap is Þund's opening.

Tool Model Batch / Streaming Arrow-native? Strength Pain What we steal
Arroyo Distributed dataflow DAG on DataFusion physical operators; key-partitioned subtasks Streaming-first (batching is a perf knob) Yes — RecordBatch internal; Arrow IPC over TCP (not Flight) Disaggregated state: incremental Parquet on S3, not RocksDB → cheap checkpoints, clean rescale SQL-only authoring (no programmatic API); no allowed-lateness knob Reuse DataFusion physical ops as streaming building blocks; S3-Parquet state; dual append/updating SQL semantics; single binary that scales to a cluster
DataFusion + Ballista Pull-based vectorized ExecutionPlan → SendableRecordBatchStream Batch engine over (un)bounded inputs; no native event-time/watermarks Yes, pervasively Embeddable Rust query engine; orthogonal Boundedness×EmissionType + SanityCheckPlan No streaming semantics in core; API churn The whole ExecutionPlan/stream backbone; Boundedness×EmissionType; Flight shuffle (do_get); add watermarks/windows/checkpoints as first-class
Estuary Flow capture→derive→materialize over Gazette journals Both ("right-time") No (JSON journals; Arrow only at Parquet edge) Exactly-once on object-storage log; replayable collections SaaS-only; row-oriented core Journal + recovery-log split; dedupe-by-UUID exactly-once; declarative reduction annotations
Materialize Differential dataflow (data,time,diff) + arrangements Streaming IVM No (Arrow egress only) Best-in-class incremental correctness, strict serializability RAM-bound arrangements (data,time,diff) collections + arrangements — but back them with Arrow buffers
RisingWave Actor-based streaming dataflow; Hummock LSM on S3 Both No (custom StreamChunk; Arrow at UDF/DataFusion edge, paying a tax) Durable S3-backed state; barrier checkpointing; Postgres-compatible DataChunk↔Arrow tax; spill immaturity S3-backed LSM state; Chandy-Lamport barriers; arrow-udf over Flight doExchange
Polars Lazy logical plan → morsel-driven push/pull async engine Out-of-core batch (not unbounded streaming) Arrow layout via forked polars-arrow Morsel-driven parallelism; lazy optimizer Not a true streaming engine; single-node core Morsel-driven async-state-machine operators; expression-fusion classification; two-stage lowering with auto fallback
Dagster Software-defined assets (declare the artifact, infer the DAG) Batch / micro-batch Partial (Polars/Delta I/O managers) Lineage + reconciliation + asset checks + freshness as first-class Heavy vocabulary The asset model itself; AutomationCondition; blocking asset checks; partitioned reconciliation
Airflow (3) Task DAG of operators; assets are trigger signals, not data Batch / event-triggered No (XCom through the metadata DB) Maturity, integration catalog Scheduler re-parses every DAG; data through the DB; lineage-blind The integration catalog — and everything to avoid
Prefect Runtime-evaluated @flow/@task; transactional rollback Batch / event No (pickle/JSON) Dynamic graphs; idempotent rollback SQLite-as-default scaling trap Runtime graph construction; transactional idempotency; work-pool pub/sub
Kestra Declarative YAML flows; per-language containers All triggers incl. realtime No (Amazon ION internal) One unified trigger abstraction; 1200+ plugins Untyped-string YAML logic Unified trigger abstraction; schema-backed (LLM-friendly) specs — but Arrow over ION
Temporal Durable execution by replay; append-only Event History Durable workflows (not data) No (opaque payloads) Transparent durability; audit trail Determinism constraint; bounded history Durable-execution-by-replay; append-only event log as canonical state + audit
Flyte Typed @task/@workflow, K8s-native, compile-time DAG Batch Parquet-on-wire (PyArrow supported) Compile-time typed DAG; content-hash caching K8s complexity The (byte-format, storage, type) transformer pattern — register zero-copy Arrow-IPC transport; content-hash caching
NiFi / Cloudera DataFlow Flow-based programming; processors + FlowFiles Both (continuous) No (opaque bytes; Python dict IPC) Per-edge back-pressure; provenance/replay; live editing JVM/GC ceiling Per-edge back-pressure as a primitive (the GATLING slow-sink problem); queryable provenance
Azure Data Factory / Fabric Pipelines + Mapping Data Flows (compiled to Spark) Batch No Connector catalog; visual transforms; OneLake shortcuts Cold-JIT Spark startup (~3–7 min) Copy-Job bulk/incremental/CDC toggle; zero-copy in-place (shortcuts); avoid the cold-cluster wait
Dremio — the Arrow cousin Distributed SQL lakehouse; semantic layer Interactive/batch MPP Yes, end-to-end — Gandiva SIMD on Arrow buffers + Flight/Flight SQL transport One columnar format storage→exec→wire; Data Reflections (transparent MV substitution) JVM-rooted; analytical only The end-to-end Arrow invariant; Flight + JDBC shim; transparent materialization substitution driven by the view graph
dbt + Fivetran ref()-derived SQL DAG (dbt) + EL ingestion (Fivetran) Batch / micro-batch CDC No (warehouse-pushdown / Parquet) dbt Fusion = Rust "comprehend, don't render" compiler; Fivetran schema-drift + CDC Pricing/lock-in; not orchestrators ref()-derives-the-DAG; Rust static-analysis thesis; schema-drift propagation + idempotent CDC

The anchor: Arroyo's strategy on DataFusion

Þund's native backend reuses DataFusion's planner + physical operators + expression evaluator and wraps the unbounded ones in streaming operator versions — exactly Arroyo's approach, which is the only proven way to get a Rust+Arrow streaming engine without re-writing query kernels. We carry DataFusion's orthogonal Boundedness × EmissionType so the planner mechanically rejects unrunnable streaming plans up front (its SanityCheckPlan rule), surfaced in Þund as ExecBackend::check (knut-thund/src/backend/mod.rs:ExecBackend::check). Concretely:

Why all-three-over-one-IR (why we do not pick one authoring model)

The brief's hypothesis was to make all three authoring surfaces lower to one IR, rather than choose a single model. That is the right call because each surface serves a different user without forking the model, and the IR stays the single source of truth:

  1. Typed Rust builder — the canonical, compile-time-typed form. The IR types are the builder (Pipeline::new(..).with_dataset(..).with_flow(..)), so no separate builder type is needed and there is nothing to keep in sync.
  2. Thin declarative DSL (RON) — authoring::dsl (feature dsl) is a pure serde (de)serialization of the same IR. It is config-as-code, the LLM-authoring surface, and what gets warehouse-historized (defs + runs). Because it round-trips losslessly through the IR's serde impls, there is no second model and therefore no drift — the law nordisk keeps relearning.
  3. facett graph editor — the visual node-graph editor reuses the existing knut-pipelines PipelineView; it edits the IR's (dataset, flow) graph directly and emits the same Pipeline. CLI/Rust/DSL/visual parity is thus structural, not bolted on.

Rejected: pick one surface. Rust-only excludes config/visual users and LLM authoring; DSL-only loses compile-time types; visual-only has no diffable text. Over-one-IR keeps every strength and a single source of truth — a pipeline can be drawn in facett, exported to RON, hand-tuned in Rust, and run on either backend, unchanged. The cost is that the IR must be a faithful superset of everything the surfaces express; we pay that willingly because it is the whole thesis.

Why an asset graph, not a task graph

The IR is datasets (nodes — what data should exist) and flows (edges — how each dataset is computed), Dagster's software-defined-asset model: declare the artifact, infer the DAG from declared inputs/outputs, then recompute only what is stale (reconciliation), not blind task-order re-execution. The Materialization::{Full, Incremental} knob (knut-thund/src/ir/dataset.rs) is exactly the reconciliation lever. A task graph encodes order and is blind to the data it touches, so it can only re-run; an asset graph knows the data and can reconcile. This is the single deepest reason Þund is not another orchestrator.

Why it kills Airflow

Airflow's DAG encodes task order and is blind to the data it touches, so it can only re-execute, never reconcile; in Airflow 3 even "assets" are trigger signals, not data. Data flows through the metadata DB (XCom), unfit for DataFrames. The scheduler re-parses every DAG every loop. It is Python-only. Þund inverts every one of those:

Spark Connect / SDP — the version reality (4.1 vs 4.2)

From primary-source research, the facts that gate the Spark backend (knut-thund/src/backend/spark.rs). These are the reason SparkBackend's capabilities look the way they do, and why they are pinned to a Spark tag:

Rejected: expectations as a lowered SDP feature (for now)

We could try to lower Expectations onto whatever SDP-adjacent constraint support exists. We don't: since expectations are not fully upstreamed in OSS 4.1, SparkBackend::capabilities reports expectations: false and the default check refuses the pipeline up front rather than lowering something that will silently no-op. The expectation stays in the IR; the native backend (knut-thund/src/backend/native.rs) runs it as a filter/abort operator on the RecordBatch stream. Better to reject loudly than to lower a lie — the SanityCheckPlan discipline applied to a capability gap.

The Iceberg↔Spark streaming GAP — and how we close it honestly

The whole point of the two-backend seam is that the one IR carries streaming knobs SDP-over-Connect cannot express, and that we neither fake them on Spark nor lose them silently. There are three honest moves, all landed:

  1. The native backend RUNS them. NativeBackend executes the event-time watermark (late-row drop, counted), the window close/emit/evict (tumbling/hopping/session), incremental accumulators (COUNT/SUM/MIN/MAX/AVG), barrier checkpoints + crash recovery, and CDC SCD Type1/Type2 — all on real DataFusion, proven equivalent to a recompute oracle (tests/streaming_microbatch.rs, 25 tests).
  2. The Spark path capability-reports the drop — per pipeline, per flow. SparkBackend::lowering_report(&pipeline) returns an SdpLoweringReport (backend/mod.rs) that names exactly which knob is dropped on which flow: watermark, window, non-default trigger, non-append output mode, expectations, and CDC (whole-flow on a 4.1 target; the SCD-Type2 history on a 4.2 target). This is the machine-readable half of what Capabilities states statically — and it exposes the silent-acceptance gap: check does not refuse a watermark/window streaming flow (SDP accepts it and runs it on Spark defaults), so without the report the loss would be invisible. Now it is not.
  3. The proof ties both ends over one IR. tests/iceberg_spark_gap.rs: one streaming IR → Spark check accepts it, lowering_report lists the watermark+window drops, and the SAME IR on the native backend drops the late row + closes the windows. What SDP drops, Native runs — red-when-broken on either side.

The deeper Iceberg-side gap (a true incremental append scan between two snapshots, the Spark/Flink streaming-read primitive) is missing from rust-iceberg upstream too (apache/iceberg-rust #1469, closed not-planned) and from our vendored skade/vendor/iceberg-arrow58 fork (it has single-snapshot time-travel snapshot_id() + hidden-partition transforms + writes, but no from_snapshot → to_snapshot range scan). Java's BaseIncrementalAppendScan is the model; the fork plan is in skade's .nornir. Until it lands, thund reads Iceberg via a full/single-snapshot scan and does incremental/CDC one layer up (bifrost's _change_type changelog + thund's CDC operator).

Status — honest

Build-out still ahead (honestly not yet wired): the S3-Parquet state backend for a distributed run (checkpoints are local-fs today), Flight shuffle across a cluster, warehouse historization of defs + runs, and the Iceberg incremental append-scan source (blocked on the iceberg-arrow58 fork above).

See also