Synthesized in: The lock-free overlay — the live representation. That page is the current architecture-level account of the immutable, CAS-published overlay (arc-swap root, owned Child enum, zero unsafe). This record is the foundational mechanism design plus its Phase-A conversion notes; the older im::Vector / raw-AtomicU64-root sketch in the body below is superseded (see the Phase-A box) and kept for provenance.
This document describes the lock-free concurrent insert mechanism for PersistentARTrie and PersistentARTrieChar using persistent (immutable) data structures and Compare-And-Swap (CAS) operations.
Traditional concurrent trie implementations use locks (RwLock) which serialize writes and can cause contention when many threads insert concurrently. This design uses persistent data structures combined with CAS operations to achieve truly lock-free concurrent inserts.
⚠️ Phase A — current state (supersedes the
im::Vector/ RwLock descriptions below)The sections after this box describe the original design. The char overlay (
PersistentCharNode, used by both the char and vocab lock-free overlays) has since been made genuinely lock-free and leak-free; the byte overlay has received the correctness fix only (its owned-Arcconversion is a follow-on). Current reality, for reconstruction:
Atomic root is genuinely atomic.
AtomicNodePtr(now the sharedcore/overlay/atomic_ptr.rs) wrapsarc_swap::ArcSwapOption<PersistentCharNode>, not aRwLock.load()→ArcSwapOption::load_full()(lock-free, hazard-protected, returns an ownedArc= an MVCC snapshot);compare_exchange→ArcSwapOption::compare_and_swap+Arc::ptr_eq(pointer-identity CAS, no spurious failure). An earlier stopgap stored a rawArcin anAtomicU64(unsound) and then retreated to aRwLock(a "lock-free root" that was a lock). arc-swap is the sound and lock-free resolution.Children are owned, not smuggled (the leak fix).
PersistentCharNode's child slots areChild = InMem(Arc<PersistentCharNode>) | OnDisk(SwizzledPtr)(now the sharedcore/overlay/node.rs), stored in a tieredChildStore($Inline[\le 4]$ zero-alloc /Heap[5+]) — notim::Vector. Previously an in-memory child was anArc::into_rawpointer smuggled throughSwizzledPtr'su64; because thatu64has noDrop, every superseded node version leaked its children. With ownedChild::InMem, reclamation is ordinaryArcrefcounting (a node frees exactly when no live version — including reader snapshots — references it). No EBR is required for correctness; it would only batch refcount traffic. All overlayunsafe(theArc::from_rawhandoff
- the manual
unsafe impl Send/Sync) is removed:Send/Syncnow auto-derive (the compiler proves what the manual impl asserted), and theformal-verification/UNSAFE_INVENTORY.tsv/UNSAFE_CONTRACTS.tsvrows for those blocks are deleted.Prefix-insert finalization (correctness fix). At
depth == len,build_path_recursivereturns the existing (shared) node un-finalized, soinsert_cas'stry_set_final(an atomicfetch_or) is the single arbiter of the winner across racing inserters. The old code pre-finalized vianode.as_final(), which madetry_set_finalobserve an already-final node and wrongly report a new proper-prefix term (e.g. "d" after "da") as a duplicate — returningfalseand skipping the lock-free cache, so the cache-onlymerge_lockfree_to_persistentsilently dropped the term (data loss). Fixed in both the char and byte overlays. The vocab overlay commits final+value in a single root-CAS-published path-copy and is already correct (it must not receive this change).Verification.
tests/persistent_lockfree_overlay_proptest.rs(BTreeSet oracle + contended finalization + post-merge data-loss witnesses),tests/persistent_lockfree_overlay_loom.rs(no-lost-update, prefix single- arbiter, reader-no-UAF), and an in-cratereclaim_testsmodule inlockfree_cas.rs(Arc::strong_count == 1after drop $\Rightarrow$ no leaked references).Scope: char overlay = fully converted (atomic root + owned children + fix). Byte overlay = now also fully converted —
im::Vector→ tieredChildStore(Inline/Heap, u8 keys),SwizzledPtrchildren → ownedChild,AtomicNodePtrRwLock→arc_swap::ArcSwapOption, all overlayunsaferemoved +Send/Syncauto-derived. This removed the crate's lastimuser, so theimdependency is dropped fromCargo.toml. Vocab overlay = already correct (sharesPersistentCharNode; migrated alongside char). Both byte and char overlays now carry thereclaim_testsstrong_count == 1-after-drop leak witness.
Uses im::Vector for keys and children to enable O(log n) structural sharing:
pub struct PersistentNode {
version: AtomicU64, // Monotonic version counter
keys: im::Vector<u8>, // Sorted child keys
children: im::Vector<SwizzledPtr>, // Child pointers
flags: AtomicU8, // IS_FINAL, HAS_VALUE, etc.
value: AtomicU64, // Value for final nodes
prefix: Arc<[u8]>, // Path compression
}
Key property: with_child(key, child) returns a NEW node, never mutates self.
Wraps Arc<PersistentNode> for atomic compare-and-swap:
pub struct AtomicNodePtr {
ptr: AtomicU64, // Raw pointer stored as u64
}
impl AtomicNodePtr {
fn compare_exchange(
&self,
expected: &Arc<PersistentNode>,
new: Arc<PersistentNode>,
) -> Result<(), Arc<PersistentNode>>;
}
The algorithm builds the path from leaf to root:
The epoch manager protects against ABA problems:
When multiple threads insert the same term:
The try_set_final() uses atomic fetch_or:
pub fn try_set_final(&self) -> bool {
let old = self.flags.fetch_or(IS_FINAL, Ordering::AcqRel);
(old & IS_FINAL) == 0 // Returns true only if THIS call set it
}
| Operation | Complexity | Notes |
|---|---|---|
| Cache lookup | O(1) | DashMap sharded access |
| Tree traversal | O(k) | k = term length |
| Node modification | O(log n) | n = children count (structural sharing) |
| CAS retry | O(1) expected | Contention-dependent |
Under typical workloads with unique terms per thread:
cas_retries atomic counterlet mut trie = PersistentARTrie::create("vocab.part")?;
trie.enable_lockfree(); // Must call before using CAS methods
let trie = Arc::new(trie); // No RwLock needed!
let handles: Vec<_> = (0..12).map(|i| {
let t = Arc::clone(&trie);
thread::spawn(move || {
for term in get_terms_for_thread(i) {
t.insert_cas(term.as_bytes());
}
})
}).collect();
if trie.contains_lockfree(b"hello") {
println!("Found!");
}
let merged_count = trie.merge_lockfree_to_persistent()?;
| Component | Thread-Safe? | Mechanism |
|---|---|---|
insert_cas() | ✓ | CAS + retry loop |
contains_lockfree() | ✓ | Immutable traversal |
try_set_final() | ✓ | Atomic fetch_or |
lockfree_cache | ✓ | DashMap (sharded) |
AtomicNodePtr | ✓ | AtomicU64 + Arc |
| File | Description |
|---|---|
persistent_artrie/core/overlay/node.rs | canonical shared OverlayNode<K, V> (post-G4) — the unified byte/char node body |
persistent_artrie/core/overlay/atomic_ptr.rs | canonical shared AtomicNodePtr<K, V> (arc-swap root) |
persistent_artrie/nodes/persistent_node.rs | byte PersistentNode<V> — now a ByteKey alias of the shared node |
persistent_artrie/nodes/atomic_ptr.rs | byte AtomicNodePtr — alias of the shared root |
persistent_artrie/dict_impl.rs | Lock-free methods for PersistentARTrie (byte) |
persistent_artrie/char/nodes/persistent_node.rs | char PersistentCharNode<V> — CharKey alias of the shared node |
persistent_artrie/char/nodes/atomic_ptr.rs | char AtomicNodePtr — alias of the shared root |
persistent_artrie/char/dict_impl_char.rs | Lock-free methods for PersistentARTrieChar |
persistent_artrie/vocab/mutation_api.rs | vocab lock-free insert with atomic index allocation (next_index.fetch_add) |
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |