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

nordisk/nornir

.nornir/warehouse.md

Warehouse

Internal reference for Urðr — nornir's single append-only Apache Iceberg store, and the canonical catalogue of every table / projection it holds. This is the "where does each fact live" map. It lives in .nornir/ as a durable design note and is a chapter of nornir docs book.

Source: src/warehouse/ — iceberg_schema.rs (every table schema), iceberg.rs (ensure_layout, the TABLE_* names, append/scan), mod.rs (the Warehouse trait + BenchFilter); the catalogue is the standalone skade-katalog (redb). Decisions: plan.md §4 ("Decided").

What it is

One Iceberg warehouse per workspace, under <storage>/.nornir/warehouse/ (default workspace_holger/.nornir/warehouse/), catalogued by skade-katalog (single-file redb) over object_store::local. Every derived fact about the workspace is an Iceberg row keyed by git SHA (and a snapshot/release id), so the whole history is queryable and time-travellable. The server is the sole holder of the warehouse lock; CLI / MCP / viz are thin clients.

It is live local data — never checked into git (.nornir/warehouse/ is gitignored in every repo). The on-disk .nornir/cache/ materialised views (Tantivy index, vector .nvf) are rehydrated from warehouse blobs on demand.

Laws (locked — see plan.md §4)

The tables (every projection)

Registered in iceberg::ensure_layout; schemas in iceberg_schema.rs. Adding a table is a schema fn + a TABLE_* const + an ensure_layout entry + an append/scan path (see Adding a table). Identity columns are the idempotency key.

Release & publish

table purpose key
release_lineage one row per (release, repo) — full provenance, git SHAs, gate outcomes, the rollback handle; carries the additive *_snapshot_id time-machine pins release_id, workspace_name, repo
release_commits conventional commits between release SHAs release_id, repo, git_sha
publish_attempts post-publish crates.io index-visibility poll loop release_id, repo, crate_name, version
crate_metadata_checks gate: readme/license/repository/description present release_id, repo, crate_name, version
crate_tarball_stats packaged tarball size + largest-file warnings release_id, repo, crate_name, version
yank_events cascade yank / un-yank in dependency order event_id, ts_micros, crate_name, version
registry_mirrors mirror a crate to additional registries mirror_id, release_id, crate_name, version
path_dep_audits audit version= on every path-dep release_id, repo, dep_name
patch_strip_events per-line [patch.crates-io] strip events release_id, repo, file, line
msrv_probes actual MSRV via cargo toolchain probes probe_id, repo, crate_name
semver_diffs rustdoc-JSON semver diff (breaking-change scan) from_snapshot_id, to_snapshot_id, crate_name
version_bump_plans / version_bump_targets coordinated cross-workspace bump header + per-crate rows plan_id (+ repo, crate_name)

Bench & test

table purpose key
bench_runs one envelope per nornir bench run (version, machine, cores) run_id, repo, ts_micros
bench_results the free-form metric values per result (see bencher.md) run_id, result_name, metric_name
test_outcomes pass/fail per test alongside the bench run_id, test_name
test_impacted_selections auditable impact-set test selection run_id, changed_repo, selected_test_name
test_quarantines flake quarantine decision state per test test_name, repo, quarantined_at_ts

Dependency graph (DAG 1)

table purpose key
dep_graph_edges component dependency edges per snapshot snapshot_id, from_repo, to_repo, via_crate
links_declarations links = manifest declarations (conflict detection) dep_graph_snapshot_id, crate_name, links_value
resolved_features resolved feature set per workspace member dep_graph_snapshot_id, member_repo, member_crate, dep_crate

Plan DAG (DAG 2 — the funnel)

table purpose key
funnel_events every mutation to the ideas→plan DAG, immutable append (8 event kinds) event_id, ts_micros, kind

Knowledge map (Mímir — source facts)

