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

nordisk/knut

.nornir/pipelines-design.md

knut-pipelines — design

Class: design · Function axis: f0 one-model-many-frontends · f1 DAG model · f2 run state + job progress · f3 Spark Connect connector · f4 renderers · f5 GUI · f6 introspection · f7 facett adapter. Strict: this file states only the shape we run. Every why, every alternative, lives in pipelines-reasoning.md.

Reuse: pipelines.uses = [] — standalone (default build is pure model + CLI). The facett adapter (f7) is an optional outbound edge to facett-pipeline.

f0 — one model, many frontends (the whole)

knut-pipelines is a viewer for Spark Declarative Pipelines (SDP, Spark 4.x) and plain Spark Connect jobs. It ships the knutpipe binary. The whole design is one invariant:

ViewerState is the single source of truth; the CLI text/JSON view, the egui GUI, and the headless run API are all pure functions of it. A Spark Connect server streams three things — the compiled dataflow DAG, pipeline run events, and per-stage job progress — and PipelinesClient::run_updates merges them into one ViewerUpdate stream that ViewerState::apply folds into that one state (progress.rs:240). Every frontend renders the same state; none holds state of its own.

Two laws hold the loop:

law statement enforced by
CLI parity every capability the GUI shows is reachable from the command line viewer::dump_text/dump_json/dump_state (viewer.rs:28,:18,:88) mirror the GUI
one merged stream pipeline events and job progress fold through one apply run_updates → UpdateStream (client.rs:449), ViewerState::apply (progress.rs:240)

Heavy stacks are opt-in. The default build is pure model + CLI show. grpc (tonic 0.14 + vendored Spark Connect proto) and gui (egui/winit/glow) are cargo features; live = gui + grpc. egui/eframe are pinned to 0.28 for egui-snarl 0.4 compatibility.

f1 — the DAG model (src/model.rs)

A pipeline is a dataflow graph: datasets (nodes) + flows (edges).

item shape where
DataflowGraph { datasets, flows, default_catalog, default_database }; builder new/with_dataset/with_flow model.rs:186,:204
Dataset a node: name + OutputType (MaterializedView/StreamingTable/View/Sink) model.rs:88,:36
Flow an edge: name, target, reads: Vec<String>, optional query (with_query) — a flow with a query is runnable model.rs:133,:174
validate / topo_order acyclicity + a topological layout order for the viewer (not for execution — Spark schedules) model.rs:235,:272
PipelineRun { graph_id, full_refresh_all, storage, … } — the run request model.rs:317
PipelineRunEvent / EventLevel one lifecycle event; Info/Warn/Error model.rs:359,:345

f2 — run state + job progress (src/progress.rs)

ViewerState (progress.rs:188) is the folded truth: the DataflowGraph, a per-dataset NodeStatus, the event log, and the latest ExecutionProgress.

item shape where
NodeStatus Idle/Running/Done/Failed per dataset (label) progress.rs:126,:140
ExecutionProgress a job snapshot: StageProgress[], fraction, total_tasks/completed_tasks, bytes_read, all_done, summary progress.rs:54,:78
StageProgress one stage's task counts + fraction progress.rs:25,:41
ViewerUpdate the merge alphabet folded by apply (event | progress | …) progress.rs:397
ViewerState::apply fold one update; returns true on real change progress.rs:240
status_counts / topo_order / status_of derived views over the state progress.rs:313,:320,:230

f3 — the Spark Connect connector (src/client.rs [feature grpc])

A thin hand-shaped driver over the Spark Connect gRPC service — not JDBC, not the REST API, not Arrow Flight SQL. The one RPC that matters is server-streaming SparkConnectService.ExecutePlan. See assets/spark_connect_flow.svg.

Everything is an ExecutePlan. There is no bespoke pipelines RPC: every pipeline operation is a Command in a Plan on an ExecutePlanRequest, every answer arrives on the returned server stream (pipeline_request, client.rs:234).

