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)
- Append-only, git-SHA-keyed. Facts are never mutated in place; a new row supersedes. Identity is the git SHA (+ snapshot/release id), never row order.
- Schema evolution is additive-nullable only. Never rename or retype a required field; add nullable columns (this is how
release_lineagegained the*_snapshot_idtime-machine pins). - Idempotent writes. Each table has an identity key (listed below); a re-run with the same key must not double-count.
- One backend.
IcebergWarehouse(file:// via the skade-katalog redb catalogue) today;RemoteWarehouse(gRPC, Phase 5) later — sameWarehousetrait surface.
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
- Release pipeline (
release::pipeline::run_pipeline) writes the release/bench/test/dep-graph tables in one pass, taggedrelease_id+ per-repogit_sha+ dep-graph snapshot id. nornir bench run <repo>spawns the repo's bench example, parses theBenchRunJSON, and appendsbench_runs/bench_results/test_outcomes.nornir warehouse import-jsonl <repo>loads a legacybench_history.jsonlintobench_runs(the jsonl stays as the on-disk receipt + the docs renderer's fallback source — see bencher.md).knowledge scan/index build/vector indexwrite the knowledge, full-text, and embedding projections.
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):
- explicit
[storage].local_path→<workspace_root>/<local_path>/warehouse— the repo-/workspace-local home, the recommended default for a CLI; - otherwise the shared
workspace_holger/.nornir/warehousefallback — 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:
- prints a one-line
WARNING:(degraded mode is never silent), - copies
catalog.redbaside into a tempdir and opens that copy (open_snapshot) — a point-in-time read-only catalog snapshot. The temp dir is tied to the warehouse lifetime; only the few-MB catalog index is copied, not the data: table files are referenced by absolutefile://paths, so they still resolve to the live warehouse.
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):
- explicit
--funnel-root <dir>flag (none wired today; the hook exists); NORNIR_FUNNEL_ROOT— the global override, used as-given (nowarehouse/suffix).nornir serve installwrites it into the generatednornir.env(default/home/nornir/funnel), so on a real install every surface lands on that exact dir;- config
[storage].local_path→<workspace_root>/<local_path>/warehouse(kept in lock-step withconfig::Loaded::warehouse_root); - 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 ignoresNORNIR_FUNNEL_ROOTand whose empty-local_pathdefault isworkspace_holger/.nornir/warehouse), while the server/MCP read the globalNORNIR_FUNNEL_ROOT. So anornir funnel submit/plan/nodewrote one place and the viz Funnel tab (via the server) read another — the funnel never appeared. Routing all three throughresolve_rootmakes the CLI write exactly where the server reads. Note the level-4 default is.nornir/warehouse(the server'sdefault_root), deliberately not theworkspace_holger/.nornir/warehousethe 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):
create_partitioned_table_if_missing(.., &["repo"])lays the table out with an identity spec (identity_partition_specbinds names→ids against the schema, returns it unbound forTableCreation).append_batchcallspartition_key_for(table, batch)— it reads the table's default spec and derives aPartitionKeyfrom the batch, then passes it toDataFileWriterBuilder::build(Some(key))so the file is tagged with its partition. Invariant: identity-on-string only, and each append must be single-partition (all rows share the value) — true for nornir's per-repo appends; it errors loudly rather than mis-tag if a batch ever spans repos.- Reads are unchanged: the existing
repo == Xscan predicate now prunes by partition (guaranteed) instead of relying on row-group stats.
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
- Add a
pub fn <name>() -> Result<Schema>iniceberg_schema.rs(additive nullable columns only if evolving an existing one). - Add a
TABLE_<NAME>const + anensure_layoutentry iniceberg.rs:create_table_if_missing(unpartitioned) orcreate_partitioned_table_if_missing(.., &["repo"])when the table is written single-partition and read scoped (see Partitioning above). - Add an append path and a
query_*method (mirrorappend_bench_run/query_bench_runs); choose an idempotency key and document it in the table above. - If it's a derived artifact, prefer the
artifact_snapshots/_blobstwo-table shape over a bespoke schema.
Gotchas
- Never commit
.nornir/warehouse/— it's live data; it's gitignored. The catalogue is a single redb file; deleting it orphans the tables. - Idempotency is per-key, not per-call — re-running a release with the same
release_idupdates in place; a new id appends. Don't key by timestamp. - Schema changes are additive-nullable only. Renaming a required field breaks every historical row's read.
- The docs renderer reads
bench_history.jsonlfirst as a fallback, iceberg when present — keep the jsonl receipt in sync viabench run(see bencher.md).