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

nordisk/skade

.nornir/time-travel-reasoning.md

Time-travel — reasoning

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

Why a static search tree (STree64), not a B-tree lookup on redb

Snapshot ids are i64, immutable, and globally unique. Time-travel is, at its core, "map this id to a location." redb can do that — it's the fallback — but a redb point lookup pays begin_read() + open_table() + a B-tree descent through a general-purpose on-disk structure. For a hot historical read path that a query planner hits repeatedly, that's wasteful.

The Ragnar STree64 (znippy-zoomies) is a cache-line-packed implicit B-tree built over the sorted ids. A lookup is a handful of cache-line probes with no hashing (the ids are already integers), no lock, and no redb access. We reuse it verbatim, so the catalog inherits that crate's AVX2 and hugepage tuning for free on each dependency bump. Building our own index structure would have been slower and more code.

Why not just hash the ids? A hash map answers point lookups but loses the batch win below, and would still need locking or an immutable-swap wrapper. The sorted tree gives both point and pipelined-batch lookup from one structure.

Why the batch path (lookup_many / resolve_many) is the real prize

Query planners don't ask for one historical snapshot — they fan out over many (scan a range of history, diff snapshots, plan a time-window). Doing that as N independent lookups pays N tree walks and, worse, N redb transactions for the uncovered tail. resolve_many instead does one pipelined STree64 pass (batch_stream::<16>, which overlaps the cache-line misses across queries — the specific win Ragnar's structure was built for) and one redb read transaction for everything the index doesn't cover. One tree walk, one txn, N answers. This is the difference between time-travel being a novelty and being usable in a planner.

Why an immutable swap + a live tail, instead of an incrementally-updated index

The index could be mutated in place on every commit. We deliberately don't:

The cost of not updating in place is that recent commits (above the cutoff) aren't in the tree yet — so we keep the redb commits live tail as the fallback for ids above the cutoff. Routing id <= cutoff → tree, else → redb gives lock-free speed for the bulk of history and correctness for the fresh tail, and the background compactor moves the cutoff forward. Reads pay one atomic load to check the cutoff; writes pay one atomic increment to drive the trigger.

Why compaction is background, single-flight, threshold-triggered