Þ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 inthund-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 theknut-pipelinesSpark Connect client; the IR is a strict superset ofknut_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:
- From Arroyo: disaggregated state as incremental Parquet on object storage (S3), not RocksDB; the dual append-only vs updating SQL semantics; a single self-contained binary that scales unchanged to a cluster. We improve on it with an explicit allowed-lateness knob (Arroyo drops late rows silently) —
Watermark::allowed_lateness_ms(knut-thund/src/ir/streaming.rs:Watermark) — and a real programmatic authoring API (Arroyo is SQL-only). - From DataFusion: the
ExecutionPlan/SendableRecordBatchStreambackbone and Flight shuffle — and we add what it deliberately omits (event-time watermarks, windowing, barrier checkpointing) as first-class. - From Materialize/RisingWave:
(data,time,diff)collections + arrangements backed by Arrow buffers; S3-backed LSM state; Chandy-Lamport barrier checkpointing for exactly-once; arrow-udf over Flight for polyglot UDFs. - From Dremio: the end-to-end Arrow invariant and Flight as the default transport (knut already has the Flight stack in
graphar-flight). - From Dagster: the asset model as the primary abstraction (below).
- From NiFi: per-edge back-pressure (count + byte thresholds) — the exact shape of nordisk's GATLING slow-sink law.
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:
- 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. - Thin declarative DSL (RON) —
authoring::dsl(featuredsl) is a pureserde(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'sserdeimpls, there is no second model and therefore no drift — the law nordisk keeps relearning. - facett graph editor — the visual node-graph editor reuses the existing
knut-pipelinesPipelineView; it edits the IR's(dataset, flow)graph directly and emits the samePipeline. 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:
- Asset graph, not task graph (Dagster) — declare the artifact, infer the DAG, recompute only what's stale. Incremental reconciliation, not blind re-run.
- Arrow end-to-end (Dremio) — one columnar format storage→exec→wire, Flight transport, zero-copy. No XCom-through-the-DB.
- Streaming first-class — watermarks/triggers/output-modes/windowing/CDC in the IR, run natively or lowered to SDP. No orchestrator does this.
- Pluggable backend — dev locally on the native engine, run on a cluster via Spark-SDP, unchanged. No cold-JIT-cluster wait for the inner loop (the ADF anti-pattern).
- Parity over one IR — Rust + DSL + visual + CLI all produce the same pipeline (the nordisk parity law), and every component emits its state as readable data for the screenshot-proof viz test matrix.
- Data-quality + provenance built in — expectations as blocking checks, runs historized to the warehouse as an append-only event log (Temporal/NiFi lineage), back-pressure per edge (NiFi / the GATLING law).
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:
- SDP GA'd in Apache Spark 4.1.0 (released 2025-12-16), under SPARK-51727 — not 4.0 (4.0.0 = 2025-05-23 does not contain it). Any "Spark 4.0 SDP" claim is wrong about GA.
- The wire target is
spark/connect/pipelines.protoatsql/connect/common/src/main/protobuf/spark/connect/— absent from branch-4.0. There is no new gRPC service: every pipeline op rides onSparkConnectService.ExecutePlan, wrapped inCommand.pipeline_command(field 19). The four-command dance isCreateDataflowGraph→DefineOutputper dataset →DefineFlowper flow →StartRun(then read the streamedPipelineEventResult). This is exactly whatknut-pipelinesalready implements, which is whySparkBackendreuses it rather than reimplementing. - What requires 4.2+: external sinks (
create_sink) and persistent views (SPARK-53651);AutoCdcFlowDetails(CDC) is 4.2-era on master.SparkBackend::spark_4_2()(knut-thund/src/backend/spark.rs:SparkBackend::spark_4_2) flips those capabilities on. - Expectations are documented on Databricks/Lakeflow but not yet fully upstreamed into OSS Spark 4.1 — hence
expectations: falseby default (knut-thund/src/backend/spark.rs:SparkBackend::capabilities) and the planner refuses to lower an expectation-bearing pipeline onto Spark (it keeps it in the IR; the native backend runs it). - Pin to a tag.
DefineOutput/DefineFlowfield names drift between 4.1.0, 4.1.2 and 4.2 master — always generate tonic stubs from the proto at the exact target Spark tag. - Arrow on the wire: Spark Connect transports results as Arrow-encoded batches over the
ExecutePlanResponsestream — so even the Spark path stays Arrow at the edge, keeping the end-to-end-Arrow invariant intact.
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:
- The native backend RUNS them.
NativeBackendexecutes 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). - The Spark path capability-reports the drop — per pipeline, per flow.
SparkBackend::lowering_report(&pipeline)returns anSdpLoweringReport(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 whatCapabilitiesstates statically — and it exposes the silent-acceptance gap:checkdoes 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. - The proof ties both ends over one IR.
tests/iceberg_spark_gap.rs: one streaming IR → Sparkcheckaccepts it,lowering_reportlists 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
- IR (datasets/flows/expectations, batch + streaming, Arrow-typed) — complete and validated (
validate/topo_order/to_sdp/SparkBackend::lowering_report,knut-thund/src/ir/pipeline.rs). - Authoring: typed Rust builder (the IR itself) + RON DSL (
dsl, lossless round-trip) + SQL (sql, SDP-style DDL via an sqlparser extension) + the facett graph editor (reusesknut-pipelinesPipelineView/facett_adapter). All four lower to the samePipeline. ExecBackendtrait +Capabilities/RunEvent+SdpLoweringReport.NativeBackendgenuinely RUNS batch + streaming (watermark/window/CDC/ incremental-agg/checkpoint-recovery) on DataFusion — no longer a stub.SparkBackendlowers to SDP + drives a real Spark Connect server under thesparkfeature (knut-pipelinesgrpc); with no server it returns an honest transport error after proving the lowering.spark_4_2()flips sinks / persistent views / CDC on.- Tests: the in-crate suite +
native_*/streaming_microbatch/iceberg_spark_gap/sql_frontendintegration lanes are green under--features native,spark,dsl,sql(the CInativelane covers the gated files). One IR proven on both backends (iceberg_spark_gap.rs).
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
- pipelines — the SDP/Spark Connect viewer Þund reuses and lowers onto (the four-command dance,
DataflowGraph). - bifrost — knut's table→graph CDC fabric; Þund's
FileDrop/Cdcsources share its lineage. - thund-design.md — the chosen shape, tersely.
- thund-guide.md — how to author + run.