Navigation: ↑ Persistence architecture · Lock-free overlay · Durability & recovery · Eviction
The persistent-ARTrie family is lock-free by default: SharedARTrie,
SharedCharARTrie, and SharedVocabARTrie are bare Arc<T> handles, and both reads and
writes proceed without a global lock. The only operations that take any mutual exclusion
are concurrent checkpoints, byte/char merges, and eviction — and those obey
one strict, acyclic lock order. This document is the concurrency contract: the F4 lock
collapse, MVCC snapshot reads, the two distinct "epoch" mechanisms, version GC, and the
eviction-safety stamp.
| Term | Definition |
|---|---|
| lock-free | System-wide progress is guaranteed: some thread always makes progress, even if others stall. Readers here are additionally wait-free (every reader finishes in bounded steps). |
| linearizable | Every operation appears to take effect atomically at a single point between its call and return. For a write, that point is its winning root CAS. |
| MVCC | Multi-Version Concurrency Control — readers observe a consistent version (snapshot) unaffected by concurrent writers. |
| EBR | Epoch-Based Reclamation — deferring the freeing of a retired object until all readers that could still reference it have left a shared epoch. |
| F4 lock collapse | The change that deleted the outer trie RwLock, making the Shared* handles bare Arc<T> with lock-free reads and writes. |
Before F4, each handle was Arc<RwLock<T>>; a writer took the write lock, serializing all
mutation. F4 (core/shared_access.rs) deletes that outer RwLock: the handle is a bare
Arc<T>, every live write target is the lock-free overlay CAS root, and mutators became
&self, routing to CAS internally.
Backward compatibility is preserved without rewriting ~270 handle.read() / handle.write()
call sites (plus the cross-repo liblevenshtein-rust sibling): the SharedTrieAccess
extension trait adds .read() / .write() to the bare Arc<T>, each returning a
transparent, Deref-only TrieAccessGuard that hands back a shared &T — there is
no lock. An existing let mut g = handle.write(); g.insert(term) still compiles because
insert is now &self. The two Copy-enum fields that a lifecycle setter mutates
(durability_policy) live in an AtomicEnumCell<E: U8Enum> — a single AtomicU8 with
lock-free &self load/store, strictly cheaper than the old RwLock-guarded read, with no
new unsafe.
The residual locks obey a strict acyclic order — acquire only top-to-bottom:
- CK —
checkpoint_lock: Mutex<()>serializes concurrent checkpoints. - merge_lock — serializes merge-vs-merge (byte/char only).
- EC —
eviction_coordinator: Mutex<Option<..>>, a leaf: never held across acquiringCK/merge_lock, and never held across a worker.join()— the drop-before-join discipline,let x = field.lock().take(); x.shutdown();.
Only three locks remain. The former inner owned_root: RwLock<TrieRoot> rung (once a dormant
kill-switch / WAL-replay fallback) was removed once the lock-free overlay became the sole
production structure — reads and writes take no owned-tree lock (see
lock-free-overlay.md; dict_impl.rs retains only
checkpoint_lock / merge_lock / eviction_coordinator).
Vocab is a strict subset. SharedVocabARTrie is overlay-only, so it has no merge_lock —
only CK and EC. Its eviction callback is a no-op, so CK never
reads EC; the two are independent and the order is trivially acyclic. The lock hierarchy
is exhaustively exercised by tests/persistent_lockfree_f4_lock_hierarchy_loom.rs and
tests/vocab_lockfree_f4_lock_hierarchy_loom.rs, and the shared-handle linearizability by
SharedPersistentConcurrency.tla (byte + char + vocab). The full field-disposition record
is ../design/f4-lock-collapse-implementation.md.
Overlay nodes are immutable and Arc-refcounted, so a plain read already sees a consistent
snapshot and a retired version is freed when its last holder drops it — no epoch is needed
for basic node reclamation (see lock-free-overlay.md).
On top of that, core/mvcc.rs provides an explicit snapshot-transaction layer: TrieRoot
(the snapshot interface, blanket-implemented for OverlayNode<K, V>) and ReadTransaction,
which begin(root, epoch_manager) pins an epoch and freezes an Arc root, so
contains / get observe one immutable version for the transaction's lifetime. The
EpochManager (core/concurrency.rs) is the EBR substrate that additionally gates
eviction and version GC:
The codebase uses "epoch" for two unrelated mechanisms:
| ① EBR — reader-safety epochs | ② Durability / checkpoint epochs | |
|---|---|---|
| Module | core/concurrency.rs, core/mvcc.rs |
core/epoch.rs |
| Answers | when is a retired version safe to free? | how is the WAL segmented for checkpoints? |
| Mechanism | pin an epoch during a read; reclaim after all pinners leave | per-epoch WAL segments; threshold-driven advancement; trusted after checkpoint publish |
For point-in-time / time-travel versions, core/version_checkpoint.rs
(VersionCheckpointManager, VersionSnapshot) tracks current-vs-durable version ids, and
core/version_gc.rs (VersionGcRegistry, ReaderGuard) reclaims a superseded version only
when (a) no active reader is pinned and (b) a durable GC decision has been recorded — the
reader guard blocks reclamation until it drops. This "active readers block reclaim" race is
model-checked by VersionLifecycle.tla.
Eviction unswizzles cold overlay subtrees to disk concurrently with readers and writers.
The lynchpin that makes it race-free is the node's serial_disk_ptr stamp (the M-2a guard):
a node is safe to unswizzle iff its stamp still equals the disk pointer the eviction
registry recorded. A concurrent writer that path-copies the node publishes a new version
with a fresh identity, so the registered stamp no longer matches — and the evictor backs
off rather than publish a stale image:
This is model-checked by OverlayEvictionCas.tla and OverlayEvictionStale.tla (each with
an _Unsafe negative control that removes the stamp and exhibits the loss). The full
subsystem is in eviction.md.
Everything above describes concurrency within one OS process. The lock-free overlay, the epoch
managers, the WAL frontier, and the checkpoint counters are all process-local heap — an
AtomicNodePtr holds a virtual address meaningless in another process, and nothing coordinates the
WAL, mmap growth, or checkpoint rewrites across processes. Exactly one process may own a backing
file. A second process — or a second concurrent handle to the same path — is rejected with
PersistentARTrieError::FileLocked by the Tier-1 advisory lock (flock on a .wlock sidecar,
acquired once at the DiskManager open chokepoints): see
os-level-locking.md. Genuine single-writer / multi-reader-process
access is the Tier-2 SWMR design, swmr-multiprocess.md. Both
preserve the single-process lock-free invariant described here — the lock is taken once per open,
never per operation.
| Guarantee | Where proved |
|---|---|
| Deadlock-freedom of the F4 hierarchy | tests/persistent_lockfree_f4_lock_hierarchy_loom.rs, tests/vocab_lockfree_f4_lock_hierarchy_loom.rs |
| Linearizability of shared reads/writes/checkpoints (byte/char/vocab) | SharedPersistentConcurrency.tla, ConcurrentVocabLinearizability.tla, LockFreeARTrieLinearizability.tla |
| No lost update on the value/counter CAS | LockFreeOverlayValueCas.tla, LockFreeCounterMergeAtomicity.tla |
| Reads observe only completed visible state | SharedPersistentConcurrency.tla (ReadsObserveCompletedVisibleState) |
| Eviction stamp safety | OverlayEvictionCas.tla, OverlayEvictionStale.tla |
The full invariant
Status. The lock-free/F4 design described here is the current, verified architecture. The byte, char, and vocab collapse are committed and gated; the vocab-F4 extension is complete and model-checked (
SharedPersistentConcurrency.tlaclean; the threevocab_*loom/concurrency tests pass).
- M. Herlihy, N. Shavit. The Art of Multiprocessor Programming. Morgan Kaufmann, 2008 (lock-freedom, linearizability).
- K. Fraser. Practical Lock-Freedom. PhD thesis, University of Cambridge, 2004 (EBR). Technical Report UCAM-CL-TR-579.