Skip to main content

subetha_cxc/
shared_hash_map.rs

1//! `SharedHashMap<K, V>` - cross-process open-addressed hash map
2//! backed by a single MMF file.
3//!
4//! # Why open addressing?
5//!
6//! All storage is inline. No allocator, no pointer indirection. Each
7//! slot lives in its own cache line; the entire table is a flat
8//! array in the MMF. Robin Hood, linear, and quadratic probing all
9//! work; we use **linear probing** because it's the most cache-
10//! friendly on modern CPUs (sequential access dominates probe-
11//! variance on speculative-prefetch architectures).
12//!
13//! # Stable hashing
14//!
15//! `std::hash::BuildHasher` uses a per-process random seed for DoS
16//! resistance, which would make keys irreproducible across
17//! processes. We use **FNV-1a** over the key bytes - fast, deps-free,
18//! and deterministic across processes / runs / OSes.
19//!
20//! # Layout
21//!
22//! ```text
23//! +---------------------------+
24//! | MapHeader (64B)           |
25//! |   magic, capacity, count  |
26//! |   key_size, value_size    |
27//! +---------------------------+
28//! | Slot[0]  (64B cache line) |
29//! |   state (EMPTY/OCC/TS)    |
30//! |   version (SeqLock)       |
31//! |   hash (cached)           |
32//! |   payload [u8; 48]: K + V |
33//! | Slot[1] ...               |
34//! +---------------------------+
35//! ```
36//!
37//! # Protocol
38//!
39//! ## Insert
40//! 1. Hash key (FNV-1a).
41//! 2. Probe from `hash % capacity`, linearly.
42//! 3. At each slot:
43//!    - **Empty**: CAS state Empty → Occupied. On success, SeqLock-
44//!      write `(K, V)` and store hash; bump `count`. Return Inserted.
45//!    - **Occupied & hash matches & key matches**: SeqLock-update V
46//!      (state unchanged). Return Updated.
47//!    - **Occupied & no match**: probe next slot.
48//!    - **Tombstone**: skip (linear probe continues; tombstones do
49//!      NOT terminate insert because we want to overwrite them
50//!      preferentially - track first tombstone and use it if no
51//!      Empty is found earlier than a definitive "not present"
52//!      conclusion).
53//!
54//! Actually the simpler insert: probe until first Empty (insert
55//! there) OR find key (update). The tombstone-reuse optimisation
56//! costs an extra bookkeeping pass; we skip it and reclaim
57//! tombstones via the `compact()` method (single-writer in-place
58//! rebuild) instead.
59//!
60//! ## Get
61//! 1. Hash key, probe linearly.
62//! 2. **Empty**: key absent (probe always terminates at Empty).
63//! 3. **Occupied & hash matches**: SeqLock-read; if K matches, return V.
64//! 4. **Tombstone or hash mismatch**: continue probing.
65//!
66//! ## Remove
67//! 1. Find key (same probe).
68//! 2. CAS state Occupied → Tombstone. `count.fetch_sub(1)`.
69//!
70//! # Concurrency
71//!
72//! All slot writes are SeqLock-protected so readers never observe
73//! torn key+value. The state byte's CAS is the serialisation point
74//! for who "owns" a slot for write. Two writers racing on the same
75//! key both reach the same slot; one wins the Empty→Occupied CAS
76//! and writes; the loser falls through to "Occupied + key matches"
77//! and updates instead.
78//!
79//! # Capacity and load factor
80//!
81//! Fixed at create time. Recommend `capacity = 2 * expected_max`
82//! to keep load factor below 0.5; linear probing degrades sharply
83//! above 0.7. `insert` returns `MapError::Full` when the probe
84//! chain saturates.
85//!
86//! # If you need dynamic sizing, use `SharedUniversal`
87//!
88//! `SharedHashMap` deliberately does NOT implement resize-on-grow.
89//! Cross-process resize requires the same reader-coordination
90//! machinery as MMF-backed migration (atomic file rename, reader
91//! re-open signaling). Rather than reinvent that machinery inside
92//! `SharedHashMap`, callers who need a dynamically-resizing hash
93//! map should use [`crate::shared_universal::SharedUniversal<T>`]
94//! configured with hash-map-only backings. The migration mechanism
95//! handles cross-process resize correctly, with the reader-side
96//! generation-bump protocol that makes wrap-around safe.
97
98use std::fs::{File, OpenOptions};
99use std::marker::PhantomData;
100use std::mem::size_of;
101use std::path::Path;
102use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering};
103
104use memmap2::{MmapMut, MmapOptions};
105
106pub const MAP_MAGIC: u32 = 0x4150_484D;
107pub const MAP_PAYLOAD_BYTES: usize = 48;
108
109pub const SLOT_EMPTY: u8 = 0;
110pub const SLOT_OCCUPIED: u8 = 1;
111pub const SLOT_TOMBSTONE: u8 = 2;
112
113#[repr(C, align(64))]
114pub struct MapHeader {
115    pub magic: u32,
116    pub capacity: u32,
117    pub count: AtomicU64,
118    pub key_size: u32,
119    pub value_size: u32,
120    /// Monotonic counter of tombstones currently in the table.
121    /// Bumped by `remove`, zeroed by `compact`. Used by callers
122    /// (e.g. `SharedLRUCache`) to decide when to compact.
123    pub tombstones: AtomicU64,
124    _pad: [u8; 32],
125}
126
127#[repr(C, align(64))]
128pub struct MapSlot {
129    pub state: AtomicU8,
130    _pad1: [u8; 3],
131    pub version: AtomicU32,
132    pub hash: AtomicU64,
133    pub payload: [u8; MAP_PAYLOAD_BYTES],
134}
135
136const _: () = {
137    assert!(size_of::<MapHeader>() == 64);
138    assert!(size_of::<MapSlot>() == 64);
139};
140
141pub const fn map_file_size(capacity: usize) -> usize {
142    size_of::<MapHeader>() + capacity * size_of::<MapSlot>()
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum MapError {
147    Full,
148    PayloadTooLarge,
149    LayoutMismatch,
150    IoError(std::io::ErrorKind),
151}
152
153impl From<std::io::Error> for MapError {
154    fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum InsertOutcome {
159    Inserted,
160    Updated,
161}
162
163/// FNV-1a 64-bit over a byte slice. Deterministic across processes
164/// (unlike `std::hash::BuildHasher` which uses per-process random
165/// seeds for DoS resistance).
166#[inline]
167pub fn fnv1a_64(bytes: &[u8]) -> u64 {
168    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
169    for &b in bytes {
170        h ^= b as u64;
171        h = h.wrapping_mul(0x100_0000_01b3);
172    }
173    h
174}
175
176pub struct SharedHashMap<K: Copy + Eq + 'static, V: Copy + 'static> {
177    _file: File,
178    mmap: MmapMut,
179    capacity: usize,
180    /// `capacity - 1`, valid only when `cap_is_pow2`.
181    cap_mask: usize,
182    /// True when `capacity` is a power of two, so slot reduction can use
183    /// `& cap_mask` instead of a `% capacity` hardware DIV. The probe
184    /// loop reduces twice per step (start + each probe), so this removes
185    /// the DIV from the hash-map hot path when capacity is pow2.
186    cap_is_pow2: bool,
187    _phantom: PhantomData<(K, V)>,
188    header_sidecar: subetha_core::HandshakeHeader,
189    ring_sidecar: Box<subetha_core::ObservationRing>,
190}
191
192unsafe impl<K: Copy + Eq + Send + 'static, V: Copy + Send + 'static> Send for SharedHashMap<K, V> {}
193unsafe impl<K: Copy + Eq + Sync + 'static, V: Copy + Sync + 'static> Sync for SharedHashMap<K, V> {}
194
195impl<K: Copy + Eq + Send + Sync + 'static, V: Copy + Send + Sync + 'static>
196    subetha_sidecar::AdaptiveInstance for SharedHashMap<K, V>
197{
198    fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
199    fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
200    fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
201        Box::new(subetha_sidecar::NoMigrationPolicy)
202    }
203}
204
205impl<K: Copy + Eq + 'static, V: Copy + 'static> SharedHashMap<K, V> {
206    fn check_layout() -> Result<(), MapError> {
207        if size_of::<K>() + size_of::<V>() > MAP_PAYLOAD_BYTES {
208            return Err(MapError::PayloadTooLarge);
209        }
210        Ok(())
211    }
212
213    pub fn create(path: impl AsRef<Path>, capacity: usize) -> Result<Self, MapError> {
214        Self::check_layout()?;
215        assert!(capacity >= 2);
216        let total = map_file_size(capacity);
217        let file = OpenOptions::new()
218            .read(true).write(true).create(true).truncate(true)
219            .open(path.as_ref())?;
220        file.set_len(total as u64)?;
221        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
222        let hdr_ptr = mmap.as_mut_ptr() as *mut MapHeader;
223        unsafe {
224            std::ptr::write_bytes(hdr_ptr as *mut u8, 0, size_of::<MapHeader>());
225            (*hdr_ptr).magic = MAP_MAGIC;
226            (*hdr_ptr).capacity = capacity as u32;
227            (*hdr_ptr).key_size = size_of::<K>() as u32;
228            (*hdr_ptr).value_size = size_of::<V>() as u32;
229            // count and tombstones are AtomicU64; write_bytes zeroed
230            // the storage, which is the valid representation of 0.
231        }
232        for i in 0..capacity {
233            let slot_ptr = unsafe {
234                mmap.as_mut_ptr()
235                    .add(size_of::<MapHeader>())
236                    .add(i * size_of::<MapSlot>())
237            } as *mut MapSlot;
238            unsafe {
239                std::ptr::write(slot_ptr, MapSlot {
240                    state: AtomicU8::new(SLOT_EMPTY),
241                    _pad1: [0; 3],
242                    version: AtomicU32::new(0),
243                    hash: AtomicU64::new(0),
244                    payload: [0u8; MAP_PAYLOAD_BYTES],
245                });
246            }
247        }
248        Ok(Self {
249            _file: file, mmap, capacity,
250            cap_mask: capacity.wrapping_sub(1),
251            cap_is_pow2: capacity.is_power_of_two(),
252            _phantom: PhantomData,
253            header_sidecar: subetha_core::HandshakeHeader::new(),
254            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
255        })
256    }
257
258    pub fn open(path: impl AsRef<Path>, expected_capacity: usize) -> Result<Self, MapError> {
259        Self::check_layout()?;
260        let total = map_file_size(expected_capacity);
261        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
262        if file.metadata()?.len() < total as u64 {
263            return Err(MapError::LayoutMismatch);
264        }
265        let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
266        let hdr = unsafe { &*(mmap.as_ptr() as *const MapHeader) };
267        if hdr.magic != MAP_MAGIC
268            || hdr.capacity != expected_capacity as u32
269            || hdr.key_size != size_of::<K>() as u32
270            || hdr.value_size != size_of::<V>() as u32
271        {
272            return Err(MapError::LayoutMismatch);
273        }
274        Ok(Self {
275            _file: file, mmap, capacity: expected_capacity,
276            cap_mask: expected_capacity.wrapping_sub(1),
277            cap_is_pow2: expected_capacity.is_power_of_two(),
278            _phantom: PhantomData,
279            header_sidecar: subetha_core::HandshakeHeader::new(),
280            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
281        })
282    }
283
284    /// Reduce an index into `[0, capacity)`. Uses `& cap_mask` when
285    /// capacity is a power of two (the common case) - removing the
286    /// `% capacity` hardware DIV the linear-probe loop would otherwise
287    /// run on every step - and falls back to the modulo otherwise.
288    /// A bit-mask is required (not Lemire-style multiply-shift) because
289    /// linear probing needs consecutive indices to map to consecutive
290    /// slots with wraparound.
291    #[inline]
292    fn wrap(&self, i: usize) -> usize {
293        if self.cap_is_pow2 { i & self.cap_mask } else { i % self.capacity }
294    }
295
296    #[inline]
297    pub fn capacity(&self) -> usize { self.capacity }
298
299    #[inline]
300    pub fn len(&self) -> usize {
301        self.header().count.load(Ordering::Acquire) as usize
302    }
303
304    #[inline]
305    pub fn is_empty(&self) -> bool { self.len() == 0 }
306
307    fn header(&self) -> &MapHeader {
308        unsafe { &*(self.mmap.as_ptr() as *const MapHeader) }
309    }
310
311    fn slot(&self, idx: usize) -> &MapSlot {
312        let base = unsafe { self.mmap.as_ptr().add(size_of::<MapHeader>()) };
313        unsafe { &*(base.add(idx * size_of::<MapSlot>()) as *const MapSlot) }
314    }
315
316    fn hash_key(k: &K) -> u64 {
317        let bytes = unsafe {
318            std::slice::from_raw_parts(k as *const K as *const u8, size_of::<K>())
319        };
320        fnv1a_64(bytes)
321    }
322
323    /// SeqLock-write a (K, V) pair into a slot's payload region.
324    fn write_payload(&self, slot_idx: usize, k: &K, v: &V) {
325        let slot = self.slot(slot_idx);
326        slot.version.fetch_add(1, Ordering::AcqRel); // odd
327        let base = unsafe {
328            self.mmap.as_ptr()
329                .add(size_of::<MapHeader>())
330                .add(slot_idx * size_of::<MapSlot>())
331                .add(std::mem::offset_of!(MapSlot, payload))
332                as *mut u8
333        };
334        unsafe {
335            // Layout: key bytes then value bytes.
336            std::ptr::copy_nonoverlapping(
337                k as *const K as *const u8, base, size_of::<K>(),
338            );
339            std::ptr::copy_nonoverlapping(
340                v as *const V as *const u8,
341                base.add(size_of::<K>()),
342                size_of::<V>(),
343            );
344        }
345        slot.version.fetch_add(1, Ordering::AcqRel); // even
346    }
347
348    /// SeqLock-read a (K, V) pair from a slot. Spins on odd version.
349    fn read_payload(&self, slot_idx: usize) -> (K, V) {
350        let slot = self.slot(slot_idx);
351        loop {
352            let v1 = slot.version.load(Ordering::Acquire);
353            if v1 & 1 != 0 {
354                std::hint::spin_loop();
355                continue;
356            }
357            let mut k = std::mem::MaybeUninit::<K>::uninit();
358            let mut v = std::mem::MaybeUninit::<V>::uninit();
359            let src = unsafe {
360                self.mmap.as_ptr()
361                    .add(size_of::<MapHeader>())
362                    .add(slot_idx * size_of::<MapSlot>())
363                    .add(std::mem::offset_of!(MapSlot, payload))
364            };
365            unsafe {
366                std::ptr::copy_nonoverlapping(
367                    src, k.as_mut_ptr() as *mut u8, size_of::<K>(),
368                );
369                std::ptr::copy_nonoverlapping(
370                    src.add(size_of::<K>()),
371                    v.as_mut_ptr() as *mut u8,
372                    size_of::<V>(),
373                );
374            }
375            let v2 = slot.version.load(Ordering::Acquire);
376            if v1 == v2 {
377                return unsafe { (k.assume_init(), v.assume_init()) };
378            }
379        }
380    }
381
382    /// Insert or update. Returns `Inserted` for a new key,
383    /// `Updated` when an existing key's value was overwritten,
384    /// `Err(Full)` if the table has no slot for the key (probed
385    /// every slot without finding Empty, a key match, or a
386    /// reclaimable tombstone).
387    ///
388    /// # Tombstone reuse
389    ///
390    /// Insert tracks the FIRST tombstone seen during the probe.
391    /// If the probe terminates at an Empty (key absent) AND a
392    /// tombstone was seen, the tombstone slot is reclaimed instead
393    /// of consuming the Empty. This eliminates the need for an
394    /// explicit `compact()` call in steady-state insert/remove
395    /// workloads. `compact()` is still useful for bulk reclamation
396    /// in workloads that don't naturally trigger reuse (e.g. a
397    /// long insert-only period after heavy removes).
398    pub fn insert(&self, key: K, value: V) -> Result<InsertOutcome, MapError> {
399        let r = self.insert_inner(key, value);
400        self.ring_sidecar.push_op(
401            crate::sidecar_ops::hash_map::OP_INSERT,
402            if matches!(r, Err(MapError::Full)) { 1 } else { 0 },
403        );
404        r
405    }
406
407    fn insert_inner(&self, key: K, value: V) -> Result<InsertOutcome, MapError> {
408        let h = Self::hash_key(&key);
409        let start = self.wrap(h as usize);
410        // Track the first tombstone seen during the probe. If the
411        // probe terminates at an Empty without finding the key, we
412        // claim this tombstone slot rather than the Empty.
413        let mut first_tombstone: Option<usize> = None;
414        for i in 0..self.capacity {
415            let idx = self.wrap(start + i);
416            let slot = self.slot(idx);
417            let state = slot.state.load(Ordering::Acquire);
418            if state == SLOT_EMPTY {
419                // End of probe chain. Key is not present. Either
420                // claim the tracked tombstone (reuse path) or this
421                // Empty slot.
422                if let Some(tomb_idx) = first_tombstone
423                    && self.try_claim_tombstone(tomb_idx, h, &key, &value) {
424                        return Ok(InsertOutcome::Inserted);
425                    }
426                    // Tombstone was stolen by another writer; fall
427                    // through to claim the Empty slot.
428                if slot.state.compare_exchange(
429                    SLOT_EMPTY, SLOT_OCCUPIED,
430                    Ordering::AcqRel, Ordering::Acquire,
431                ).is_ok() {
432                    slot.hash.store(h, Ordering::Release);
433                    self.write_payload(idx, &key, &value);
434                    self.header().count.fetch_add(1, Ordering::AcqRel);
435                    return Ok(InsertOutcome::Inserted);
436                }
437                // CAS on the Empty slot lost; the slot transitioned
438                // to Occupied or Tombstone in between. Reread.
439                let now = slot.state.load(Ordering::Acquire);
440                if now == SLOT_OCCUPIED {
441                    let cached = slot.hash.load(Ordering::Acquire);
442                    if cached == h {
443                        let (k, _) = self.read_payload(idx);
444                        if k == key {
445                            self.write_payload(idx, &key, &value);
446                            return Ok(InsertOutcome::Updated);
447                        }
448                    }
449                } else if now == SLOT_TOMBSTONE && first_tombstone.is_none() {
450                    first_tombstone = Some(idx);
451                }
452                continue;
453            }
454            if state == SLOT_OCCUPIED {
455                let cached = slot.hash.load(Ordering::Acquire);
456                if cached == h {
457                    let (k, _) = self.read_payload(idx);
458                    if k == key {
459                        self.write_payload(idx, &key, &value);
460                        return Ok(InsertOutcome::Updated);
461                    }
462                }
463                continue;
464            }
465            // SLOT_TOMBSTONE: track first one, keep probing
466            // (subsequent slots may hold the key).
467            if first_tombstone.is_none() {
468                first_tombstone = Some(idx);
469            }
470        }
471        // Walked every slot. Found no key, no Empty. If we saw a
472        // tombstone, try to reuse it; otherwise truly Full.
473        if let Some(tomb_idx) = first_tombstone
474            && self.try_claim_tombstone(tomb_idx, h, &key, &value) {
475                return Ok(InsertOutcome::Inserted);
476            }
477        Err(MapError::Full)
478    }
479
480    /// Claim a tombstone slot for a new insert. Returns true on
481    /// success, false if another writer stole the slot via CAS.
482    ///
483    /// On success: writes hash + payload, increments live count,
484    /// decrements tombstone counter via defensive CAS-loop (the
485    /// counter cannot underflow under the single-writer contract,
486    /// but the loop tolerates concurrent races defensively).
487    #[inline]
488    fn try_claim_tombstone(&self, tomb_idx: usize, h: u64, key: &K, value: &V) -> bool {
489        let tomb_slot = self.slot(tomb_idx);
490        if tomb_slot.state.compare_exchange(
491            SLOT_TOMBSTONE, SLOT_OCCUPIED,
492            Ordering::AcqRel, Ordering::Acquire,
493        ).is_err() {
494            return false;
495        }
496        tomb_slot.hash.store(h, Ordering::Release);
497        self.write_payload(tomb_idx, key, value);
498        self.header().count.fetch_add(1, Ordering::AcqRel);
499        // Defensive saturating decrement: bounded retry loop that
500        // never underflows past zero. Under the single-writer
501        // contract the counter is structurally > 0 here (we just
502        // converted a tombstone slot), but the loop tolerates any
503        // race that violates that assumption.
504        loop {
505            let cur = self.header().tombstones.load(Ordering::Acquire);
506            if cur == 0 { break; }
507            if self.header().tombstones.compare_exchange(
508                cur, cur - 1, Ordering::AcqRel, Ordering::Acquire,
509            ).is_ok() {
510                break;
511            }
512        }
513        true
514    }
515
516    /// Look up a key. Returns `None` if absent.
517    pub fn get(&self, key: &K) -> Option<V> {
518        let r = self.get_inner(key);
519        self.ring_sidecar.push_op(
520            crate::sidecar_ops::hash_map::OP_GET,
521            if r.is_none() { 2 } else { 0 },
522        );
523        r
524    }
525
526    /// Internal lookup; no sidecar observation. Used by `get`,
527    /// `contains_key`, and `remove` so each public entry point
528    /// pushes its own semantic op_kind without double-counting.
529    fn get_inner(&self, key: &K) -> Option<V> {
530        let h = Self::hash_key(key);
531        let start = self.wrap(h as usize);
532        for i in 0..self.capacity {
533            let idx = self.wrap(start + i);
534            let slot = self.slot(idx);
535            let state = slot.state.load(Ordering::Acquire);
536            if state == SLOT_EMPTY {
537                return None;
538            }
539            if state == SLOT_OCCUPIED {
540                let cached = slot.hash.load(Ordering::Acquire);
541                if cached == h {
542                    let (k, v) = self.read_payload(idx);
543                    if k == *key {
544                        return Some(v);
545                    }
546                }
547            }
548            // Tombstone or mismatch: continue probing.
549        }
550        None
551    }
552
553    /// True if `key` is present.
554    pub fn contains_key(&self, key: &K) -> bool {
555        let r = self.get_inner(key);
556        self.ring_sidecar.push_op(
557            crate::sidecar_ops::hash_map::OP_CONTAINS,
558            if r.is_none() { 2 } else { 0 },
559        );
560        r.is_some()
561    }
562
563    /// Remove a key. Returns the value if present.
564    pub fn remove(&self, key: &K) -> Option<V> {
565        let r = self.remove_inner(key);
566        self.ring_sidecar.push_op(
567            crate::sidecar_ops::hash_map::OP_REMOVE,
568            if r.is_none() { 2 } else { 0 },
569        );
570        r
571    }
572
573    fn remove_inner(&self, key: &K) -> Option<V> {
574        let h = Self::hash_key(key);
575        let start = self.wrap(h as usize);
576        for i in 0..self.capacity {
577            let idx = self.wrap(start + i);
578            let slot = self.slot(idx);
579            let state = slot.state.load(Ordering::Acquire);
580            if state == SLOT_EMPTY { return None; }
581            if state == SLOT_OCCUPIED {
582                let cached = slot.hash.load(Ordering::Acquire);
583                if cached == h {
584                    let (k, v) = self.read_payload(idx);
585                    if k == *key {
586                        if slot.state.compare_exchange(
587                            SLOT_OCCUPIED, SLOT_TOMBSTONE,
588                            Ordering::AcqRel, Ordering::Acquire,
589                        ).is_ok() {
590                            self.header().count.fetch_sub(1, Ordering::AcqRel);
591                            self.header().tombstones.fetch_add(1, Ordering::AcqRel);
592                            return Some(v);
593                        }
594                        // Another remover won; key is gone.
595                        return None;
596                    }
597                }
598            }
599        }
600        None
601    }
602
603    /// Clear the entire map. Marks every slot Empty and resets both
604    /// the live count and the tombstone counter to 0. Not
605    /// concurrency-safe vs concurrent insert/remove - callers should
606    /// ensure no other writers are active when calling this.
607    pub fn clear(&self) {
608        for i in 0..self.capacity {
609            let slot = self.slot(i);
610            slot.state.store(SLOT_EMPTY, Ordering::Release);
611        }
612        self.header().count.store(0, Ordering::Release);
613        self.header().tombstones.store(0, Ordering::Release);
614        self.ring_sidecar
615            .push_op(crate::sidecar_ops::hash_map::OP_CLEAR, 0);
616    }
617
618    /// Current tombstone count (slots marked dead by `remove` that
619    /// have not yet been reclaimed by `compact`).
620    #[inline]
621    pub fn tombstone_count(&self) -> usize {
622        self.header().tombstones.load(Ordering::Acquire) as usize
623    }
624
625    /// Heuristic: returns `true` if tombstones occupy at least
626    /// `threshold_fraction` of capacity. Callers typically pass
627    /// `0.30` (30 %) - past that, linear-probe chains stretch out
628    /// and lookup/insert latency degrades sharply. Cheap O(1).
629    pub fn should_compact(&self, threshold_fraction: f64) -> bool {
630        debug_assert!(
631            (0.0..=1.0).contains(&threshold_fraction),
632            "threshold_fraction must be in [0, 1]; got {threshold_fraction}",
633        );
634        let tombs = self.tombstone_count() as f64;
635        tombs / self.capacity as f64 >= threshold_fraction
636    }
637
638    /// Reclaim tombstones via in-place rebuild. Returns the number
639    /// of slots reclaimed.
640    ///
641    /// # What it does
642    ///
643    /// Snapshots every Occupied slot into a `Vec<(K, V)>`, resets
644    /// every slot to Empty (zeroing both counters), then re-inserts
645    /// each snapshotted pair via the normal probe. Since no
646    /// tombstones remain, every key lands as close to its ideal
647    /// slot as the live keys permit - probe chains shrink back to
648    /// the no-deletion baseline.
649    ///
650    /// # Concurrency
651    ///
652    /// **NOT concurrency-safe with `insert` / `remove`.** The caller
653    /// MUST guarantee no other writer (in any process holding an
654    /// MMF handle to the same file) is mutating the map during
655    /// `compact`. Readers calling `get` will see a transient empty
656    /// state mid-rebuild and may return spurious `None` for keys
657    /// that are about to be re-inserted; if that is unacceptable,
658    /// serialise readers too.
659    ///
660    /// # Cost
661    ///
662    /// O(capacity) for the snapshot + reset, O(live_count *
663    /// avg_probe) for re-insert. Allocates a temporary `Vec<(K, V)>`
664    /// sized to the live count. For a 1 M-slot map at 50 % load,
665    /// expect ~tens of milliseconds.
666    pub fn compact(&self) -> Result<usize, MapError> {
667        self.ring_sidecar
668            .push_op(crate::sidecar_ops::hash_map::OP_COMPACT, 0);
669        let mut live: Vec<(K, V)> = Vec::with_capacity(self.len());
670        let mut reclaimed = 0usize;
671        for i in 0..self.capacity {
672            let slot = self.slot(i);
673            let s = slot.state.load(Ordering::Acquire);
674            if s == SLOT_OCCUPIED {
675                live.push(self.read_payload(i));
676            } else if s == SLOT_TOMBSTONE {
677                reclaimed += 1;
678            }
679        }
680        for i in 0..self.capacity {
681            let slot = self.slot(i);
682            slot.state.store(SLOT_EMPTY, Ordering::Release);
683        }
684        self.header().count.store(0, Ordering::Release);
685        self.header().tombstones.store(0, Ordering::Release);
686        for (k, v) in live {
687            // Re-insert under single-writer contract: cannot race,
688            // and `Full` is impossible because the live set fit in
689            // the table before compaction.
690            self.insert(k, v)?;
691        }
692        Ok(reclaimed)
693    }
694
695    /// Walk and collect all (K, V) pairs currently present. Best-
696    /// effort snapshot under concurrent writers.
697    pub fn snapshot(&self) -> Vec<(K, V)> {
698        let mut out = Vec::with_capacity(self.len());
699        for i in 0..self.capacity {
700            let slot = self.slot(i);
701            if slot.state.load(Ordering::Acquire) == SLOT_OCCUPIED {
702                out.push(self.read_payload(i));
703            }
704        }
705        out
706    }
707
708    /// Current load factor (count / capacity).
709    pub fn load_factor(&self) -> f64 {
710        self.len() as f64 / self.capacity as f64
711    }
712
713    pub fn flush(&self) -> Result<(), MapError> {
714        self.mmap.flush()?;
715        Ok(())
716    }
717
718    /// Non-blocking flush: schedules a writeback via the OS.
719    /// Note: Windows is only partially async (sync to page cache,
720    /// not to disk).
721    pub fn flush_async(&self) -> Result<(), MapError> {
722        self.mmap.flush_async()?;
723        Ok(())
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use std::sync::Arc;
731    use std::thread;
732
733    fn tmp(name: &str) -> std::path::PathBuf {
734        let mut p = std::env::temp_dir();
735        let pid = std::process::id();
736        p.push(format!("subetha-hashmap-{name}-{pid}.bin"));
737        p
738    }
739
740    #[test]
741    fn create_initial_empty() {
742        let p = tmp("init");
743        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 32).unwrap();
744        assert_eq!(m.capacity(), 32);
745        assert_eq!(m.len(), 0);
746        assert!(m.is_empty());
747        assert_eq!(m.get(&42), None);
748        std::fs::remove_file(&p).ok();
749    }
750
751    #[test]
752    fn insert_and_get_round_trip() {
753        let p = tmp("rt");
754        let m: SharedHashMap<u32, u64> = SharedHashMap::create(&p, 32).unwrap();
755        assert_eq!(m.insert(1, 100).unwrap(), InsertOutcome::Inserted);
756        assert_eq!(m.insert(2, 200).unwrap(), InsertOutcome::Inserted);
757        assert_eq!(m.insert(3, 300).unwrap(), InsertOutcome::Inserted);
758        assert_eq!(m.len(), 3);
759        assert_eq!(m.get(&1), Some(100));
760        assert_eq!(m.get(&2), Some(200));
761        assert_eq!(m.get(&3), Some(300));
762        assert_eq!(m.get(&999), None);
763        std::fs::remove_file(&p).ok();
764    }
765
766    #[test]
767    fn duplicate_insert_updates_value() {
768        let p = tmp("dup");
769        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
770        assert_eq!(m.insert(7, 100).unwrap(), InsertOutcome::Inserted);
771        assert_eq!(m.insert(7, 200).unwrap(), InsertOutcome::Updated);
772        assert_eq!(m.len(), 1);
773        assert_eq!(m.get(&7), Some(200));
774        std::fs::remove_file(&p).ok();
775    }
776
777    #[test]
778    fn remove_returns_value_and_decrements_count() {
779        let p = tmp("rm");
780        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
781        m.insert(1, 10).unwrap();
782        m.insert(2, 20).unwrap();
783        assert_eq!(m.remove(&1), Some(10));
784        assert_eq!(m.len(), 1);
785        assert_eq!(m.get(&1), None);
786        assert_eq!(m.get(&2), Some(20));
787        assert_eq!(m.remove(&999), None);
788        std::fs::remove_file(&p).ok();
789    }
790
791    #[test]
792    fn tombstone_does_not_break_probing() {
793        let p = tmp("tomb");
794        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 8).unwrap();
795        // Force collisions by using keys that hash close together.
796        // Insert several keys, remove the middle one, verify later
797        // keys remain findable past the tombstone.
798        for k in 0..6u32 { m.insert(k, k * 10).unwrap(); }
799        // Remove a middle key.
800        m.remove(&2);
801        for k in [0u32, 1, 3, 4, 5] {
802            assert_eq!(m.get(&k), Some(k * 10),
803                "key {k} should still be findable past the tombstone");
804        }
805        assert_eq!(m.get(&2), None);
806        std::fs::remove_file(&p).ok();
807    }
808
809    #[test]
810    fn full_map_returns_error_on_new_key() {
811        let p = tmp("full");
812        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 4).unwrap();
813        m.insert(1, 1).unwrap();
814        m.insert(2, 2).unwrap();
815        m.insert(3, 3).unwrap();
816        m.insert(4, 4).unwrap();
817        assert_eq!(m.insert(5, 5).err(), Some(MapError::Full));
818        // Update of existing key still works.
819        assert_eq!(m.insert(1, 100).unwrap(), InsertOutcome::Updated);
820        std::fs::remove_file(&p).ok();
821    }
822
823    #[test]
824    fn clear_resets_to_empty() {
825        let p = tmp("clear");
826        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
827        for k in 0..5u32 { m.insert(k, k).unwrap(); }
828        assert_eq!(m.len(), 5);
829        m.clear();
830        assert_eq!(m.len(), 0);
831        assert_eq!(m.get(&0), None);
832        m.insert(99, 99).unwrap();
833        assert_eq!(m.get(&99), Some(99));
834        std::fs::remove_file(&p).ok();
835    }
836
837    #[test]
838    fn snapshot_collects_all_present_pairs() {
839        let p = tmp("snap");
840        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
841        m.insert(1, 10).unwrap();
842        m.insert(2, 20).unwrap();
843        m.insert(3, 30).unwrap();
844        m.remove(&2);
845        let mut snap = m.snapshot();
846        snap.sort();
847        assert_eq!(snap, vec![(1, 10), (3, 30)]);
848        std::fs::remove_file(&p).ok();
849    }
850
851    #[test]
852    fn cross_handle_visibility() {
853        let p = tmp("cross-handle");
854        let writer: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
855        let reader: SharedHashMap<u32, u32> = SharedHashMap::open(&p, 16).unwrap();
856        writer.insert(42, 4242).unwrap();
857        assert_eq!(reader.get(&42), Some(4242));
858        reader.insert(7, 77).unwrap();
859        assert_eq!(writer.get(&7), Some(77));
860        writer.remove(&42);
861        assert_eq!(reader.get(&42), None);
862        std::fs::remove_file(&p).ok();
863    }
864
865    #[test]
866    fn concurrent_inserters_all_keys_present() {
867        let p = tmp("concurrent");
868        let m: Arc<SharedHashMap<u32, u32>> = Arc::new(SharedHashMap::create(&p, 1024).unwrap());
869        let n_threads = 4;
870        let per_thread = 100u32;
871        let mut handles = vec![];
872        for t in 0..n_threads {
873            let m = m.clone();
874            handles.push(thread::spawn(move || {
875                for i in 0..per_thread {
876                    let key = (t as u32) * per_thread + i;
877                    m.insert(key, key * 10).unwrap();
878                }
879            }));
880        }
881        for h in handles { h.join().unwrap(); }
882        assert_eq!(m.len(), n_threads * per_thread as usize);
883        for t in 0..n_threads as u32 {
884            for i in 0..per_thread {
885                let key = t * per_thread + i;
886                assert_eq!(m.get(&key), Some(key * 10),
887                    "key {key} should be present with value {}", key * 10);
888            }
889        }
890        std::fs::remove_file(&p).ok();
891    }
892
893    #[test]
894    fn struct_key_and_value_round_trip() {
895        #[derive(Clone, Copy, Debug, PartialEq, Eq)]
896        #[repr(C)]
897        struct UserId { realm: u32, user: u32 }
898        #[derive(Clone, Copy, Debug, PartialEq)]
899        #[repr(C)]
900        struct Session { token: u64, expires_us: u64 }
901        let p = tmp("struct");
902        let m: SharedHashMap<UserId, Session> = SharedHashMap::create(&p, 32).unwrap();
903        let k = UserId { realm: 1, user: 42 };
904        let v = Session { token: 0xDEAD_BEEF, expires_us: 9_999_999_999 };
905        m.insert(k, v).unwrap();
906        assert_eq!(m.get(&k), Some(v));
907        std::fs::remove_file(&p).ok();
908    }
909
910    #[test]
911    fn payload_too_large_at_create() {
912        #[allow(dead_code)] // sizeof signal only
913        struct BigKey([u8; 64]);
914        impl Copy for BigKey {}
915        impl Clone for BigKey { fn clone(&self) -> Self { *self } }
916        impl PartialEq for BigKey { fn eq(&self, _: &Self) -> bool { true } }
917        impl Eq for BigKey {}
918        let p = tmp("too-large");
919        let r = SharedHashMap::<BigKey, u32>::create(&p, 4);
920        assert_eq!(r.err(), Some(MapError::PayloadTooLarge));
921        std::fs::remove_file(&p).ok();
922    }
923
924    #[test]
925    fn disk_persistence_survives_reopen() {
926        let p = tmp("disk");
927        {
928            let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
929            for k in 0..5u32 { m.insert(k, k * 100).unwrap(); }
930            m.remove(&2);
931            m.flush().unwrap();
932        }
933        let m2: SharedHashMap<u32, u32> = SharedHashMap::open(&p, 16).unwrap();
934        assert_eq!(m2.len(), 4);
935        assert_eq!(m2.get(&0), Some(0));
936        assert_eq!(m2.get(&1), Some(100));
937        assert_eq!(m2.get(&2), None);
938        assert_eq!(m2.get(&3), Some(300));
939        assert_eq!(m2.get(&4), Some(400));
940        std::fs::remove_file(&p).ok();
941    }
942
943    #[test]
944    fn fnv1a_64_is_deterministic() {
945        // Sanity: same input always produces the same hash.
946        let h1 = fnv1a_64(b"adaptive-prims");
947        let h2 = fnv1a_64(b"adaptive-prims");
948        assert_eq!(h1, h2);
949        // And different inputs hash differently.
950        let h3 = fnv1a_64(b"ADAPTIVE-PRIMS");
951        assert_ne!(h1, h3);
952    }
953
954    #[test]
955    fn load_factor_reports_correctly() {
956        let p = tmp("load");
957        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 10).unwrap();
958        for k in 0..3u32 { m.insert(k, k).unwrap(); }
959        assert_eq!(m.load_factor(), 0.3);
960        std::fs::remove_file(&p).ok();
961    }
962
963    #[test]
964    fn remove_bumps_tombstone_counter() {
965        let p = tmp("tomb-counter");
966        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
967        assert_eq!(m.tombstone_count(), 0);
968        for k in 0..5u32 { m.insert(k, k).unwrap(); }
969        assert_eq!(m.tombstone_count(), 0);
970        m.remove(&1);
971        m.remove(&3);
972        assert_eq!(m.tombstone_count(), 2);
973        // Removing absent key does NOT bump tombstone count.
974        m.remove(&999);
975        assert_eq!(m.tombstone_count(), 2);
976        std::fs::remove_file(&p).ok();
977    }
978
979    #[test]
980    fn compact_on_empty_map_is_noop() {
981        let p = tmp("compact-empty");
982        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
983        assert_eq!(m.compact().unwrap(), 0);
984        assert_eq!(m.len(), 0);
985        assert_eq!(m.tombstone_count(), 0);
986        // Map remains fully usable.
987        m.insert(7, 70).unwrap();
988        assert_eq!(m.get(&7), Some(70));
989        std::fs::remove_file(&p).ok();
990    }
991
992    #[test]
993    fn compact_reclaims_tombstones() {
994        let p = tmp("compact-reclaim");
995        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
996        for k in 0..10u32 { m.insert(k, k * 10).unwrap(); }
997        for k in [0u32, 2, 4, 6, 8] { m.remove(&k); }
998        assert_eq!(m.tombstone_count(), 5);
999        let reclaimed = m.compact().unwrap();
1000        assert_eq!(reclaimed, 5);
1001        assert_eq!(m.tombstone_count(), 0);
1002        assert_eq!(m.len(), 5);
1003        std::fs::remove_file(&p).ok();
1004    }
1005
1006    #[test]
1007    fn compact_preserves_all_live_pairs() {
1008        let p = tmp("compact-preserve");
1009        let m: SharedHashMap<u32, u64> = SharedHashMap::create(&p, 32).unwrap();
1010        // Insert 20, remove 10, compact, verify the remaining 10
1011        // are all present with their original values.
1012        for k in 0..20u32 { m.insert(k, (k as u64) * 1000).unwrap(); }
1013        for k in (0..20u32).filter(|k| k % 2 == 0) { m.remove(&k); }
1014        let pre: Vec<(u32, u64)> = {
1015            let mut s = m.snapshot();
1016            s.sort();
1017            s
1018        };
1019        m.compact().unwrap();
1020        let post: Vec<(u32, u64)> = {
1021            let mut s = m.snapshot();
1022            s.sort();
1023            s
1024        };
1025        assert_eq!(pre, post,
1026            "compact must preserve every live (K, V) pair exactly");
1027        // And every preserved key is still findable by lookup.
1028        for (k, v) in &post {
1029            assert_eq!(m.get(k), Some(*v));
1030        }
1031        std::fs::remove_file(&p).ok();
1032    }
1033
1034    #[test]
1035    fn compact_reclaims_after_heavy_churn() {
1036        // Many insert/remove cycles accumulate tombstones in the
1037        // probe path because insert probes PAST tombstones if the
1038        // tombstone-reuse path is not exercised. Compact must
1039        // reclaim every dead slot exactly.
1040        let p = tmp("compact-churn");
1041        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 64).unwrap();
1042        for k in 0..32u32 { m.insert(k, k).unwrap(); }
1043        for round in 0..16u32 {
1044            m.remove(&round);
1045            let new_key = 100 + round;
1046            m.insert(new_key, new_key).unwrap();
1047        }
1048        assert_eq!(m.tombstone_count(), 16);
1049        let live_before = m.len();
1050        let reclaimed = m.compact().unwrap();
1051        assert_eq!(reclaimed, 16);
1052        assert_eq!(m.tombstone_count(), 0);
1053        assert_eq!(m.len(), live_before);
1054        for round in 0..16u32 {
1055            assert_eq!(m.get(&round), None);
1056            let new_key = 100 + round;
1057            assert_eq!(m.get(&new_key), Some(new_key));
1058        }
1059        std::fs::remove_file(&p).ok();
1060    }
1061
1062    #[test]
1063    fn tombstone_reuse_avoids_full_after_remove() {
1064        // 8-slot table, fill it, remove one key. The next insert
1065        // of a NEW key REUSES the tombstone slot instead of
1066        // returning Full. This validates the tombstone-reuse-on-
1067        // insert path.
1068        let p = tmp("reuse-avoids-full");
1069        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 8).unwrap();
1070        for k in 0..8u32 { m.insert(k, k).unwrap(); }
1071        m.remove(&3);
1072        assert_eq!(m.tombstone_count(), 1);
1073        // Without reuse this returns Full (7 live + 1 tombstone
1074        // in 8 slots). With reuse it succeeds and the tombstone
1075        // counter drops to 0.
1076        assert!(m.insert(99, 99).is_ok(),
1077            "tombstone reuse must let insert succeed");
1078        assert_eq!(m.get(&99), Some(99));
1079        assert_eq!(m.tombstone_count(), 0,
1080            "successful tombstone reuse must decrement the counter");
1081        std::fs::remove_file(&p).ok();
1082    }
1083
1084    #[test]
1085    fn compact_still_useful_for_remove_heavy_workload() {
1086        // Insert N, remove most WITHOUT re-inserting. Tombstones
1087        // accumulate because there is no insert to trigger reuse.
1088        // compact() bulk-reclaims them. This covers workloads
1089        // that lack the insert pressure to trigger reuse
1090        // naturally.
1091        let p = tmp("compact-still-useful");
1092        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 32).unwrap();
1093        for k in 0..20u32 { m.insert(k, k).unwrap(); }
1094        for k in 0..15u32 { m.remove(&k); }
1095        assert_eq!(m.tombstone_count(), 15);
1096        let reclaimed = m.compact().unwrap();
1097        assert_eq!(reclaimed, 15);
1098        assert_eq!(m.tombstone_count(), 0);
1099        assert_eq!(m.len(), 5);
1100        std::fs::remove_file(&p).ok();
1101    }
1102
1103    #[test]
1104    fn should_compact_threshold_logic() {
1105        let p = tmp("should-compact");
1106        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 100).unwrap();
1107        // 0 tombstones / 100 capacity = 0.0
1108        assert!(!m.should_compact(0.01));
1109        // Insert 50, remove 30 → 30 tombstones / 100 = 0.30.
1110        for k in 0..50u32 { m.insert(k, k).unwrap(); }
1111        for k in 0..30u32 { m.remove(&k); }
1112        assert_eq!(m.tombstone_count(), 30);
1113        assert!(m.should_compact(0.30));
1114        assert!(m.should_compact(0.29));
1115        assert!(!m.should_compact(0.31));
1116        std::fs::remove_file(&p).ok();
1117    }
1118
1119    #[test]
1120    fn compact_persists_across_reopen() {
1121        let p = tmp("compact-disk");
1122        {
1123            let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
1124            for k in 0..6u32 { m.insert(k, k * 10).unwrap(); }
1125            for k in [0u32, 2, 4] { m.remove(&k); }
1126            m.compact().unwrap();
1127            m.flush().unwrap();
1128        }
1129        let m2: SharedHashMap<u32, u32> = SharedHashMap::open(&p, 16).unwrap();
1130        assert_eq!(m2.len(), 3);
1131        assert_eq!(m2.tombstone_count(), 0);
1132        for k in [1u32, 3, 5] {
1133            assert_eq!(m2.get(&k), Some(k * 10));
1134        }
1135        for k in [0u32, 2, 4] {
1136            assert_eq!(m2.get(&k), None);
1137        }
1138        std::fs::remove_file(&p).ok();
1139    }
1140}