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

nordisk/knut

.nornir/pipeline-run-design.md

Running a pipeline end-to-end — design

Class: design · Function axis: f0 one run, three frontends · f1 define→run→progress → the CLI/GUI/web viewers · f2 the sc:// session seam · f3 the Airflow AIP-82 asset sink · f4 the native CDC / SCD merge operator · f5 the skade-sourced changelog cutover. Strict: this file states only the shape we run. The crate-local why lives in pipelines-reasoning.md and thund-reasoning.md.

Scope: the cross-crate story added on autonom/knut-impl — how a Spark Declarative Pipeline is defined, run, and watched (terminal / egui / browser), how a session authenticates over sc://, how completions become Airflow asset events, and how a change-data-capture merge is applied natively over the single-sourced _change_type vocabulary. Builds on pipelines-design.md, thund-design.md and bifrost-design.md.

f0 — one run, three frontends (the whole)

A pipeline run is one merged stream folded into one ViewerState (progress.rs:240), and every frontend is a pure function of that state — the CLI text/JSON dump, the egui/snarl node graph, and now a browser page over Server-Sent Events. Nothing about the run changes when you change how you watch it; the web viewer can show nothing the CLI can't.

define_graph ─► start_run ─► PipelineRunEvent + ExecutionProgress
     (client.rs:354)  (client.rs:373)          │
                                                ▼
                          ViewerState::apply  (progress.rs:240)   ◄── one truth
                             │            │             │
                   dump_state│      DagViewer│     WebEventSink│
                   (viewer.rs)      (gui.rs)      (web.rs:56)
                    terminal         egui/snarl       browser / SSE

Two laws from pipelines-design.md still hold — CLI parity (show/dump_json is the floor) and one merged stream — and the web viewer is added under them, never beside them: it folds events through the same PipelineEventSink seam (web.rs:96) into the same ViewerState.

f1 — the web viewer (src/web.rs [feature web])

A third frontend: an axum server + SSE, serving facett's Rust facett-pipeline viewer compiled to wasm (the facet-wrapped bundle) and feeding it PipelineModel frames. knut draws nothing in JavaScript: the page is facet-wrapped's own loader, served as bytes from a run-time directory (FacetBundle; --facet-dist / FACET_WRAPPED_DIST), and every frame is to_pipeline_model encoded by the ONE codec, facett_pipeline::wire::to_json (asserted by index_serves_the_facet_wrapped_loader_not_a_drawing and events_stream_the_pipeline_model_wire_to_completion). Before lane SW row 6 (2026-09-05) this module hand-drew the DAG in SVG/JS; that is deleted, not flagged.

endpoint serves where
GET / 303 to the loader, /index.html?component=pipeline&events=/events (LOADER_URL); 503 + the missing-file list if there is no bundle web.rs index
GET /index.html, /facet_wrapped.js, /facet_wrapped_bg.wasm the bundle, verbatim, typed text/html / text/javascript / application/wasm web.rs bundle_file
GET /graph.json the DAG as JSON, reusing crate::dump_json web.rs graph_json
GET /pipeline.json one all-idle frame on the wire web.rs pipeline_json
GET /events an SSE stream of frames (wire_frame), one per run event, ended by {"done":true} / {"error":…} web.rs events

The event source is pluggable, the sink is shared. run_source (web.rs:195) drives one connection through a WebEventSink (web.rs:56) — a PipelineEventSink that folds each event into a ViewerState and broadcasts the resulting state_json over an mpsc channel. Two sources feed it:

The page (web.rs:292): fetch('graph.json') → layout() does longest-path layering over read → target edges and draws one g.node per dataset; an EventSource('events') recolours each node by node_status (st-idle / st-running / st-done / st-failed) and updates #meta / #log. A {"done":true} frame closes the stream.

The serve_on bound-listener seam

serve (web.rs:131) binds an address then delegates to serve_on (web.rs:152), which takes an already-bound TcpListener. That seam lets a caller bind("127.0.0.1:0"), recover the OS-assigned port via local_addr(), and then hand the live listener over — no bind race. The browser robot test uses exactly this to point fantoccini at the real server (robot_web_viewer.rs:63).

The browser robot test (tests/robot_web_viewer.rs [feature web, #[ignore]])

The browser analogue of robot_snarl_gui.rs: serve the real page on an ephemeral port with a fast synthetic replay (Duration::from_millis(50)), drive a real browser through fantoccini (WebDriver), and gate on a red/green Row matrix. dag_animates_to_all_done (robot_web_viewer.rs:53) asserts the page drew one #nodes g.node per dataset, every node reaches class st-done, #meta shows "N done", and #log names the terminal rollup flow — ≥ 8 steps, zero reds. It is #[ignore] because it needs a WebDriver at 127.0.0.1:4444 (chromedriver / geckodriver), so the default build never links it and CI runs it only when a driver is present.

f2 — the sc:// session seam (src/client.rs [feature grpc])

connect (client.rs:206) now threads the Spark Connect URL's session params instead of dropping them. The sc:// grammar is sc://host:port/;user_id=<u>;session_id=<s>;token=<t>;use_ssl=<b>;…; parse_sc_url (client.rs:552) splits host:port from the ;k=v tail into an ScConnect (client.rs:539):

param effect where
user_id sets UserContext.user_id on every request user_context (client.rs:230), stamped in pipeline_request/execute_sql
session_id pins the request session_id (else a fresh new_uuid) client.rs:214
token attached as authorization: Bearer <t> metadata via a tonic interceptor AuthInterceptor (client.rs:592)
others (use_ssl, user_agent, …) recognised-but-ignored (not session-shaping here) parse_sc_url match arm

AuthInterceptor (client.rs:592) implements tonic::service::Interceptor; a None token is a pure pass-through so unauthenticated servers are unaffected (auth_interceptor_sets_bearer_only_when_token_present). A plain http(s):// URL carries no params — generated session, no auth. The client's inner type is now an InterceptedService<Channel, AuthInterceptor>.

The execute_sql progress finding

execute_sql (client.rs:491) streams ExecutionProgress for any Spark SQL query, making the viewer a live Spark job monitor. The correction pinned on this branch: progress is server-driven, not requested. Spark Connect pushes ExecutePlanResponse.execution_progress (base.proto field 18) unconditionally while a plan runs, paced by the server-side spark.connect.progress.reportInterval config — there is no per-request toggle. ExecutePlanRequest.request_options (field 5) is documented "currently unused" and only carries ReattachOptions / ResultChunkingOptions / an extension Any, none of which control progress. So the request leaves request_options at its default (empty) and simply consumes the frames the server volunteers. The live integration test knutpipe_live_path_streams_spark_job_progress (tests/integration_spark_connect.rs:83) asserts progress snapshots actually arrive.

f3 — the Airflow AIP-82 asset sink (src/airflow.rs [feature airflow])

AirflowAssetSink (airflow.rs:39) is a PipelineEventSink that turns each SDP output completing into an Airflow asset event, so a downstream Airflow 3 DAG can schedule on a pipeline output instead of a cron. It reuses bifröst's model — knut_bifrost::airflow::{AssetEvent, AssetEmitter} — and this crate's ViewerState for attribution + status + row parsing; it duplicates neither.

f4 — the native CDC / SCD merge operator (knut-thund/src/backend/native.rs [feature native])

Previously a FlowKind::Cdc flow was reported honestly as deferred. This branch implements it natively over DataFusion. Dispatch is at native.rs:476: a CDC flow routes to run_cdc_flow (native.rs:710), which resolves the changelog from an upstream reads source and folds it into the target as an idempotent, key-matched merge ordered by cdc.sequence_by.

The IR (knut-thund/src/ir/flow.rs): CdcSpec { keys, sequence_by, apply_as_deletes, scd_type } (flow.rs:30); ScdType::{Type1, Type2} (flow.rs:18); FlowKind::Cdc { cdc } (flow.rs:66).

The changelog contract — apply_cdc_merge (native.rs:846) — consumes skade / knut-bifrost's shared _change_type vocabulary (CHANGE_TYPE_COLUMN, native.rs:699):

_change_type meaning merge effect
INSERT / UPDATE_AFTER new / post-image upsert (latest sequence_by per key wins)
DELETE tombstone removes the key (or the apply_as_deletes predicate)
UPDATE_BEFORE pre-image of an in-place update dropped (no net change)

The merge is a pure SQL rewrite over a registered changelog MemTable, with identifiers quoted via quote_ident (native.rs:828):

Micro-batch vs bounded. When the changelog source was seeded as a micro-batch stream, run_cdc_flow accumulates the changelog seen so far and re-merges each trigger — apply-changes is a pure function of the ordered changelog, so recomputation is correct and deterministic. With no seeded stream it runs once over the bounded changelog. Backend capability: only NativeBackend runs CDC honestly; SparkBackend gates it behind spark_4_2() (see thund-design.md).

Tests (knut-thund/tests/streaming_microbatch.rs): cdc_scd_type1_upserts_and_deletes (:375), cdc_scd_type2_keeps_version_history (:405).

f5 — the skade-sourced changelog cutover (knut-bifrost/src/source.rs [feature skade])

Before this branch, knut-bifrost's IcebergSource kept a private DeltaPlan struct and re-derived the CDC changelog itself (its own INSERT/DELETE tagging, delete-identity widening, full-reload fallback). The cutover makes skade the single source of truth:

The upshot: the _change_type changelog vocabulary is now defined once in skade and consumed identically by knut-bifrost (source side) and knut-thund (merge side, f4) — no divergent private copies.

Also on this branch

Feature-flag matrix

feature crate pulls in gates
web knut-pipelines axum, tokio, tokio-stream, futures-util the SSE web viewer (web.rs), knutpipe web, serve_on
web + grpc knut-pipelines + tonic 0.14 tree knutpipe web --connect (live run into the page)
grpc knut-pipelines tonic 0.14, prost, vendored proto PipelinesClient, sc:// params, AuthInterceptor, execute_sql
airflow knut-pipelines knut-bifrost (default-features off) AirflowAssetSink — reuses bifröst's AssetEvent/AssetEmitter
gui / live knut-pipelines egui 0.28 / + grpc snarl node graph / the live desktop viewer
native knut-thund datafusion, tokio, futures the CDC/SCD merge operator (run_cdc_flow, apply_cdc_merge)
spark knut-thund knut-pipelines/grpc IR→SDP lowering; CDC only on spark_4_2()
skade knut-bifrost skade IcebergSource + the re-exported skade::DeltaPlan / read_changelog

Dev-only: fantoccini (browser robot, web), async-trait + a minimal tokio (airflow-sink mock-emitter test). All default builds are unchanged — every surface above is off by default.

Architecture map

Area Where
Web viewer + SSE knut-pipelines/src/web.rs [web] (serve, serve_on, WebEventSink, synthetic_run, drive_updates, INDEX_HTML)
Browser robot knut-pipelines/tests/robot_web_viewer.rs [web, #[ignore]] (dag_animates_to_all_done)
sc:// session + auth knut-pipelines/src/client.rs [grpc] (parse_sc_url, ScConnect, AuthInterceptor, user_context)
Progress finding knut-pipelines/src/client.rs:491 (execute_sql) + tests/integration_spark_connect.rs:83
Airflow asset sink knut-pipelines/src/airflow.rs [airflow] (AirflowAssetSink, scan_completed, emit)
Native CDC/SCD knut-thund/src/backend/native.rs [native] (run_cdc_flow, apply_cdc_merge, CHANGE_TYPE_COLUMN)
CDC IR knut-thund/src/ir/flow.rs (CdcSpec, ScdType, FlowKind::Cdc)
Changelog cutover knut-bifrost/src/source.rs [skade] (read_changelog, re-exported skade::DeltaPlan, strip_change_type)
Cypher Utf8 fallback graphar-flight/src/convert.rs (cell_type)
wasm-guest build xtask/src/main.rs (build_wasm_guest)