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    /// Obtain the map at `path`, initializing an empty one if the path does
214    /// not yet exist and attaching to it if it does. Attaching leaves live
215    /// entries in place; a region built with a different capacity or
216    /// payload type is a `LayoutMismatch`. [`reset`](Self::reset)
217    /// reinitializes.
218    pub fn create(path: impl AsRef<Path>, capacity: usize) -> Result<Self, MapError> {
219        Self::check_layout()?;
220        assert!(capacity >= 2);
221        let total = map_file_size(capacity);
222        let (file, mmap) = crate::mmf_attach::create_or_attach(
223            path.as_ref(),
224            total,
225            |ptr| unsafe { Self::init_region(ptr, capacity) },
226            |ptr| unsafe { (*(ptr as *const MapHeader)).magic == MAP_MAGIC },
227        )?;
228        Self::from_region(file, mmap, capacity)
229    }
230
231    /// Truncate the map at `path` and initialize a fresh empty one,
232    /// discarding whatever entries a live peer holds. For a caller that
233    /// knows it owns the path.
234    pub fn reset(path: impl AsRef<Path>, capacity: usize) -> Result<Self, MapError> {
235        Self::check_layout()?;
236        assert!(capacity >= 2);
237        let total = map_file_size(capacity);
238        let (file, mmap) = crate::mmf_attach::reset(path.as_ref(), total, |ptr| unsafe {
239            Self::init_region(ptr, capacity)
240        })?;
241        Self::from_region(file, mmap, capacity)
242    }
243
244    /// Lay out an empty map: header fields first, magic last, because
245    /// attachers spin on the magic and must not observe it before the
246    /// layout fields are in place. The zeroed region is already the valid
247    /// slot array (`SLOT_EMPTY`, version 0, hash 0) and the valid `count`
248    /// and `tombstones` of 0.
249    ///
250    /// # Safety
251    /// `ptr` addresses at least `map_file_size(capacity)` writable zeroed
252    /// bytes.
253    unsafe fn init_region(ptr: *mut u8, capacity: usize) {
254        let hdr = ptr as *mut MapHeader;
255        unsafe {
256            (*hdr).capacity = capacity as u32;
257            (*hdr).key_size = size_of::<K>() as u32;
258            (*hdr).value_size = size_of::<V>() as u32;
259            std::ptr::write_volatile(&raw mut (*hdr).magic, MAP_MAGIC);
260        }
261    }
262
263    /// Wrap an initialized region, refusing one whose layout does not
264    /// match this type at this capacity.
265    fn from_region(file: File, mmap: MmapMut, capacity: usize) -> Result<Self, MapError> {
266        let hdr = unsafe { &*(mmap.as_ptr() as *const MapHeader) };
267        if hdr.magic != MAP_MAGIC
268            || hdr.capacity != 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,
276            cap_mask: capacity.wrapping_sub(1),
277            cap_is_pow2: 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    pub fn open(path: impl AsRef<Path>, expected_capacity: usize) -> Result<Self, MapError> {
285        Self::check_layout()?;
286        let total = map_file_size(expected_capacity);
287        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
288        if file.metadata()?.len() < total as u64 {
289            return Err(MapError::LayoutMismatch);
290        }
291        let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
292        Self::from_region(file, mmap, expected_capacity)
293    }
294
295    /// Reduce an index into `[0, capacity)`. Uses `& cap_mask` when
296    /// capacity is a power of two (the common case) - removing the
297    /// `% capacity` hardware DIV the linear-probe loop would otherwise
298    /// run on every step - and falls back to the modulo otherwise.
299    /// A bit-mask is required (not Lemire-style multiply-shift) because
300    /// linear probing needs consecutive indices to map to consecutive
301    /// slots with wraparound.
302    #[inline]
303    fn wrap(&self, i: usize) -> usize {
304        if self.cap_is_pow2 { i & self.cap_mask } else { i % self.capacity }
305    }
306
307    #[inline]
308    pub fn capacity(&self) -> usize { self.capacity }
309
310    #[inline]
311    pub fn len(&self) -> usize {
312        self.header().count.load(Ordering::Acquire) as usize
313    }
314
315    #[inline]
316    pub fn is_empty(&self) -> bool { self.len() == 0 }
317
318    fn header(&self) -> &MapHeader {
319        unsafe { &*(self.mmap.as_ptr() as *const MapHeader) }
320    }
321
322    fn slot(&self, idx: usize) -> &MapSlot {
323        let base = unsafe { self.mmap.as_ptr().add(size_of::<MapHeader>()) };
324        unsafe { &*(base.add(idx * size_of::<MapSlot>()) as *const MapSlot) }
325    }
326
327    fn hash_key(k: &K) -> u64 {
328        let bytes = unsafe {
329            std::slice::from_raw_parts(k as *const K as *const u8, size_of::<K>())
330        };
331        fnv1a_64(bytes)
332    }
333
334    /// SeqLock-write a (K, V) pair into a slot's payload region.
335    fn write_payload(&self, slot_idx: usize, k: &K, v: &V) {
336        let slot = self.slot(slot_idx);
337        slot.version.fetch_add(1, Ordering::AcqRel); // odd
338        let base = unsafe {
339            self.mmap.as_ptr()
340                .add(size_of::<MapHeader>())
341                .add(slot_idx * size_of::<MapSlot>())
342                .add(std::mem::offset_of!(MapSlot, payload))
343                as *mut u8
344        };
345        unsafe {
346            // Layout: key bytes then value bytes.
347            std::ptr::copy_nonoverlapping(
348                k as *const K as *const u8, base, size_of::<K>(),
349            );
350            std::ptr::copy_nonoverlapping(
351                v as *const V as *const u8,
352                base.add(size_of::<K>()),
353                size_of::<V>(),
354            );
355        }
356        slot.version.fetch_add(1, Ordering::AcqRel); // even
357    }
358
359    /// SeqLock-read a (K, V) pair from a slot. Spins on odd version.
360    fn read_payload(&self, slot_idx: usize) -> (K, V) {
361        let slot = self.slot(slot_idx);
362        loop {
363            let v1 = slot.version.load(Ordering::Acquire);
364            if v1 & 1 != 0 {
365                std::hint::spin_loop();
366                continue;
367            }
368            let mut k = std::mem::MaybeUninit::<K>::uninit();
369            let mut v = std::mem::MaybeUninit::<V>::uninit();
370            let src = unsafe {
371                self.mmap.as_ptr()
372                    .add(size_of::<MapHeader>())
373                    .add(slot_idx * size_of::<MapSlot>())
374                    .add(std::mem::offset_of!(MapSlot, payload))
375            };
376            unsafe {
377                std::ptr::copy_nonoverlapping(
378                    src, k.as_mut_ptr() as *mut u8, size_of::<K>(),
379                );
380                std::ptr::copy_nonoverlapping(
381                    src.add(size_of::<K>()),
382                    v.as_mut_ptr() as *mut u8,
383                    size_of::<V>(),
384                );
385            }
386            let v2 = slot.version.load(Ordering::Acquire);
387            if v1 == v2 {
388                return unsafe { (k.assume_init(), v.assume_init()) };
389            }
390        }
391    }
392
393    /// Insert or update. Returns `Inserted` for a new key,
394    /// `Updated` when an existing key's value was overwritten,
395    /// `Err(Full)` if the table has no slot for the key (probed
396    /// every slot without finding Empty, a key match, or a
397    /// reclaimable tombstone).
398    ///
399    /// # Tombstone reuse
400    ///
401    /// Insert tracks the FIRST tombstone seen during the probe.
402    /// If the probe terminates at an Empty (key absent) AND a
403    /// tombstone was seen, the tombstone slot is reclaimed instead
404    /// of consuming the Empty. This eliminates the need for an
405    /// explicit `compact()` call in steady-state insert/remove
406    /// workloads. `compact()` is still useful for bulk reclamation
407    /// in workloads that don't naturally trigger reuse (e.g. a
408    /// long insert-only period after heavy removes).
409    pub fn insert(&self, key: K, value: V) -> Result<InsertOutcome, MapError> {
410        let r = self.insert_inner(key, value);
411        self.ring_sidecar.push_op(
412            crate::sidecar_ops::hash_map::OP_INSERT,
413            if matches!(r, Err(MapError::Full)) { 1 } else { 0 },
414        );
415        r
416    }
417
418    fn insert_inner(&self, key: K, value: V) -> Result<InsertOutcome, MapError> {
419        let h = Self::hash_key(&key);
420        let start = self.wrap(h as usize);
421        // Track the first tombstone seen during the probe. If the
422        // probe terminates at an Empty without finding the key, we
423        // claim this tombstone slot rather than the Empty.
424        let mut first_tombstone: Option<usize> = None;
425        for i in 0..self.capacity {
426            let idx = self.wrap(start + i);
427            let slot = self.slot(idx);
428            let state = slot.state.load(Ordering::Acquire);
429            if state == SLOT_EMPTY {
430                // End of probe chain. Key is not present. Either
431                // claim the tracked tombstone (reuse path) or this
432                // Empty slot.
433                if let Some(tomb_idx) = first_tombstone
434                    && self.try_claim_tombstone(tomb_idx, h, &key, &value) {
435                        return Ok(InsertOutcome::Inserted);
436                    }
437                    // Tombstone was stolen by another writer; fall
438                    // through to claim the Empty slot.
439                if slot.state.compare_exchange(
440                    SLOT_EMPTY, SLOT_OCCUPIED,
441                    Ordering::AcqRel, Ordering::Acquire,
442                ).is_ok() {
443                    slot.hash.store(h, Ordering::Release);
444                    self.write_payload(idx, &key, &value);
445                    self.header().count.fetch_add(1, Ordering::AcqRel);
446                    return Ok(InsertOutcome::Inserted);
447                }
448                // CAS on the Empty slot lost; the slot transitioned
449                // to Occupied or Tombstone in between. Reread.
450                let now = slot.state.load(Ordering::Acquire);
451                if now == SLOT_OCCUPIED {
452                    let cached = slot.hash.load(Ordering::Acquire);
453                    if cached == h {
454                        let (k, _) = self.read_payload(idx);
455                        if k == key {
456                            self.write_payload(idx, &key, &value);
457                            return Ok(InsertOutcome::Updated);
458                        }
459                    }
460                } else if now == SLOT_TOMBSTONE && first_tombstone.is_none() {
461                    first_tombstone = Some(idx);
462                }
463                continue;
464            }
465            if state == SLOT_OCCUPIED {
466                let cached = slot.hash.load(Ordering::Acquire);
467                if cached == h {
468                    let (k, _) = self.read_payload(idx);
469                    if k == key {
470                        self.write_payload(idx, &key, &value);
471                        return Ok(InsertOutcome::Updated);
472                    }
473                }
474                continue;
475            }
476            // SLOT_TOMBSTONE: track first one, keep probing
477            // (subsequent slots may hold the key).
478            if first_tombstone.is_none() {
479                first_tombstone = Some(idx);
480            }
481        }
482        // Walked every slot. Found no key, no Empty. If we saw a
483        // tombstone, try to reuse it; otherwise truly Full.
484        if let Some(tomb_idx) = first_tombstone
485            && self.try_claim_tombstone(tomb_idx, h, &key, &value) {
486                return Ok(InsertOutcome::Inserted);
487            }
488        Err(MapError::Full)
489    }
490
491    /// Claim a tombstone slot for a new insert. Returns true on
492    /// success, false if another writer stole the slot via CAS.
493    ///
494    /// On success: writes hash + payload, increments live count,
495    /// decrements tombstone counter via defensive CAS-loop (the
496    /// counter cannot underflow under the single-writer contract,
497    /// but the loop tolerates concurrent races defensively).
498    #[inline]
499    fn try_claim_tombstone(&self, tomb_idx: usize, h: u64, key: &K, value: &V) -> bool {
500        let tomb_slot = self.slot(tomb_idx);
501        if tomb_slot.state.compare_exchange(
502            SLOT_TOMBSTONE, SLOT_OCCUPIED,
503            Ordering::AcqRel, Ordering::Acquire,
504        ).is_err() {
505            return false;
506        }
507        tomb_slot.hash.store(h, Ordering::Release);
508        self.write_payload(tomb_idx, key, value);
509        self.header().count.fetch_add(1, Ordering::AcqRel);
510        // Defensive saturating decrement: bounded retry loop that
511        // never underflows past zero. Under the single-writer
512        // contract the counter is structurally > 0 here (we just
513        // converted a tombstone slot), but the loop tolerates any
514        // race that violates that assumption.
515        loop {
516            let cur = self.header().tombstones.load(Ordering::Acquire);
517            if cur == 0 { break; }
518            if self.header().tombstones.compare_exchange(
519                cur, cur - 1, Ordering::AcqRel, Ordering::Acquire,
520            ).is_ok() {
521                break;
522            }
523        }
524        true
525    }
526
527    /// Look up a key. Returns `None` if absent.
528    pub fn get(&self, key: &K) -> Option<V> {
529        let r = self.get_inner(key);
530        self.ring_sidecar.push_op(
531            crate::sidecar_ops::hash_map::OP_GET,
532            if r.is_none() { 2 } else { 0 },
533        );
534        r
535    }
536
537    /// Internal lookup; no sidecar observation. Used by `get`,
538    /// `contains_key`, and `remove` so each public entry point
539    /// pushes its own semantic op_kind without double-counting.
540    fn get_inner(&self, key: &K) -> Option<V> {
541        let h = Self::hash_key(key);
542        let start = self.wrap(h as usize);
543        for i in 0..self.capacity {
544            let idx = self.wrap(start + i);
545            let slot = self.slot(idx);
546            let state = slot.state.load(Ordering::Acquire);
547            if state == SLOT_EMPTY {
548                return None;
549            }
550            if state == SLOT_OCCUPIED {
551                let cached = slot.hash.load(Ordering::Acquire);
552                if cached == h {
553                    let (k, v) = self.read_payload(idx);
554                    if k == *key {
555                        return Some(v);
556                    }
557                }
558            }
559            // Tombstone or mismatch: continue probing.
560        }
561        None
562    }
563
564    /// True if `key` is present.
565    pub fn contains_key(&self, key: &K) -> bool {
566        let r = self.get_inner(key);
567        self.ring_sidecar.push_op(
568            crate::sidecar_ops::hash_map::OP_CONTAINS,
569            if r.is_none() { 2 } else { 0 },
570        );
571        r.is_some()
572    }
573
574    /// Remove a key. Returns the value if present.
575    pub fn remove(&self, key: &K) -> Option<V> {
576        let r = self.remove_inner(key);
577        self.ring_sidecar.push_op(
578            crate::sidecar_ops::hash_map::OP_REMOVE,
579            if r.is_none() { 2 } else { 0 },
580        );
581        r
582    }
583
584    fn remove_inner(&self, key: &K) -> Option<V> {
585        let h = Self::hash_key(key);
586        let start = self.wrap(h as usize);
587        for i in 0..self.capacity {
588            let idx = self.wrap(start + i);
589            let slot = self.slot(idx);
590            let state = slot.state.load(Ordering::Acquire);
591            if state == SLOT_EMPTY { return None; }
592            if state == SLOT_OCCUPIED {
593                let cached = slot.hash.load(Ordering::Acquire);
594                if cached == h {
595                    let (k, v) = self.read_payload(idx);
596                    if k == *key {
597                        if slot.state.compare_exchange(
598                            SLOT_OCCUPIED, SLOT_TOMBSTONE,
599                            Ordering::AcqRel, Ordering::Acquire,
600                        ).is_ok() {
601                            self.header().count.fetch_sub(1, Ordering::AcqRel);
602                            self.header().tombstones.fetch_add(1, Ordering::AcqRel);
603                            return Some(v);
604                        }
605                        // Another remover won; key is gone.
606                        return None;
607                    }
608                }
609            }
610        }
611        None
612    }
613
614    /// Clear the entire map. Marks every slot Empty and resets both
615    /// the live count and the tombstone counter to 0. Not
616    /// concurrency-safe vs concurrent insert/remove - callers should
617    /// ensure no other writers are active when calling this.
618    pub fn clear(&self) {
619        for i in 0..self.capacity {
620            let slot = self.slot(i);
621            slot.state.store(SLOT_EMPTY, Ordering::Release);
622        }
623        self.header().count.store(0, Ordering::Release);
624        self.header().tombstones.store(0, Ordering::Release);
625        self.ring_sidecar
626            .push_op(crate::sidecar_ops::hash_map::OP_CLEAR, 0);
627    }
628
629    /// Current tombstone count (slots marked dead by `remove` that
630    /// have not yet been reclaimed by `compact`).
631    #[inline]
632    pub fn tombstone_count(&self) -> usize {
633        self.header().tombstones.load(Ordering::Acquire) as usize
634    }
635
636    /// Heuristic: returns `true` if tombstones occupy at least
637    /// `threshold_fraction` of capacity. Callers typically pass
638    /// `0.30` (30 %) - past that, linear-probe chains stretch out
639    /// and lookup/insert latency degrades sharply. Cheap O(1).
640    pub fn should_compact(&self, threshold_fraction: f64) -> bool {
641        debug_assert!(
642            (0.0..=1.0).contains(&threshold_fraction),
643            "threshold_fraction must be in [0, 1]; got {threshold_fraction}",
644        );
645        let tombs = self.tombstone_count() as f64;
646        tombs / self.capacity as f64 >= threshold_fraction
647    }
648
649    /// Reclaim tombstones via in-place rebuild. Returns the number
650    /// of slots reclaimed.
651    ///
652    /// # What it does
653    ///
654    /// Snapshots every Occupied slot into a `Vec<(K, V)>`, resets
655    /// every slot to Empty (zeroing both counters), then re-inserts
656    /// each snapshotted pair via the normal probe. Since no
657    /// tombstones remain, every key lands as close to its ideal
658    /// slot as the live keys permit - probe chains shrink back to
659    /// the no-deletion baseline.
660    ///
661    /// # Concurrency
662    ///
663    /// **NOT concurrency-safe with `insert` / `remove`.** The caller
664    /// MUST guarantee no other writer (in any process holding an
665    /// MMF handle to the same file) is mutating the map during
666    /// `compact`. Readers calling `get` will see a transient empty
667    /// state mid-rebuild and may return spurious `None` for keys
668    /// that are about to be re-inserted; if that is unacceptable,
669    /// serialise readers too.
670    ///
671    /// # Cost
672    ///
673    /// O(capacity) for the snapshot + reset, O(live_count *
674    /// avg_probe) for re-insert. Allocates a temporary `Vec<(K, V)>`
675    /// sized to the live count. For a 1 M-slot map at 50 % load,
676    /// expect ~tens of milliseconds.
677    pub fn compact(&self) -> Result<usize, MapError> {
678        self.ring_sidecar
679            .push_op(crate::sidecar_ops::hash_map::OP_COMPACT, 0);
680        let mut live: Vec<(K, V)> = Vec::with_capacity(self.len());
681        let mut reclaimed = 0usize;
682        for i in 0..self.capacity {
683            let slot = self.slot(i);
684            let s = slot.state.load(Ordering::Acquire);
685            if s == SLOT_OCCUPIED {
686                live.push(self.read_payload(i));
687            } else if s == SLOT_TOMBSTONE {
688                reclaimed += 1;
689            }
690        }
691        for i in 0..self.capacity {
692            let slot = self.slot(i);
693            slot.state.store(SLOT_EMPTY, Ordering::Release);
694        }
695        self.header().count.store(0, Ordering::Release);
696        self.header().tombstones.store(0, Ordering::Release);
697        for (k, v) in live {
698            // Re-insert under single-writer contract: cannot race,
699            // and `Full` is impossible because the live set fit in
700            // the table before compaction.
701            self.insert(k, v)?;
702        }
703        Ok(reclaimed)
704    }
705
706    /// Walk and collect all (K, V) pairs currently present. Best-
707    /// effort snapshot under concurrent writers.
708    pub fn snapshot(&self) -> Vec<(K, V)> {
709        let mut out = Vec::with_capacity(self.len());
710        for i in 0..self.capacity {
711            let slot = self.slot(i);
712            if slot.state.load(Ordering::Acquire) == SLOT_OCCUPIED {
713                out.push(self.read_payload(i));
714            }
715        }
716        out
717    }
718
719    /// Current load factor (count / capacity).
720    pub fn load_factor(&self) -> f64 {
721        self.len() as f64 / self.capacity as f64
722    }
723
724    pub fn flush(&self) -> Result<(), MapError> {
725        self.mmap.flush()?;
726        Ok(())
727    }
728
729    /// Non-blocking flush: schedules a writeback via the OS.
730    /// Note: Windows is only partially async (sync to page cache,
731    /// not to disk).
732    pub fn flush_async(&self) -> Result<(), MapError> {
733        self.mmap.flush_async()?;
734        Ok(())
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use std::sync::Arc;
742    use std::thread;
743
744    fn tmp(name: &str) -> std::path::PathBuf {
745        let mut p = std::env::temp_dir();
746        let pid = std::process::id();
747        p.push(format!("subetha-hashmap-{name}-{pid}.bin"));
748        p
749    }
750
751    #[test]
752    fn create_initial_empty() {
753        let p = tmp("init");
754        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 32).unwrap();
755        assert_eq!(m.capacity(), 32);
756        assert_eq!(m.len(), 0);
757        assert!(m.is_empty());
758        assert_eq!(m.get(&42), None);
759        std::fs::remove_file(&p).ok();
760    }
761
762    #[test]
763    fn insert_and_get_round_trip() {
764        let p = tmp("rt");
765        let m: SharedHashMap<u32, u64> = SharedHashMap::create(&p, 32).unwrap();
766        assert_eq!(m.insert(1, 100).unwrap(), InsertOutcome::Inserted);
767        assert_eq!(m.insert(2, 200).unwrap(), InsertOutcome::Inserted);
768        assert_eq!(m.insert(3, 300).unwrap(), InsertOutcome::Inserted);
769        assert_eq!(m.len(), 3);
770        assert_eq!(m.get(&1), Some(100));
771        assert_eq!(m.get(&2), Some(200));
772        assert_eq!(m.get(&3), Some(300));
773        assert_eq!(m.get(&999), None);
774        std::fs::remove_file(&p).ok();
775    }
776
777    /// A second create attaches to the live map with its entries intact;
778    /// reset is what strips them.
779    #[test]
780    fn second_create_attaches_and_keeps_entries() {
781        let p = tmp("attach");
782        std::fs::remove_file(&p).ok();
783        let m: SharedHashMap<u32, u64> = SharedHashMap::create(&p, 32).unwrap();
784        m.insert(7, 777).unwrap();
785
786        let m2: SharedHashMap<u32, u64> = SharedHashMap::create(&p, 32).unwrap();
787        assert_eq!(m2.get(&7), Some(777), "attach lost a live entry");
788        assert_eq!(m2.len(), 1);
789
790        // Windows refuses to truncate a mapped file, so every handle goes
791        // before the reset.
792        drop(m);
793        drop(m2);
794        let fresh: SharedHashMap<u32, u64> = SharedHashMap::reset(&p, 32).unwrap();
795        assert_eq!(fresh.len(), 0);
796        assert_eq!(fresh.get(&7), None, "reset left an entry behind");
797        drop(fresh);
798        std::fs::remove_file(&p).ok();
799    }
800
801    /// Attaching with a different capacity or key type is refused.
802    #[test]
803    fn create_refuses_a_mismatched_region() {
804        let p = tmp("mismatch");
805        std::fs::remove_file(&p).ok();
806        let m: SharedHashMap<u32, u64> = SharedHashMap::create(&p, 32).unwrap();
807        assert!(matches!(
808            SharedHashMap::<u32, u64>::create(&p, 16),
809            Err(MapError::LayoutMismatch),
810        ));
811        assert!(matches!(
812            SharedHashMap::<u64, u64>::create(&p, 32),
813            Err(MapError::LayoutMismatch),
814        ));
815        drop(m);
816        std::fs::remove_file(&p).ok();
817    }
818
819    #[test]
820    fn duplicate_insert_updates_value() {
821        let p = tmp("dup");
822        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
823        assert_eq!(m.insert(7, 100).unwrap(), InsertOutcome::Inserted);
824        assert_eq!(m.insert(7, 200).unwrap(), InsertOutcome::Updated);
825        assert_eq!(m.len(), 1);
826        assert_eq!(m.get(&7), Some(200));
827        std::fs::remove_file(&p).ok();
828    }
829
830    #[test]
831    fn remove_returns_value_and_decrements_count() {
832        let p = tmp("rm");
833        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
834        m.insert(1, 10).unwrap();
835        m.insert(2, 20).unwrap();
836        assert_eq!(m.remove(&1), Some(10));
837        assert_eq!(m.len(), 1);
838        assert_eq!(m.get(&1), None);
839        assert_eq!(m.get(&2), Some(20));
840        assert_eq!(m.remove(&999), None);
841        std::fs::remove_file(&p).ok();
842    }
843
844    #[test]
845    fn tombstone_does_not_break_probing() {
846        let p = tmp("tomb");
847        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 8).unwrap();
848        // Force collisions by using keys that hash close together.
849        // Insert several keys, remove the middle one, verify later
850        // keys remain findable past the tombstone.
851        for k in 0..6u32 { m.insert(k, k * 10).unwrap(); }
852        // Remove a middle key.
853        m.remove(&2);
854        for k in [0u32, 1, 3, 4, 5] {
855            assert_eq!(m.get(&k), Some(k * 10),
856                "key {k} should still be findable past the tombstone");
857        }
858        assert_eq!(m.get(&2), None);
859        std::fs::remove_file(&p).ok();
860    }
861
862    #[test]
863    fn full_map_returns_error_on_new_key() {
864        let p = tmp("full");
865        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 4).unwrap();
866        m.insert(1, 1).unwrap();
867        m.insert(2, 2).unwrap();
868        m.insert(3, 3).unwrap();
869        m.insert(4, 4).unwrap();
870        assert_eq!(m.insert(5, 5).err(), Some(MapError::Full));
871        // Update of existing key still works.
872        assert_eq!(m.insert(1, 100).unwrap(), InsertOutcome::Updated);
873        std::fs::remove_file(&p).ok();
874    }
875
876    #[test]
877    fn clear_resets_to_empty() {
878        let p = tmp("clear");
879        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
880        for k in 0..5u32 { m.insert(k, k).unwrap(); }
881        assert_eq!(m.len(), 5);
882        m.clear();
883        assert_eq!(m.len(), 0);
884        assert_eq!(m.get(&0), None);
885        m.insert(99, 99).unwrap();
886        assert_eq!(m.get(&99), Some(99));
887        std::fs::remove_file(&p).ok();
888    }
889
890    #[test]
891    fn snapshot_collects_all_present_pairs() {
892        let p = tmp("snap");
893        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
894        m.insert(1, 10).unwrap();
895        m.insert(2, 20).unwrap();
896        m.insert(3, 30).unwrap();
897        m.remove(&2);
898        let mut snap = m.snapshot();
899        snap.sort();
900        assert_eq!(snap, vec![(1, 10), (3, 30)]);
901        std::fs::remove_file(&p).ok();
902    }
903
904    #[test]
905    fn cross_handle_visibility() {
906        let p = tmp("cross-handle");
907        let writer: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
908        let reader: SharedHashMap<u32, u32> = SharedHashMap::open(&p, 16).unwrap();
909        writer.insert(42, 4242).unwrap();
910        assert_eq!(reader.get(&42), Some(4242));
911        reader.insert(7, 77).unwrap();
912        assert_eq!(writer.get(&7), Some(77));
913        writer.remove(&42);
914        assert_eq!(reader.get(&42), None);
915        std::fs::remove_file(&p).ok();
916    }
917
918    #[test]
919    fn concurrent_inserters_all_keys_present() {
920        let p = tmp("concurrent");
921        let m: Arc<SharedHashMap<u32, u32>> = Arc::new(SharedHashMap::create(&p, 1024).unwrap());
922        let n_threads = 4;
923        let per_thread = 100u32;
924        let mut handles = vec![];
925        for t in 0..n_threads {
926            let m = m.clone();
927            handles.push(thread::spawn(move || {
928                for i in 0..per_thread {
929                    let key = (t as u32) * per_thread + i;
930                    m.insert(key, key * 10).unwrap();
931                }
932            }));
933        }
934        for h in handles { h.join().unwrap(); }
935        assert_eq!(m.len(), n_threads * per_thread as usize);
936        for t in 0..n_threads as u32 {
937            for i in 0..per_thread {
938                let key = t * per_thread + i;
939                assert_eq!(m.get(&key), Some(key * 10),
940                    "key {key} should be present with value {}", key * 10);
941            }
942        }
943        std::fs::remove_file(&p).ok();
944    }
945
946    #[test]
947    fn struct_key_and_value_round_trip() {
948        #[derive(Clone, Copy, Debug, PartialEq, Eq)]
949        #[repr(C)]
950        struct UserId { realm: u32, user: u32 }
951        #[derive(Clone, Copy, Debug, PartialEq)]
952        #[repr(C)]
953        struct Session { token: u64, expires_us: u64 }
954        let p = tmp("struct");
955        let m: SharedHashMap<UserId, Session> = SharedHashMap::create(&p, 32).unwrap();
956        let k = UserId { realm: 1, user: 42 };
957        let v = Session { token: 0xDEAD_BEEF, expires_us: 9_999_999_999 };
958        m.insert(k, v).unwrap();
959        assert_eq!(m.get(&k), Some(v));
960        std::fs::remove_file(&p).ok();
961    }
962
963    #[test]
964    fn payload_too_large_at_create() {
965        #[allow(dead_code)] // sizeof signal only
966        struct BigKey([u8; 64]);
967        impl Copy for BigKey {}
968        impl Clone for BigKey { fn clone(&self) -> Self { *self } }
969        impl PartialEq for BigKey { fn eq(&self, _: &Self) -> bool { true } }
970        impl Eq for BigKey {}
971        let p = tmp("too-large");
972        let r = SharedHashMap::<BigKey, u32>::create(&p, 4);
973        assert_eq!(r.err(), Some(MapError::PayloadTooLarge));
974        std::fs::remove_file(&p).ok();
975    }
976
977    #[test]
978    fn disk_persistence_survives_reopen() {
979        let p = tmp("disk");
980        {
981            let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
982            for k in 0..5u32 { m.insert(k, k * 100).unwrap(); }
983            m.remove(&2);
984            m.flush().unwrap();
985        }
986        let m2: SharedHashMap<u32, u32> = SharedHashMap::open(&p, 16).unwrap();
987        assert_eq!(m2.len(), 4);
988        assert_eq!(m2.get(&0), Some(0));
989        assert_eq!(m2.get(&1), Some(100));
990        assert_eq!(m2.get(&2), None);
991        assert_eq!(m2.get(&3), Some(300));
992        assert_eq!(m2.get(&4), Some(400));
993        std::fs::remove_file(&p).ok();
994    }
995
996    #[test]
997    fn fnv1a_64_is_deterministic() {
998        // Sanity: same input always produces the same hash.
999        let h1 = fnv1a_64(b"adaptive-prims");
1000        let h2 = fnv1a_64(b"adaptive-prims");
1001        assert_eq!(h1, h2);
1002        // And different inputs hash differently.
1003        let h3 = fnv1a_64(b"ADAPTIVE-PRIMS");
1004        assert_ne!(h1, h3);
1005    }
1006
1007    #[test]
1008    fn load_factor_reports_correctly() {
1009        let p = tmp("load");
1010        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 10).unwrap();
1011        for k in 0..3u32 { m.insert(k, k).unwrap(); }
1012        assert_eq!(m.load_factor(), 0.3);
1013        std::fs::remove_file(&p).ok();
1014    }
1015
1016    #[test]
1017    fn remove_bumps_tombstone_counter() {
1018        let p = tmp("tomb-counter");
1019        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
1020        assert_eq!(m.tombstone_count(), 0);
1021        for k in 0..5u32 { m.insert(k, k).unwrap(); }
1022        assert_eq!(m.tombstone_count(), 0);
1023        m.remove(&1);
1024        m.remove(&3);
1025        assert_eq!(m.tombstone_count(), 2);
1026        // Removing absent key does NOT bump tombstone count.
1027        m.remove(&999);
1028        assert_eq!(m.tombstone_count(), 2);
1029        std::fs::remove_file(&p).ok();
1030    }
1031
1032    #[test]
1033    fn compact_on_empty_map_is_noop() {
1034        let p = tmp("compact-empty");
1035        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
1036        assert_eq!(m.compact().unwrap(), 0);
1037        assert_eq!(m.len(), 0);
1038        assert_eq!(m.tombstone_count(), 0);
1039        // Map remains fully usable.
1040        m.insert(7, 70).unwrap();
1041        assert_eq!(m.get(&7), Some(70));
1042        std::fs::remove_file(&p).ok();
1043    }
1044
1045    #[test]
1046    fn compact_reclaims_tombstones() {
1047        let p = tmp("compact-reclaim");
1048        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
1049        for k in 0..10u32 { m.insert(k, k * 10).unwrap(); }
1050        for k in [0u32, 2, 4, 6, 8] { m.remove(&k); }
1051        assert_eq!(m.tombstone_count(), 5);
1052        let reclaimed = m.compact().unwrap();
1053        assert_eq!(reclaimed, 5);
1054        assert_eq!(m.tombstone_count(), 0);
1055        assert_eq!(m.len(), 5);
1056        std::fs::remove_file(&p).ok();
1057    }
1058
1059    #[test]
1060    fn compact_preserves_all_live_pairs() {
1061        let p = tmp("compact-preserve");
1062        let m: SharedHashMap<u32, u64> = SharedHashMap::create(&p, 32).unwrap();
1063        // Insert 20, remove 10, compact, verify the remaining 10
1064        // are all present with their original values.
1065        for k in 0..20u32 { m.insert(k, (k as u64) * 1000).unwrap(); }
1066        for k in (0..20u32).filter(|k| k % 2 == 0) { m.remove(&k); }
1067        let pre: Vec<(u32, u64)> = {
1068            let mut s = m.snapshot();
1069            s.sort();
1070            s
1071        };
1072        m.compact().unwrap();
1073        let post: Vec<(u32, u64)> = {
1074            let mut s = m.snapshot();
1075            s.sort();
1076            s
1077        };
1078        assert_eq!(pre, post,
1079            "compact must preserve every live (K, V) pair exactly");
1080        // And every preserved key is still findable by lookup.
1081        for (k, v) in &post {
1082            assert_eq!(m.get(k), Some(*v));
1083        }
1084        std::fs::remove_file(&p).ok();
1085    }
1086
1087    #[test]
1088    fn compact_reclaims_after_heavy_churn() {
1089        // Many insert/remove cycles accumulate tombstones in the
1090        // probe path because insert probes PAST tombstones if the
1091        // tombstone-reuse path is not exercised. Compact must
1092        // reclaim every dead slot exactly.
1093        let p = tmp("compact-churn");
1094        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 64).unwrap();
1095        for k in 0..32u32 { m.insert(k, k).unwrap(); }
1096        for round in 0..16u32 {
1097            m.remove(&round);
1098            let new_key = 100 + round;
1099            m.insert(new_key, new_key).unwrap();
1100        }
1101        assert_eq!(m.tombstone_count(), 16);
1102        let live_before = m.len();
1103        let reclaimed = m.compact().unwrap();
1104        assert_eq!(reclaimed, 16);
1105        assert_eq!(m.tombstone_count(), 0);
1106        assert_eq!(m.len(), live_before);
1107        for round in 0..16u32 {
1108            assert_eq!(m.get(&round), None);
1109            let new_key = 100 + round;
1110            assert_eq!(m.get(&new_key), Some(new_key));
1111        }
1112        std::fs::remove_file(&p).ok();
1113    }
1114
1115    #[test]
1116    fn tombstone_reuse_avoids_full_after_remove() {
1117        // 8-slot table, fill it, remove one key. The next insert
1118        // of a NEW key REUSES the tombstone slot instead of
1119        // returning Full. This validates the tombstone-reuse-on-
1120        // insert path.
1121        let p = tmp("reuse-avoids-full");
1122        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 8).unwrap();
1123        for k in 0..8u32 { m.insert(k, k).unwrap(); }
1124        m.remove(&3);
1125        assert_eq!(m.tombstone_count(), 1);
1126        // Without reuse this returns Full (7 live + 1 tombstone
1127        // in 8 slots). With reuse it succeeds and the tombstone
1128        // counter drops to 0.
1129        assert!(m.insert(99, 99).is_ok(),
1130            "tombstone reuse must let insert succeed");
1131        assert_eq!(m.get(&99), Some(99));
1132        assert_eq!(m.tombstone_count(), 0,
1133            "successful tombstone reuse must decrement the counter");
1134        std::fs::remove_file(&p).ok();
1135    }
1136
1137    #[test]
1138    fn compact_still_useful_for_remove_heavy_workload() {
1139        // Insert N, remove most WITHOUT re-inserting. Tombstones
1140        // accumulate because there is no insert to trigger reuse.
1141        // compact() bulk-reclaims them. This covers workloads
1142        // that lack the insert pressure to trigger reuse
1143        // naturally.
1144        let p = tmp("compact-still-useful");
1145        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 32).unwrap();
1146        for k in 0..20u32 { m.insert(k, k).unwrap(); }
1147        for k in 0..15u32 { m.remove(&k); }
1148        assert_eq!(m.tombstone_count(), 15);
1149        let reclaimed = m.compact().unwrap();
1150        assert_eq!(reclaimed, 15);
1151        assert_eq!(m.tombstone_count(), 0);
1152        assert_eq!(m.len(), 5);
1153        std::fs::remove_file(&p).ok();
1154    }
1155
1156    #[test]
1157    fn should_compact_threshold_logic() {
1158        let p = tmp("should-compact");
1159        let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 100).unwrap();
1160        // 0 tombstones / 100 capacity = 0.0
1161        assert!(!m.should_compact(0.01));
1162        // Insert 50, remove 30 → 30 tombstones / 100 = 0.30.
1163        for k in 0..50u32 { m.insert(k, k).unwrap(); }
1164        for k in 0..30u32 { m.remove(&k); }
1165        assert_eq!(m.tombstone_count(), 30);
1166        assert!(m.should_compact(0.30));
1167        assert!(m.should_compact(0.29));
1168        assert!(!m.should_compact(0.31));
1169        std::fs::remove_file(&p).ok();
1170    }
1171
1172    #[test]
1173    fn compact_persists_across_reopen() {
1174        let p = tmp("compact-disk");
1175        {
1176            let m: SharedHashMap<u32, u32> = SharedHashMap::create(&p, 16).unwrap();
1177            for k in 0..6u32 { m.insert(k, k * 10).unwrap(); }
1178            for k in [0u32, 2, 4] { m.remove(&k); }
1179            m.compact().unwrap();
1180            m.flush().unwrap();
1181        }
1182        let m2: SharedHashMap<u32, u32> = SharedHashMap::open(&p, 16).unwrap();
1183        assert_eq!(m2.len(), 3);
1184        assert_eq!(m2.tombstone_count(), 0);
1185        for k in [1u32, 3, 5] {
1186            assert_eq!(m2.get(&k), Some(k * 10));
1187        }
1188        for k in [0u32, 2, 4] {
1189            assert_eq!(m2.get(&k), None);
1190        }
1191        std::fs::remove_file(&p).ok();
1192    }
1193}