table purpose key
symbol_facts one row per public item from the syn walker snapshot_id, repo, crate_name, item_name
call_edges call-graph edges from syn ItemFn body walks snapshot_id, repo, crate_name, caller_path, callee_ident
feature_gate_facts per-item #[cfg(...)] attributes snapshot_id, repo, crate_name, item_name
feature_dep_edges Cargo.toml feature-ACTIVATION graph: feature → dep / feature (incl. the weak pkg?/feat AND-group edge cargo metadata cannot supply); feature = "" ⇒ unconditional. Written by populate step 7d snapshot_id, repo, crate_name, feature, enables
git_heat_facts per-file git activity (commits, authors) snapshot_id, repo, file

Artifact time-machine (snapshot/blob pairs)

A uniform two-table shape (artifact_snapshots + artifact_blobs schemas) reused under several names — byte-for-byte derived artifacts keyed by source git SHA, rehydrated into .nornir/cache/ in ~20–30 ms. See the time-machine note below. Tantivy is live; DWARF/gimli/rustdoc are schema-only (nothing writes them yet).

tables purpose key
tantivy_index_{snapshots,blobs} full-text (BM25) code index, git-SHA-pinned snapshot_id, repo, git_sha / filename
docs_index_{snapshots,blobs} full-text (BM25) docs index — separate from the code index, over the generated docs snapshot_id, repo, git_sha / filename
dwarf_{snapshots,blobs} · gimli_{…} · rustdoc_json_{…} DWARF symbols / unwind tables / rustdoc-JSON (schema-only today) same shape

Rendered documentation

table purpose key
doc_exports historized docs export/docs book artifacts — full bytes inline, deduped on content repo, doc_name, version, format, sha256

Vector / embeddings (see vector.md)

table purpose key
embeddings content-addressed, deduped vector store (native list<float32>) content_hash, model_profile
embedding_snapshots index identity + build metadata per (repo, git_sha, model) snapshot_id, repo, git_sha, model_profile
embedding_manifest per-occurrence {file, span} rows for a snapshot snapshot_id, ordinal, content_hash, model_profile

How a fact gets in

Surfaces

surface how
CLI nornir warehouse {import-jsonl,query}, plus the per-projection verbs (bench run, knowledge scan, index, vector, release …)
MCP query_* tools (bench/dep-graph/knowledge/vector), regression_trace, funnel_*
gRPC the tonic server is the sole lock-holder; clients query through it
Trait warehouse::Warehouse (query_bench_runs(&BenchFilter), …); IcebergWarehouse impl in iceberg.rs

Warehouse-root precedence & lock-tolerant reads

The server is the sole holder of the warehouse's exclusive catalog.redb lock (skade-katalog is single-writer). But a dev-side local CLI read — the docs_fresh gate, docs render, release trace, a dep-graph load — must not deadlock against a live nornir-server that already holds that lock.

Root resolution (warehouse::warehouse_root, kept in lock-step with config::Loaded::warehouse_root and the server):

  1. explicit [storage].local_path → <workspace_root>/<local_path>/warehouse — the repo-/workspace-local home, the recommended default for a CLI;
  2. otherwise the shared workspace_holger/.nornir/warehouse fallback — which the live server also defaults to, so a CLI and the server can land on the same file. That collision is exactly why read paths must tolerate the lock.

Lock-tolerant read open (warehouse::open_read_only → IcebergWarehouse::open_read_only). A read first tries a normal exclusive open. If that fails with a redb exclusive-lock error (is_catalog_lock_error), instead of aborting it:

The snapshot is read-only by construction — any write would land in the throwaway temp dir, not the live catalog — so mutating callers must use plain open. This is what lets a CLI read coexist with the server instead of being wedged by its lock; CLI↔server stay isolated without the human having to stop the server first.

Funnel-store root resolution (CLI ↔ server ↔ MCP)

The funnel (funnel_events, DAG 2) has its own root override, separate from the main warehouse root above. This exists so the global server install can point every surface at one shared funnel dir. The single source of truth is funnel::Store::resolve_root(workspace_root, local_path, explicit) — the CLI (nornir funnel …), the gRPC server (nornir-server), and the MCP (nornir-mcp) all call it, so a funnel written by one is read by the others.

