build-info — the second half of a build (schema + emitter)
A build produces two things at once: the thing (artifact bytes, hashed by
BuildProvenanceand — a later slice — stored in holger's znippy CAS) and the build info (the columnar record ABOUT the build). This note wires the second half innornir-build-thing: the §f3 Arrow schema, a flat dep-light row, and a pluggableBuildInfoSinkwith an in-process default. The full constellation design (Flight seam, at-rest seal, jera DAG) isworkspace_nordisk/.nornir/ build-info-flight-stream-design.md; this is the build-thing-local slice 2.
The schema (§f3) — one row, six kinds
Build info is inherently record-shaped, so it IS columnar. One row shape (build_info::BuildInfoRow), discriminated by the kind column, carries the whole stream — the Artifactory-parity manifest rows (provenance / dep / artifact, derived from BuildProvenance) AND the live feed (log / metric / step). Columns, in declared order:
build_id (Utf8) · step_id (Utf8) · ts_micros (Int64) · kind (Utf8) · level (Utf8) · name (Utf8) · value (Utf8) · content_id (Utf8) · blob (Binary, the only nullable column).
Dep-light by construction
The rows + the sink are pure std + serde — a leaf repo's xtask gets the emitter for free. The Arrow RecordBatch binding (build_info_schema() / build_info_batch()) is gated behind the build-info-arrow feature, so the default crate keeps its no-heavy-deps promise. The feature reuses the arrow builder plumbing (arrow-array / arrow-schema, the crates + version znippy pins) rather than re-rolling arrow; znippy's own build_metadata_batch builds a different schema (blob-index), so only the plumbing is shared, not the shape.
The sink seam
BuildInfoSink is transport-agnostic (emit(&[BuildInfoRow])):
InMemBuildInfoSink— the in-process default (a shared, cloneableVec): single-box localhost, zero transport tax. Hand a clone to the build, read the rows back afterwards.NullBuildInfoSink— the no-op default so existingbuild_variant()callers pay nothing.- Flight-backed sink — a later slice (the
edda/nornir-flightseam is the transport); not wired here. Same schema + sink API both ways.
The emitter
build_variant_with_info(set, name, repo_root, sink) runs the same build as build_variant (which now just routes to a NullBuildInfoSink, so callers are unchanged) and emits: the live feed (step/log/metric for the cargo-build step) as it finishes, then the manifest rows from the captured BuildProvenance. Emission is best-effort — a sink error never fails the build.
Tested (RED-when-broken)
arrow_schema_has_f3_columns_in_order— exact column order + types + the blob-only-nullable invariant + the schema-version metadata key.manifest_rows_cover_provenance_dep_and_artifact— a known captured build yields the expected provenance / dep / artifact rows;content_id== sha256.in_mem_sink_round_trips_rows— what is emitted comes back byte-identical.arrow_batch_matches_schema_and_values— the RecordBatch shape + cell values.emits_functional_rows_for_build_info(featuretestmatrix) — honest matrix rows for the emit + round-trip.
Slice 3 — native warehouse landing + at-rest seal (shipped)
The build-info feed no longer rides test_results as an interim; it lands in its OWN warehouse table with its OWN submit path, and seals at rest:
build_infotable (warehouse::build_info,iceberg_schema::build_info()) — the §f3 columns 1:1 (build_id·step_id·ts_micros·kind·level·name·value·content_id·blobnullable), the exploded sibling of the one-row-per-buildbuild_provenanceledger.append_build_info/query_build_inforeusenornir_build_thing::build_info::BuildInfoRowverbatim — the same struct the emitter and the seal use, so live wire, at-rest blob, and warehouse row are one shape (no second format).- Submit RPCs (
Telemetry, mirroringSubmitTestResults):SubmitBuildProvenanceships eachBuildProvenanceas JSON (record can gain fields with no proto bump) →append_build_provenance;SubmitBuildInfoships typedBuildInfoRowPbrows (has_blobdistinguishes a NULL blob from an empty one) →append_build_info. Client helpers:remote::submit_build_provenance/submit_build_info. A dwarves build on the dev's hardware ships both to the locked server warehouse, exactly like bakeoff/test/release rows. - At-rest seal (
build::seal::seal_build_info_beside_artifact, featurebuild-provenance) — the §f3 rows sealed as znippy over Arrow-IPC →<artifact>.build-info.znippy, the airgap-portable sibling of<artifact>.build-provenance.znippy. - Viz — the generic warehouse deck lists tables via
table_names(), sobuild_info+build_provenanceshow up natively with no bespoke pane.
Guarded: submit_build_info_and_provenance_land_natively (tests/data_submit_rpcs.rs) submits real rows to an ephemeral server and reads the exact values back over Warehouse.Scan; warehouse::build_info unit tests round-trip the manifest + the nullable blob; seal_build_info_writes_znippy_ beside_artifact proves the seal. The root build-info-arrow feature forwards to nornir-build-thing/build-info-arrow, and tests/cargo_features_guard.rs fails RED if any feature ever references an undeclared dep:.
Slice 4 — jera pipeline-DAG sub-jobs (§f5, shipped)
A build/pipeline is not one job — it is a root jera job whose body is a DAG, and every step is a sub-job. Modelled in nornir::build::pipeline:
- The DAG is data — reuse, don't re-roll. A
PipelineSpec(aname+ a list ofPipelineStep{ id, name, needs, kind }) projects onto funnel-core'sPlanDagverbatim (PipelineSpec::to_plan_dag): nodes = sub-jobs, edges =needsdependencies (EdgeKind::Dep,need → step). Sotopo_order()/critical_path()/depths()(the ready-wave layering) all work for free — no new graph type. A one-step spec is a one-node zero-edge "DAG", so the existing single-job path is unchanged (additive). - The ledger is the sub-job hierarchy — reuse, don't re-roll.
PipelineSpec::run_on_ledgerstarts a rootnornir-jobsjob (kindci_build) that OWNS the DAG, then admits every step as a child sub-job (parent_id= root'sjob_id)queuedup front, promotes eachrunningin ready-wave order, and terminates itdone/failed. A step whoseneedsdid not all reachdoneis left blocked →failedwithout running. Theneedsedge set is stamped into each sub-job'sdetail_json, so the DAG is recoverable from the ledger, not only the projection. The viz Jobs tree already folds children under their parent byparent_id— no viz change needed. - build_id / step_id contract. The root
job_idis the §f3build_id; each sub-job'sjob_idis the §f3step_id.PipelineOutcome::build_info_for(rows, step)filters a slice-2 stream to one sub-job (build_id == root && step_id == sub-job job_id) — "each step's log/metric stream is the f3 feed filtered bystep_id." - One engine for nornir AND dwarves (LAW: reuse) — the same
root job → pipeline DAG → sub-jobsmodel, not two schedulers.
Guarded (src/build/pipeline.rs, RED-when-broken): three_node_dag_makes_root_plus_three_subjobs_with_needs_and_status (a fetch → compile → test DAG creates 1 root + 3 sub-jobs on the ledger with the right needs edges + per-step done), failed_step_blocks_dependents_and_fails_root (compile fails → test blocked → root failed), single_step_is_a_one_node_dag_additive, build_info_stream_filters_per_step_id_to_each_subjob, cyclic_pipeline_is_rejected, diamond_dag_reuses_funnel_core_critical_path. Emits nornir_testmatrix::functional_status rows for the three headline surfaces.
Slice 5 — the Flight-backed sink + consumer (§f1/§f2/§f5, shipped)
The arc closes: the same build-info stream is now in-process (default) OR Flight (remote) behind the ONE BuildInfoSink trait, exactly as the design says.
-
FlightBuildInfoSink(nornir::build::flight_sink, featurebuild-info-flight) — the remote counterpart ofInMemBuildInfoSink. It serializes eachBuildInfoRowbatch to the §f3 ArrowRecordBatchand ships it viaedda/nornir-flight'sdo_put. It lives in nornir because it is the one place that deps BOTHnornir-build-thing(the trait + rows) andedda/nornir-flight(the raw transport) — no cycle (edda knows nothing of build-info; build-thing knows nothing of Flight).emitis best-effort: rows are buffered to a worker that groups by route key anddo_puts one batch per key; a transport error is surfaced viaflush() -> FlightStats, never failing the build. -
Route key = per sub-job
step_id.route_key(build_id, step_id)→build_idfor root/manifest rows (emptystep_id), elsebuild_id/step_id. So a consumer subscribes by ticket per sub-job (§f5: "each step's log/metric stream is the f3 feed filtered bystep_id"). -
Consumer.
subscribe(endpoint, token, key)doesdo_get(key)and decodes the batches back intoBuildInfoRows byte-identical to what was emitted. -
Auth via nornir-service. The sink and consumer carry a bearer token; the
FlightServercomposes the sharednornir_service::auth_interceptor. An unauthenticateddo_put/do_getis rejected — proven RED-when-broken. -
The arrow version boundary — ⚠ IT IS GONE (verified 2026-08-24). This used to read: "
nornir-flightrides arrow-56 (itsarrow-flight→tonic 0.13matchesnornir-service;arrow-flight 57+needstonic 0.14), whilebuild-info-arrowis arrow-58. Those are semver-incompatible majors…"
Measured against the tree, both sides are arrow 59: nornir-flight/Cargo.toml:42-44 declares arrow-array/arrow-schema/ arrow-flight all "59" on tonic 0.14, nornir-build-thing:109-110 declares arrow-array/arrow-schema "59", and edda's Cargo.lock carries exactly one arrow family — 59.2.0, arrow-flight 59.2.0 included. The tonic 0.13/0.14 split that forced the gap resolved itself when nornir-service moved to 0.14.
rows_to_batch / batch_to_rows stay, and are not dead code: converting BuildInfoRow to a RecordBatch is the serialization layer, needed at any arrow version. What changed is the REASON — they are no longer a bridge across incompatible majors, so nobody should preserve them as one or go looking for the arrow-56 half. The test pinning their column shape against the canonical build_info_schema still earns its keep: it is now a same-major drift guard.
- Additive. The whole module is gated behind
build-info-flight(OFF by default): the in-processInMemBuildInfoSinkstays the default with zero transport tax and no second arrow in the default build.
Guarded (src/build/flight_sink.rs, RED-when-broken): flight_schema_matches_canonical_f3_columns (the §f3 shape + no-drift), batch_round_trips_rows_byte_identical (arrow-56 columnize/decode lossless incl. the NULL-vs-present blob), flight_sink_ships_and_consumer_receives_byte_identical (a mixed manifest+feed stream shipped over do_put, consumed per route key over do_get, byte-identical), auth_is_enforced_on_the_build_info_wire (no bearer ⇒ do_put/do_get rejected), and emits_functional_rows_for_flight_build_info (feature testmatrix — honest do_put/do_get matrix rows).
Slice 5 tail — the viz build-info LIVE-TAIL pane (shipped)
The arc's last open thread — surfacing the received stream in the viz — is now wired: a build-info live-tail pane in the 📡 Live Run area subscribes the §f3 stream per sub-job step_id and shows the rows arriving as they land.
BuildInfoTail(src/viz/build_info_live.rs, featureviz) — a pure fold overBuildInfoRows that knows nothing about how the rows arrived.ingest(&[BuildInfoRow])folds each row into the per-sub-job state keyed bystep_id: asteprow's status (carried in the §f3levelcolumn —queued/running/done/failed) drives the sub-job's status + a recorded transition when it changes;log/metricrows append to that sub-job's tail; manifest rows (provenance/dep/artifact, emptystep_id) are counted, not tailed (they are the static build record, not the live feed).- The live seam is
flight_sink::subscribe—BuildInfoTail::pull(endpoint, token, key)(gatedbuild-info-flight) subscribes one sub-job's ticket (route_key=build_id/step_id) overdo_getand folds the decoded rows through the SAMEingest, so live-wire and injected rows fold identically. The defaultvizbuild keeps the injectableingestpath and pays no Flight tax. - Reuse (LAW #5) — the pane paints with the shared facett palette (
Theme) and the vendored facett widgets (facett_ui::status_chip, zebra rows); nothing is re-rolled. It renders as data (state_json()["build_info_live"]), folded into the app just likebench.live, so it is robot-testable, not pixel-only. - Additive — a new
viz-gated module + a field on the app; existing viz unchanged. Drawn beneath the release-run feed in the 📡 Live Run tab. - Continuous auto-refresh (the timer, shipped). The pane no longer needs a manual
pull: a background poller (BuildInfoTail::start_poller, gatedbuild-info-flight) re-pulls every tracked sub-job ticket (route_key(build_id, step_id)) on a fixedPOLL_EVERY(1500 ms) cadence on its own OS-thread tokio runtime — the same patternFlightBuildInfoSinkuses, so apullnever blocks the egui frame nor needs an ambient runtime. Decoded rows come back over a plainstdchannel (the inbox);drawdrains the inbox through the SAME synchronousingestevery frame and callsrequest_repaint_after(POLL_EVERY)so rows that land mid-build appear with no user input (mirrorsbench_live's warehouse re-read). The poller forwards only rows past a per-key high-water mark, so re-pulling the full stream never double-folds — the append-onlyingest(and every existing inject test) is unchanged (L2). The live-ticket set is tracked (subscribe_ticket) and auto-grown as the fold discovers each sub-job; the app starts the poller in remote mode. Both the tick count (poll_count) and the live tickets are exposed instate_json.
Guarded (RED-when-broken): ingest_grows_tail_and_tracks_status + manifest_rows_counted_not_tailed + auto_refresh_drains_inbox_and_advances_ticks (a simulated interval pull GROWS the tail + ticks poll_count, no re-inject) + empty_interval_pull_is_not_a_tick (src/viz/build_info_live.rs); build_info_pane_tails_feed_per_subjob + build_info_pane_auto_updates_on_poll_timer (the REAL app auto-folds delivered batches across egui frames) + poller_pulls_live_flight_rows_across_intervals (feature build-info-flight — the poller re-pulls a live edda/nornir-flight server across intervals, deduped by the high-water mark) + a wgpu snapshot (viz_snapshot_build_info_live) (tests/viz_build_info_live.rs). Emits nornir/viz/build-info-live functional_status rows (feature testmatrix).
Deferred (later slices)
dep rows currently carry the enabled features (the consumed inputs present on BuildProvenance); a resolved crate-graph dependency row is a later slice. (Continuous auto-refresh of the live-tail pane — the timer that re-pulls each ticket while the pane is open — is now shipped; see the "Slice 5 tail" section above.) run_on_ledger runs steps sequentially in topo order today (each wave's dependents wait on the prior); parallel intra-wave execution of independent sub-jobs is a later refinement (the PlanDag already exposes the waves via depths()).