direction proto field knut
request: pipelines command Command.pipeline_command (commands.proto field 19) pipeline_request wraps a PipelineCommand
response: command result ExecutePlanResponse.pipeline_command_result (field 22) graph id read back (client.rs:278)
response: pipeline event ExecutePlanResponse.pipeline_event_result (field 21) event_from_proto → PipelineRunEvent (client.rs:74)
response: job progress ExecutePlanResponse.execution_progress progress_from_proto → ExecutionProgress (client.rs:102)

The four-command dance. define_graph (client.rs:348) drives, in order: CreateDataflowGraph → returns a dataflow_graph_id (create_dataflow_graph, client.rs:254); DefineOutput once per Dataset (define_output, client.rs:295); DefineFlow once per Flow — a flow with a query becomes a runnable WriteRelationFlowDetails (define_flow, client.rs:320); then start_run against the graph id opens the event stream (client.rs:367).

One merged stream. run_updates (client.rs:449) returns an UpdateStream (type UpdateStream = Pin<Box<dyn Stream<Item = Result<ViewerUpdate>> + Send>>, client.rs:156); response_to_update (client.rs:122) keeps only events + progress and drops arrow batches / sql results. The progress channel is orthogonal: execute_sql (client.rs:475) streams ExecutionProgress for any Spark SQL query (a Plan with a root relation, no Command), so the viewer is a live Spark job monitor as well as a pipeline-DAG viewer.

URL + session. connect (client.rs:205) accepts sc://host:port[/;params] (translated by sc_url_to_http, client.rs:519) or plain http(s)://, opens a tonic Channel, and mints a per-connection session_id (new_uuid, client.rs:539) that rides every request.

Feature isolation. The generated bindings (proto::, client.rs:32) carry their own tonic 0.14 tree via the crate build.rs (tonic-prost-build, gated), so it never collides with the workspace's tonic 0.13 — the same trick knut-bifrost uses for spark-connect.

f3b — the Spark runtime + Structured-Streaming VIEW (src/spark_view.rs)

The backend API korp's Spark pane calls to see a live runtime — its version, the Structured Streaming queries running on it, each query's status + recent progress, and (the one control verb Connect exposes) to STOP one. Same ExecutePlan transport; different Command variants:

verb proto command folds to
runtime_info AnalyzePlan(SparkVersion) (a typed version string — no arrow decode) SparkRuntimeInfo { version, connect_url }
list_active_streams StreamingQueryManagerCommand.active → ActiveResult Vec<StreamingQueryInfo { id, run_id, name }>
stream_status StreamingQueryCommand.status → StatusResult StreamingQueryStatus { message, is_active, is_data_available, is_trigger_active }
stream_recent_progress StreamingQueryCommand.recent_progress → RecentProgressResult Vec<String> (the per-batch progress JSON, left undecoded to stay arrow-free)
stop_stream (control) StreamingQueryCommand.stop drains the confirmation

The model types (StreamingQueryInfo / StreamingQueryStatus / SparkRuntimeInfo, spark_view.rs) are neutral and always compiled — korp depends on them without pulling gRPC in; the live methods that fill them live on PipelinesClient behind grpc. Honest scope: cluster executor/worker topology is not on the Connect wire (it lives in Spark's separate REST UI), so this view deliberately covers only what Connect can answer and does not invent fields the protocol cannot fill. The live methods need a reachable Spark Connect server (no way to fake a cluster); the neutral fold is unit-tested (spark_view::tests) and the gRPC wiring is compile-verified against the vendored proto.

f4 — renderers (src/viewer.rs)

Pure functions of the model/state; the CLI-parity surface.

fn renders where
dump_json(&DataflowGraph) the graph as JSON (the spec format) viewer.rs:18
dump_text(&DataflowGraph) the static text view viewer.rs:28
dump_state(&ViewerState, tail) the live folded state (status + progress + last tail events) viewer.rs:88
PipelineEventSink / StdoutEventSink the run-event sink start_run writes into; format_line viewer.rs:171,:188,:222

f5 — the egui / snarl GUI (src/gui.rs [feature gui])

launch(state, connect) (gui.rs:493) / launch_live_pipeline (gui.rs:517) run an eframe app; DagViewer impls egui_snarl::SnarlViewer<NodeData> (gui.rs:59) to draw each dataset as a node coloured by NodeStatus, with a Spark job-progress panel and the event log. The background stream feeds through spawn_live (gui.rs:688) / spawn_pipeline (gui.rs:620), which consume the same UpdateStream as the CLI. KNUT_SCREENSHOT=<path> captures one frame and exits (gui.rs:496) — the headless GUI smoke path.

f6 — introspection (headless robot-testing)

The viewer's entire UI state is observable from outside — no display, no screenshot. Two always-on channels (mirrors nornir viz's urdr-threads):

  1. State dump. ViewerState::state_json() (progress.rs:335) — datasets, node_status, status_counts, job_progress (fraction/tasks/stages), events+last_event, topo_order, connect_status/source/error, and placeholders (empty-state strings). The GUI writes it every ~300 ms to $KNUT_VIEWER_STATE (default <tmp>/knut_viewer_state.json, gui.rs:243), overwritten in place. It lives in the default build, so the data-layer matrix asserts on it with no eframe::run (progress.rs tests state_json_*).
  2. Activity trail. one stderr line per real state change.

