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

nordisk/nornir

.nornir/jobs-design.md

The job ledger — design

Class: design · Function axis: f0 (the ledger as one system) + f1 the single-writer store · f2 lifecycle & the serial scheduler · f3 container jobs · f4 the read surface + CLI. Strict: this file states only what the ledger is. The why — redb-not-Iceberg, why every long op is a job, rejected alternatives — is in jobs-reasoning.md. Sibling: jobs-guide.md.

f0 — the job ledger (the whole)

Every long-running nornir operation (~1s–minutes) is a tracked job: release runs, workspace fetch/republish/populate, docs render/export/book, knowledge scan, deepscan, bench, test matrix, arch generate, index build, vector embed, bakeoff, airgap, coverage, security scan, warehouse compact/expire, and container runs. Each op's result still lands in its domain Iceberg table; the ledger is one orthogonal place that answers "what's running / did it finish / how long did it take?" — the op lifecycle, not its output.

The ledger is a single redb file, <warehouse root>/jobs.redb, distinct from skade-katalog's catalog.redb and the server's registry.redb. It follows the same fat-XOR-thin split as the warehouse: a fat process writes the local file directly; a thin client submits each record over gRPC to the server, which owns its jobs.redb. One writer owns the file; the 🧬 viz observes by reading the same redb read-only. The engine is the self-contained leaf crate nornir-jobs, re-exported as nornir::jobs.

Reuse map (what the jobs subsystem uses). The leaf crate depends only on redb + serde + anyhow + tokio (the exclusive gate) + chrono + uuid + tempfile — no tonic (the binary supplies the gRPC submit closure), no Iceberg. It is used by every op family across the CLI and nornir-server, and by the viz through Viz.Jobs.

part lives in what it is
f1 the store JobStore jobs.rs:507 single-writer redb file, lock-tolerant reader
f2 lifecycle + scheduler JobHandle jobs.rs:326, class_of jobs.rs:178 running → done|failed, the serial gate
f3 container jobs run_container jobs.rs:823 podman/docker run … as a tracked job
f4 read surface + CLI run_jobs src/bin/nornir.rs:7004 nornir jobs, Viz.Jobs, the viz pane

f1 — the store: one mutable row per op (jobs.rs)

A job is a single redb row, key = job_id, value = JSON JobRecord (jobs.rs:207). The same key is overwritten in place as the job advances — no append-only two-rows-then-coalesce, no Iceberg snapshot per transition.

JobRecord { job_id, kind, target, workspace, status,
            ts_start_micros, ts_end_micros, elapsed_ms,
            detail_json, result_ref, parent_id }

JobStore (jobs.rs:507) owns the redb file. open (jobs.rs:516) is the sole-writer path; open_read_only (jobs.rs:536) is lock-tolerant — if the live server holds the exclusive lock, it copies the file aside across a size-stable window (copy_redb_consistent, jobs.rs:723) and opens the snapshot, the same trick as warehouse::IcebergWarehouse::open_read_only. Single-writer discipline matches registry.redb.

JobSink (jobs.rs:280, cheap Arc clone) is the write side, in three forms: JobSink::redb (jobs.rs:286, fat — upsert into the local file, prune on terminal writes), JobSink::remote (jobs.rs:303, thin — hand each record to a closure that does the Jobs.Submit RPC), and JobSink::noop (jobs.rs:309, drop every record, so call sites stay unconditional). A sink write failure is logged and swallowed — a ledger hiccup never aborts the op it tracks.

f2 — job lifecycle & the serial scheduler (JobHandle, class_of)

A JobHandle (jobs.rs:326) wraps one op's lifecycle. status (jobs.rs:59) has two non-terminal states (queued, running) and two terminal (done, failed):

transition method at
write the running row (immediate, ungated) JobHandle::start jobs.rs:341
write a running child row under a parent JobHandle::start_child jobs.rs:358
scheduler entry (see below) JobHandle::start_scheduled jobs.rs:393
terminal done (+ result_ref) JobHandle::finish jobs.rs:452
terminal failed (carrying the error chain) JobHandle::fail jobs.rs:457
terminal failed carrying caller JSON (ran-but-failed) JobHandle::fail_with_detail jobs.rs:466
dropped without a terminal call → failed impl Drop jobs.rs:482

The Drop guard (jobs.rs:482) records failed ("dropped without finish/fail") so a panic or early ? never leaves a zombie running row.

The serial scheduler. Some ops are box-saturating and only measure true in isolation (a benchmark sharing the machine with another benchmark measures noise). Each kind has a JobClass via class_of (jobs.rs:178):

class policy kinds
Exclusive (jobs.rs:165) at most one runs process-wide; the rest wait queued BENCH_RUN (today the only one)
Parallel (jobs.rs:168) the default — runs immediately, never gated everything else

is_exclusive (jobs.rs:188) is the predicate; adding a heavy kind is one arm in class_of, no call-site change. The gate is a process-global tokio::sync::Semaphore(1) in a OnceLock (exclusive_gate, jobs.rs:198). start_scheduled (jobs.rs:393): a parallel kind behaves exactly like start; an exclusive kind first writes a queued row (so the wait is visible in nornir jobs / the viz), .awaits the permit, then flips queued → running in place — re-stamping ts_start_micros so elapsed_ms measures real work, not the queue wait — and holds the permit until it terminates. So N benches submitted together are one running + (N−1) queued, draining one at a time.

f3 — container jobs (run_container)

run_container(sink, spec, target, workspace) (jobs.rs:823) runs a container as a tracked job — the generic "run a one-shot container and track it" seam.

f4 — the read surface + CLI

--json emits the raw Vec<JobRecord> — the SAME records the viz renders (CLI ⟺ viz parity).

surface how at
CLI nornir jobs [--kind K] [--running] [--limit N] [--json] remote via Viz.Jobs when NORNIR_SERVER set, else JobStore::open_read_only (lock-tolerant) run_jobs src/bin/nornir.rs:7004
CLI nornir container --image … [--engine] [--flag] [--target] -- <cmd…> same fat-or-thin sink; propagates the container exit code run_container_cmd src/bin/nornir.rs:7080
RPC Viz.Jobs newest-first, capped; the thin viz can't open the server's redb nornir-server
viz the 🧬 nornir tab's job list reads through Viz.Jobs src/viz/nornir_tab.rs

JobStore::list(sel) (jobs.rs:615) returns rows newest-first, filtered by a JobSelector (All / Workspace / Kind, jobs.rs:248); the server applies a thin client's record via JobStore::submit (jobs.rs:606). Both write paths prune on terminal writes.

Retention

Terminal jobs older than KEEP_DAYS = 30 are pruned opportunistically on each terminal write, and the total is capped at CAP = 2000 rows (oldest terminal first). Running jobs are never pruned (redb_prune, jobs.rs:652).