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

nordisk/skade

.nornir/commit-reasoning.md

The commit / write path — reasoning

Class: reasoning · Function: commit. The descriptive what is in commit-design.md.

Why group-commit — the one place embedded genuinely wins

A REST/JVM catalog serialises commits behind a network round-trip and, typically, a per-commit durable write. An embedded catalog can do something a service structurally cannot: coalesce N concurrent commits into one redb transaction and one fsync. Under a write burst this beats a service on both throughput (fewer fsyncs) and tail latency (no queue behind a socket). This is why group_commit exists and why the commit_burst benchmark exists — to demonstrate the lever, not just claim it. The alternative, one-txn-per-commit, is simpler but throws away the batching win precisely when it matters most.

The design constraint that makes coalescing safe: each queued CommitFn checks its own optimistic precondition and writes nothing on Err. So one op's conflict can't taint the shared transaction — a failing commit in the batch fails alone, and the rest still land. Without that isolation, batching would couple unrelated writers' fates.

Why optimistic CAS instead of a lock held across the commit

We validate requirements and stage metadata before taking the redb write lock, then compare-and-swap the pointer against the base location we read at stage time. The alternative — hold a lock across "read current → write blob → advance" — would serialise the expensive object write (network I/O) under the lock, destroying the group-commit win and adding head-of-line blocking. Optimistic CAS keeps the network write outside the lock; the only thing under the lock is the pointer compare-and-swap, which is why many writers can share one transaction. The price is that a losing writer must retry (CatalogCommitConflicts is marked retryable), which is the right trade when conflicts are rare.

Why commit_table and atomic_release_raw exist at all

iceberg-rust 0.9.1 makes TableCommit's builder pub(crate). A caller that holds the wire pieces — a vec of TableRequirement and a vec of TableUpdate, e.g. decoded from an Iceberg-REST commit_table request — literally cannot construct a TableCommit to hand to the trait's update_table. Rather than wait on upstream, skade exposes commit_table (one table) and atomic_release_raw (many) that take the raw triples and drive the identical persist path. When upstream opens the builder, these stay as convenient lower-level entry points.

Why multi-table atomicity is redb's job, not ours

Iceberg's per-table Transaction::commit guarantees atomicity for one table only. When several tables represent one logical release (bench_runs + dep_graph + components), partial visibility of a half-committed set is a real hazard. We get all-or-nothing across tables for free because redb's write transaction is globally atomic across every table in the file — so atomic_release stages all blobs, then flips every pointer in a single redb txn. Building this on a filesystem or a service would have meant a two-phase-commit protocol; redb hands it to us.

The honest edge: the staged metadata blob writes (step 2) are not transactional — a failure there leaves orphan files. We accept this because Iceberg already has an orphan-file cleanup story, and the alternative (transacting object writes) isn't available on plain object storage anyway.

Why durability is a knob, not a fixed policy

Immediate (fsync per commit) is the safe default. But bulk import and benchmarking are legitimately willing to trade crash-durability for throughput, and redb exposes Eventual / None levels cleanly. Making it a build-time knob lets the same catalog serve both a crash-safe production path and a fast import/bench path without a code fork. The None caveat (the file grows because redb only frees pages at higher durability) is documented so nobody leaves a production catalog on None.