Rule: any new visible list/status/panel MUST be added to state_json().

f7 — the facett adapter (src/facett_adapter.rs [feature facett-adapter])

knut owns the data + connector side; the reusable visual component lives in facett as an engine-agnostic viewer. The seam is one feature-gated file (facett-adapter = ["dep:facett-pipeline"]) — the Spark/SDP adapter, the only place that knows both knut's Spark types and facett's neutral model. It maps knut's ViewerState onto facett's PipelineModel. "Show a Spark pipeline" is exactly one adapter; a future Flink / Beam / skade-streaming viewer is another adapter, not another viewer. See assets/facett_adapter_flow.svg.

The mapping (precise):

knut input facett output how
each Dataset a Stage Stage::new(name, status); name = both id and display name
NodeStatus neutral StageStatus (Idle/Running/Done/Failed) map_status — 1:1, no Spark vocabulary leaks
Dataset.output_type stage kind sublabel with_kind(output_type.label())
a failed node's matching error event stage error error_for: last EventLevel::Error whose element == dataset; only when Failed
each flow (read → target) a PipelineEdge one PipelineEdge::new(read, target) per read
Spark ExecutionProgress PipelineMetrics fraction/tasks_done/tasks_total/inflight/bytes_read straight through; rows = real sum of ViewerState::rows_written (parsed "wrote N rows" counts); elapsed_s = measured span between first/last timestamped event. Both 0 only when the run reported nothing — no tasks_done × 1000 / 5.0 stand-in
ViewerState::rows_written[name] per-stage StageMetrics.rows each Stage carries its own real row count (with_rows) when the run attributed one to that node; None otherwise. Throughput/lag stay None (no per-node timer on the Spark stream)

Three entry points, one impl (to_pipeline_model, lib.rs:98): the free to_pipeline_model(&ViewerState); SparkPipelineAdapter<'a>(&'a ViewerState) impls PipelineAdapter; and ViewerState itself impls PipelineAdapter, so the ergonomic form is PipelineView::from_adapter(&viewer_state). A test pins that all three agree.

Back-compat law. The default build is UNCHANGED. facett is pulled in only under facett-adapter (optional = true), its model types never leak into knut's public API (adapter takes knut types in, hands a PipelineModel out), and facett's egui-0.34 tree stays off the default and gui (egui-0.28) builds.

Architecture map

Area Where
DAG model src/model.rs (DataflowGraph, Dataset, Flow, OutputType, validate, topo_order)
Run state + job progress src/progress.rs (ViewerState, ExecutionProgress, StageProgress, NodeStatus, ViewerUpdate)
Renderers src/viewer.rs (dump_text, dump_json, dump_state, PipelineEventSink, StdoutEventSink)
Spark Connect client src/client.rs [grpc] (PipelinesClient, run_updates, execute_sql, define_graph, UpdateStream)
egui / snarl GUI src/gui.rs [gui] (launch, DagViewer: SnarlViewer)
facett adapter src/facett_adapter.rs [facett-adapter] (SparkPipelineAdapter, to_pipeline_model)
Binary src/bin/knutpipe.rs (show / watch / gui / pipeline)
Vendored proto proto/spark/connect/*.proto + build.rs (tonic-prost-build, gated)