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

nordisk/skade

.nornir/git-refs-design.md

Git-like refs (skade_katalog::git) — design & guide

Branches, tags, WAP publish, and rollback for the warehouse — 100% within the Iceberg spec. Every Iceberg table already carries a refs map (movable branches, immutable tags); this surfaces it at the catalog level and adds the git control-plane operations on top, all expressed as standard Iceberg SetSnapshotRef / RemoveSnapshotRef updates driven through the existing atomic commit path. Tables stay pure Iceberg — any reader works at a given ref.

See the capability it lands from: .nornir/git-like-catalog.md (Tier 1) and .nornir/warehouse-capability-gaps.md.


1. Why (the gap it closes)

skade-katalog already had the versioned-metadata spine a git-like catalog needs — atomic multi-table commit (atomic.rs), a current-pointer mirror (pointer_cache.rs), a durable commit log, a Ragnar snapshot index (static_index.rs), and time-travel by snapshot-id / timestamp (load_table_at / load_table_as_of). What it did not expose was Iceberg's git surface: you could not create a branch, tag a release, run write-audit-publish, or roll a table back to a known-good state through the catalog — even though iceberg-rust models all of it natively (SnapshotReference / SnapshotRetention). This closes that gap without a new dependency, without a JVM (a Nessie-without-the-JVM start), and without touching any existing read/write path.

2. The idea (refs are just snapshot pointers with a policy)

An Iceberg branch is a movable named pointer to a snapshot; a tag is an immutable one. Both live in the table metadata's refs map. Every git-like operation is therefore a metadata edit that (a) validates a precondition and (b) sets or removes one ref — exactly what TableUpdate::SetSnapshotRef / RemoveSnapshotRef do. So the whole feature is a thin, correctness-focused layer over the catalog's existing commit_table pointer-advance:

create_branch / create_tag  →  SetSnapshotRef{ ref, SnapshotReference{ snap, retention } }
drop_ref                     →  RemoveSnapshotRef{ ref }
fast_forward / publish       →  SetSnapshotRef{ branch, descendant-snap }   (ancestry-checked)
rollback_to                  →  SetSnapshotRef{ main, ancestor-snap }       (ancestry-checked)

Each goes through one redb write transaction (one fsync), appends the commit log, and updates the L0/L1 caches — the same all-or-nothing discipline the rest of the catalog uses. Reads (list_refs, ref_snapshot_id, resolve_metadata_for_ref) hit the lock-free L1 pointer mirror + L0 metadata cache; no redb transaction on the warm path.

3. The shape that runs

All methods are additive on RedbCatalog (src/git.rs):

method git analogue what it does
create_branch(ident, name, snap?, retention) git branch mint a movable branch on snap (or the current snapshot); rejects an existing name
create_tag(ident, name, snap?, max_ref_age_ms) git tag mint an immutable tag; rejects an existing name
drop_ref(ident, name) git branch -d / git tag -d delete a branch or tag
list_refs(ident) git show-ref every branch/tag + its snapshot, sorted by name
ref_snapshot_id(ident, name) git rev-parse resolve a ref to its snapshot id
fast_forward(ident, branch, to) git merge --ff-only advance a branch to a descendant snapshot
publish_branch(ident, from, to) WAP publish fast-forward to (e.g. main) to the head of from (an audited branch)
rollback_to(ident, snap) git reset --hard <ancestor> move main back to an ancestor snapshot
load_table_for_ref(ident, name) git checkout <ref> read the table as of a branch/tag (routes through load_table_at)
resolve_metadata_for_ref(ident, name) — metadata-only ref read

The two safety invariants (the correctness core)

Both mutation families are guarded so history can never silently diverge — the same rules git push and git reset enforce:

  1. Fast-forward only. fast_forward / publish_branch walk the parent_snapshot_id chain from the target back toward the branch's current head; the move is allowed only if the current head is an ancestor of the target (or equal — a no-op). A non-fast-forward move is rejected with a retryable CatalogCommitConflicts. This is what makes WAP safe: a main that advanced under an audit run refuses to publish rather than rewind.
  2. Rewind only. rollback_to allows moving main only to an ancestor of its current head — you can rewind along existing history, never jump to an unrelated line. Nothing is deleted (the newer snapshots stay in the table), so the rollback is reversible with a later fast_forward.

Every move additionally carries a TableRequirement::RefSnapshotIdMatch optimistic precondition on the head it validated, so a concurrent writer that moved the ref out from under us aborts the commit instead of clobbering it.

These invariants are covered by RED-when-broken tests (tests/git_refs.rs): remove the ancestry walk and fast_forward_and_non_ff_guard / rollback_ancestor_guard fail, because a backward fast-forward or a rollback to a sibling line would wrongly succeed.

4. What is NOT here (deferred, honestly)

5. Idiot guide (zero-knowledge)

Think of a table's history as a chain of saved states (snapshots), newest last.

Nothing here changes how tables are stored: they remain ordinary Iceberg tables, so any Iceberg reader still works. skade just turns on the git buttons.

6. Benchmarks

skade_git_refs (in-proc embedded, bench/examples/nornir-bench.rs) times the git surface over a table with a linear snapshot history and many branches/tags: the lock-free ref-read hot path (ref_snapshot_id, list_refs) and the ref-move write path (create-tag/drop cycles, one redb commit + fsync each). Heavy sweep via BENCH_GIT_REFS / BENCH_GIT_READ_ITERS / BENCH_GIT_WRITE_ITERS.

Measured — HEAVY, host loki (12 cores, 64 GiB)

Table with a 64-snapshot linear history and 256 seeded branches/tags; BENCH_GIT_READ_ITERS=2000000, BENCH_GIT_WRITE_ITERS=5000.

op result
ref_snapshot_id — lock-free ref resolve (L1 mirror + L0 metadata) 3720754 ops/sec (269 ns/op)
list_refs — build + sort the whole 256-ref map 65706 ops/sec (15219 ns/op)
ref move — create_tag + drop_ref (one redb commit + fsync each) 2760 commits/sec

Ref reads ride the same lock-free warm path as resolve_metadata, so they cost a metadata-map read, not a redb hit. Ref moves pay one durable redb transaction (fsync) per commit — the same write cost as any other catalog pointer advance, so the git control plane adds no new hot-path overhead to existing reads/writes.