Precedence (highest first):

  1. explicit --funnel-root <dir> flag (none wired today; the hook exists);
  2. NORNIR_FUNNEL_ROOT — the global override, used as-given (no warehouse/ suffix). nornir serve install writes it into the generated nornir.env (default /home/nornir/funnel), so on a real install every surface lands on that exact dir;
  3. config [storage].local_path → <workspace_root>/<local_path>/warehouse (kept in lock-step with config::Loaded::warehouse_root);
  4. repo-local default → <workspace_root>/.nornir/warehouse (= FunnelStore::default_root).

Why this is its own rule (E4 fix, 2026-06-12): the CLI funnel used to resolve via iceberg_warehouse_root (which ignores NORNIR_FUNNEL_ROOT and whose empty-local_path default is workspace_holger/.nornir/warehouse), while the server/MCP read the global NORNIR_FUNNEL_ROOT. So a nornir funnel submit/plan/node wrote one place and the viz Funnel tab (via the server) read another — the funnel never appeared. Routing all three through resolve_root makes the CLI write exactly where the server reads. Note the level-4 default is .nornir/warehouse (the server's default_root), deliberately not the workspace_holger/.nornir/warehouse the bench/dep-graph tables use — funnel consistency is anchored to the server, which is what viz reads through.

redb single-writer lock: the funnel root is global, so within one server only the first opener holds the exclusive catalog.redb lock. CLI reads (nornir funnel show) use the lock-tolerant Store::open_read_only — they fall back to a copied-aside snapshot instead of deadlocking against a live server. CLI writes (submit/plan/node/link/status) need the real exclusive lock via Store::open; if a server holds it, that surfaces as a clear lock error rather than silently writing to the wrong place. (For a hands-off install where the server is always up, route writes through the funnel RPC instead of the CLI.)

Partitioning (the biggest read win)

A partition spec tells Iceberg how to physically bucket data files by a column's value, so a filtered scan prunes whole files at plan time instead of reading every file ever written. Without it, a scoped read is O(all history); with it, O(the slice you asked for).

Landed — partitioned by repo (identity transform):

Table(s) Scoped read it speeds up
bench_runs query_bench_runs(BenchFilter::for_repo(..)), bench_history, gates
symbol_facts, call_edges, feature_gate_facts, git_heat_facts knowledge::query::load_latest(repo) (was "read all rows, filter in Rust")
feature_dep_edges warehouse::feature_dep_edges::query_feature_dep_edges(wh, Some(repo)) — partition-pruned to repo, latest snapshot only; Err (not empty) when never populated
*_snapshots (tantivy/dwarf/gimli/rustdoc/docs-index) — by repo latest-snapshot-for-repo lookup (resolve_snapshot_id)
*_blobs (same) — by snapshot_id blob restore: fetch one snapshot's bytes, not O(all bytes ever)

How it works in code (src/warehouse/iceberg.rs):

No backwards-compat: the partition spec is set at table creation, so an existing warehouse must be recreated to pick it up (accepted, 2026-06-07).

Remaining: optional (repo, machine, month) for bench_runs; then the non-partition wins below (compaction, uuid keys, idempotency index).

Other efficiency gaps (plan.md → "Iceberg warehouse efficiency")

Still open: an idempotency side-index (point lookup vs full scan per write); compaction + expire_snapshots; native uuid/fixed key types instead of String (kills per-row parse); drop always-null test_outcomes columns; and lazy-create the dwarf/gimli/rustdoc stub tables on first write instead of eagerly in ensure_layout.

Adding a table

  1. Add a pub fn <name>() -> Result<Schema> in iceberg_schema.rs (additive nullable columns only if evolving an existing one).
  2. Add a TABLE_<NAME> const + an ensure_layout entry in iceberg.rs: create_table_if_missing (unpartitioned) or create_partitioned_table_if_missing(.., &["repo"]) when the table is written single-partition and read scoped (see Partitioning above).
  3. Add an append path and a query_* method (mirror append_bench_run / query_bench_runs); choose an idempotency key and document it in the table above.
  4. If it's a derived artifact, prefer the artifact_snapshots/_blobs two-table shape over a bespoke schema.

Gotchas