Running a pipeline end-to-end — design
Class:
design· Function axis:f0one run, three frontends ·f1define→run→progress → the CLI/GUI/web viewers ·f2thesc://session seam ·f3the Airflow AIP-82 asset sink ·f4the native CDC / SCD merge operator ·f5the skade-sourced changelog cutover. Strict: this file states only the shape we run. The crate-local why lives inpipelines-reasoning.mdandthund-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 oversc://, how completions become Airflow asset events, and how a change-data-capture merge is applied natively over the single-sourced_change_typevocabulary. Builds onpipelines-design.md,thund-design.mdandbifrost-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:
- Synthetic replay (
connect = None):synthetic_run(web.rs:268) walks the DAG in topological order emitting one STARTING + one COMPLETED event per flow, so the demo page animates with no Spark server.delaypaces it (DEFAULT_REPLAY_DELAY = 450 ms,web.rs:128;Duration::ZEROin tests). - Live run (
connect = Some(url), featuregrpc):drive_live(web.rs:220) opens aPipelinesClientand foldsrun_updatesthroughdrive_updates(web.rs:248) — the pure translation core, unit-tested offline with a canned update stream (drive_live_translates_event_stream_to_snapshots).
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.
- Collection is synchronous (fits the sync sink seam):
on_event(airflow.rs:113) folds the event into aViewerState, thenscan_completed(airflow.rs:87) appends oneAssetEvent { uri, extra }the first time each dataset reachesNodeStatus::Done— URI{uri_prefix}{dataset},extracarryingdataset/rows/output_type/source: "spark-sdp". Each output emits at most once (emitted: BTreeSet); a failed output advances no state and emits nothing (its asset is not current). - Emission is a separate async step:
emit(airflow.rs:75) forwards the batch through anyAssetEmitter(e.g. bifröst's HTTPAirflowEmitter) — the same "derive events / POST them" split as bifröst's ownemit_round. The AIP-82 wire body is bifröst'sasset_event_body.
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):
- SCD type 1 —
ROW_NUMBER() OVER (PARTITION BY keys ORDER BY sequence_by DESC), keep rank 1 that is not a delete: the target is the latest non-deleted row per key (history overwritten). The_change_typecolumn is stripped from the output so the target renders like an ordinary table. - SCD type 2 —
LEAD(sequence_by) OVER (PARTITION BY keys ORDER BY sequence_by)derives__start_at/__end_atvalidity bounds (__end_at = NULLfor the current version); every version is kept, and a delete closes the open version without opening a new one.
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 private
DeltaPlanis deleted; bifröst now re-exports the canonicalskade::DeltaPlan(source.rs:355). ingest_delta(source.rs:443) callsskade::Table::read_changelog(from, to)(source.rs:473) — skade walks the manifest lineage (read_delta), tags appended rowsINSERTand equality-deleted identitiesDELETE(widening them to the table schema), and re-reads the wholetosnapshot as an INSERT stream on an overwrite/position-delete window. The_change_typesentinel isskade::change_type::COLUMN.- bifröst's contract is preserved by post-processing skade's
ChangelogBatch(source.rs:370): a pure-append or full-reload window flows through untagged (strip_change_type,source.rs:545) straight to the upsert pipeline; only a window that carries equality deletes keeps skade's INSERT/DELETE tags for the listener's changelog split. skade clearsdelete_fileson a full reload, soplan.delete_files.is_empty()is exactly "no CDC deletes to route" — the same branch bifröst took before the cutover.
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
- graphar-flight nested-Cypher Utf8 fallback (
graphar-flight/src/convert.rs): a non-scalar CypherValue(map / node / list) has no scalar Arrow type, socell_typefalls back toUtf8and the cell is rendered via itsDebugform — stringified, never dropped. Pinned byauto_nested_cypher_value_falls_back_to_utf8andcell_type_maps_nested_values_to_utf8. - xtask
build-wasm-guest(xtask/src/main.rs): a pure-Rust task that builds thewasm32-unknown-unknownguest undercrates/knut-bifrost/wasm-guest/(excluded from the native workspace), leaving the artifact where the#[ignore]dknut-bifrosttests/wasm_guest.rsexpects it. Best-effortrustup target add, then a--target wasm32-unknown-unknown --releasebuild, then a non-empty-artifact check. Closes TEST-GAP-2.
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) |