Release events — the release-op DAG
Epic B (B1 + B2) of THE_BIG_PLAN.
nornir release runnow records a clean, row-level DAG of every release operation into the warehouse, readable from both the server and fat-CLI. The viz tabs (B3/B4) consume this table.
Why a second release table
release_lineage answers what shipped — one row per (release, repo) with the git SHA, gate outcome, and time-machine pins. It is an outcome record. It cannot tell you the shape of the run: the order ops happened in, which ops a repo waited on, or exactly where a partial release stalled mid-flight. Today the operator infers that from gate_status text, and the viz can't draw a live op graph.
release_events fills that gap. It is the structural twin of the funnel DAG (funnel_events): an append-only event log where each row is one component × op × phase boundary, carrying the depends_on edges from the dep-graph. Replaying a run's rows in seq order reconstructs the op DAG; the depends_on lists draw the cross-repo edges the viz lights up.
The table
release_events — defined in src/warehouse/iceberg_schema.rs::release_events, registered at warehouse open in src/warehouse/iceberg.rs::ensure_layout (unpartitioned — a run is a handful of rows; the reader's run_id/repo residual filter is enough).
| col | type | n | notes |
| run_id | string | req | groups one release run's rows (UUID server-side; epoch-secs run id in the fat CLI) |
| seq | long | req | per-run monotonic counter minted at emit time — the durable ordering key (Iceberg scan order is unspecified) |
| ts_micros | timestamptz | req | event time |
| component | string | req | the component the op acts on (the repo today; a sub-component later) |
| repo | string | req | workspace repo name (== component today; kept distinct for crate-grained ops) |
| op | string | req | run | test | bench | gate | publish | push | snapshot | … |
| phase | string | req | start | end | skip |
| status | string | req | ok | fail | warn | running |
| detail | string | req | short human message ("" when none — never a surprise null) |
| depends_on | list<string> | opt | components this op waited on (dep-graph producers). null on run/* envelope rows; a real (possibly empty) list on per-repo rows |
| elapsed_ms | long | opt | wall-clock duration; only phase = end rows carry it |
depends_on is the load-bearing column: for repo R it is { e.to | e ∈ dep_graph where e.from == R } — i.e. the in-workspace producers R consumes (edges point consumer→producer). A dep-graph root records Some([]) (an explicit empty list, distinct from the None on envelope rows), so the viz can tell "no deps" apart from "not a repo op".
Identity / relations
run_idgroups a run (loosely co-referencesrelease_lineage.release_idfor the library path — both mint a UUID per run; the fat-CLI path keys events by its ndjsonrun_idso the live-progress file and the warehouse DAG share an id).depends_onentries are repo names — the same identity asdep_graph_edges.from_repo/to_repoandrelease_lineage.repo.
Write paths — server AND fat CLI
The work-log lands in the warehouse either way (mirrors how bench rows persist). Both paths drive a single helper, ReleaseEventEmitter: it holds the run id + a shared atomic seq and appends one row per boundary. Emits are best-effort + non-fatal — a write error is logged to stderr and swallowed, so a logging-row failure can never abort an otherwise-green release.
-
Library / server path —
release::pipeline::run_pipeline(src/release/pipeline.rs). This is what thenornir-serverrelease RPC and the smoke-test orchestrator call. It brackets the run withrun/start…run/endand emitstest(start/end),bench(start/end), andgate(end) per repo as it walks the build order. Thedepends_onfor each repo comes fromdepends_on_for(graph, repo)over the sameWorkspaceGraphthe build order was derived from. -
Fat-CLI path —
run_release_runinsrc/bin/nornir.rs(nornir release run). Same event shape, driven synchronously viawh.block_on(...).depends_onis computed from the latestdep_graph_edgessnapshot's edges (the same snapshot that ordered the run). Covers the extra fat-CLI phases too:publish(start/end) andtest/skip/bench/skipfor the--skip-*flags. Earlybail!s (test/bench/publish failure) emit arun/endwithstatus = failcarrying where it stalled.
Both open the warehouse exactly as the existing bench-persist code does (the fat-CLI path reuses the same wh handle that already writes bench_runs), so there is no new locking story.
Read path — CLI parity (mandatory)
nornir release events <repo|run_id> [--json] (ReleaseOp::Events in src/bin/nornir.rs) opens the warehouse read-only + lock-tolerant (open_read_only, so it coexists with a live server holding the catalog lock via the copied-aside snapshot) and prints the DAG:
<selector>is matched as an exactrun_idfirst; if no events carry it, it is treated as a repo name (all that repo's runs). So the one command works for both "show me this run" and "show me this repo's release history".- default output is a human topo view (
render_topo): per-run, thedepends_onedges listed once, then theseq-ordered timeline with a ✓/✗/⚠/… status mark, elapsed, and detail. --jsonemits the raw rows (rows_to_json) for tooling / the viz.
The read helpers live in src/warehouse/release_events.rs: query_release_events(wh, EventSelector::{Run|Repo|All}) returns rows sorted by (run_id, seq); render_topo and rows_to_json render them.
How the viz (B3/B4) lights up
The table is shaped so the future "Release Ops" tab is a thin read:
- one swim-lane per
component, nodes placed byseq, colored bystatus; - cross-lane edges drawn straight from
depends_on(no inference); - a stalled partial release is obvious — the lane with the
failrow and every downstream component that never got astartrow; - live mode: because each append is its own Iceberg snapshot, polling the table (or the existing ndjson progress file, which shares the
run_id) streams the DAG as it fills in.
scan_preview("release_events", N) already makes the table browsable in the generic Warehouse introspection tab today.
Guide — quickstart
# Record events (automatic — every release run now writes the DAG):
nornir release run # all repos, topo order
nornir release run --repo holger # one repo
# Read the DAG back:
nornir release events holger # all of holger's release runs (topo view)
nornir release events <run_id> # one specific run
nornir release events holger --json # raw rows for tooling / viz
The run id is the UUID/release_id printed by the run (library path) or the epoch-seconds id in the 📡 live events: line (fat-CLI path).
Tests
tests/release_events.rs (inject-and-assert, temp warehouse — never the live server's):
injected_events_land_exactly— drives the emitter + append + read path with injected components/ops/phases, appends them out of seq order, then asserts the exact rows land: run_id grouping,seqmonotonic0..=6after the reader re-sorts, byte-for-byte row round-trip,depends_onedges (root →Some([]),holger→Some([znippy]), envelope →None), the injected statuses (including theholgertestfailthat stalls the run), andelapsed_msonly onendrows. Also checks repo-scoped reads filter correctly.injected_second_run_stays_isolated— two runs in one warehouse don't bleed.run_pipeline_records_release_events— runs the realrun_pipelinedry-run over two trivial crates and asserts it recorded a structurally-correct DAG (run envelope brackets; per-repo test/bench/gate; deps-first byseq;depends_onequals the dep graph'sdependencies_of; both gatesokwithdetail = "succeeded_dry_run").
Plus unit tests in src/warehouse/release_events.rs (emitter seq sharing across clones; append/query round-trip with list-column nullability).