Skip to main content

subetha_cxc/
shared_universal.rs

1//! `SharedUniversal<T>` - Layer-2 cross-process container that
2//! migrates between Shared* backings as the workload shape changes.
3//!
4//! # The architectural claim
5//!
6//! A single cross-process container that auto-swaps its backing
7//! storage when the observed operation mix favors a different shape.
8//! At creation time the container starts in `Vec` mode (cheap pushes,
9//! O(N) `contains`). When `contains` calls dominate (the common case
10//! for membership / dedup workloads), the container migrates to
11//! `HashMap` mode (O(1) `contains`, slightly more expensive insert).
12//! Subsequent peer reads observe the migration via a version bump in
13//! the shared state header and transparently re-open the new backing.
14//!
15//! # The MVP scope
16//!
17//! - **2 backings only**: `SharedVec<T>` and `SharedHashMap<T, ()>`.
18//!   The extension to 5 backings (SharedRing, SharedHandleTable,
19//!   SharedBTreeMap, SharedTreiberStack) is its own bead.
20//! - **Single-writer model**: ONE process holds the writer role and
21//!   triggers migrations. Other processes are read-only observers
22//!   that follow the strategy tag. Multi-writer voting protocol is
23//!   ap-uvj.
24//! - **Local policy**: the writer's local op histogram drives
25//!   migration decisions. Quorum / cross-process voting is ap-uvj.
26//!
27//! # File layout
28//!
29//! Three coordinated files per logical container:
30//!
31//! ```text
32//! <base>.state.bin           always; small header MMF
33//! <base>-v{N}-vec.bin        current backing if strategy == Vec
34//! <base>-v{N}-map.bin        current backing if strategy == Map
35//! ```
36//!
37//! On migration: writer creates the new `-v{N+1}-{strategy}.bin`,
38//! copies the snapshot, then bumps `state.bin`'s version+strategy
39//! with a single CAS. Readers see the bump on their next op and
40//! re-open transparently.
41//!
42//! # Concurrency model
43//!
44//! - Reader / writer ops take an INTERNAL `RwLock<Backing<T>>` on
45//!   the handle (process-local; protects against the re-open race
46//!   between two ops in the same process).
47//! - Re-open is double-checked: re-read state.version under the
48//!   write lock; if some other thread already re-opened, drop the
49//!   write lock and use the current backing.
50//! - Migration is ONLY safe from a single writer process. If two
51//!   processes both try to migrate, both will succeed locally but
52//!   race on the state CAS; the loser's new backing file is
53//!   orphaned (cleanable). The voting protocol (ap-uvj) prevents
54//!   this; the MVP documents the single-writer constraint.
55
56use std::fs::OpenOptions;
57use std::marker::PhantomData;
58use std::mem::size_of;
59use std::path::{Path, PathBuf};
60use std::sync::atomic::{AtomicU64, Ordering};
61
62use memmap2::{MmapMut, MmapOptions};
63use parking_lot::RwLock;
64
65use crate::shared_hash_map::{MapError, SharedHashMap};
66use crate::shared_vec::SharedVec;
67
68pub const UNIVERSAL_MAGIC: u32 = 0x4150_5556;
69
70/// Strategy tag: which backing is currently live.
71#[repr(u8)]
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum Strategy {
74    Vec = 0,
75    Map = 1,
76}
77
78impl Strategy {
79    fn from_u8(b: u8) -> Option<Self> {
80        match b {
81            0 => Some(Self::Vec),
82            1 => Some(Self::Map),
83            _ => None,
84        }
85    }
86
87    fn file_suffix(self) -> &'static str {
88        match self {
89            Self::Vec => "vec",
90            Self::Map => "map",
91        }
92    }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum UniversalError {
97    InvalidStrategy,
98    IoError(std::io::ErrorKind),
99    LayoutMismatch,
100    VecError,
101    MapError(MapError),
102    Full,
103    /// `current_version + 1` overflows `u32`. After ~4 billion
104    /// migrations on the same base, the container refuses further
105    /// migrations rather than wrap version back to 0 (which then
106    /// silently overwrites the v=0 backing).
107    VersionExhausted,
108}
109
110impl From<std::io::Error> for UniversalError {
111    fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
112}
113impl From<MapError> for UniversalError {
114    fn from(e: MapError) -> Self {
115        match e {
116            MapError::Full => Self::Full,
117            other => Self::MapError(other),
118        }
119    }
120}
121
122#[repr(C, align(64))]
123pub struct UniversalHeader {
124    pub magic: u32,
125    pub capacity: u32,
126    /// Packed state, single AtomicU64 for atomic update / atomic
127    /// reader-load:
128    /// - bits 63..32 = `version: u32` (bumps per migration within
129    ///   the current generation; wraps to 0 at u32::MAX)
130    /// - bits 31..16 = `generation: u16` (bumps when version wraps;
131    ///   ensures a reused (generation, version) pair NEVER appears
132    ///   in the same lifetime, so readers comparing the full u64
133    ///   state always observe wrap-around and re-open)
134    /// - bits 15..0  = `strategy: u16` (low byte is the Strategy
135    ///   discriminant; high byte reserved for strategy variants)
136    ///
137    /// True exhaustion: generation u16 AND version u32 both at MAX
138    /// (= 2^48 = 281 trillion migrations). Returns VersionExhausted.
139    pub state: AtomicU64,
140    /// Bumped by every `insert`; consumed by the writer's local
141    /// policy to decide when to migrate.
142    pub insert_count: AtomicU64,
143    /// Bumped by every `contains`; same role as `insert_count`.
144    pub contains_count: AtomicU64,
145    _pad: [u8; 32],
146}
147
148const _: () = {
149    assert!(size_of::<UniversalHeader>() == 64);
150};
151
152#[inline]
153fn pack(version: u32, generation: u16, strategy: u8) -> u64 {
154    ((version as u64) << 32) | ((generation as u64) << 16) | (strategy as u64)
155}
156#[inline]
157fn unpack(v: u64) -> (u32, u16, u8) {
158    let version = (v >> 32) as u32;
159    let generation = ((v >> 16) & 0xFFFF) as u16;
160    let strategy = (v & 0xFF) as u8;
161    (version, generation, strategy)
162}
163
164enum Backing<T: Copy + Eq + 'static> {
165    Vec(SharedVec<T>),
166    Map(SharedHashMap<T, ()>),
167}
168
169pub struct SharedUniversal<T: Copy + Eq + 'static> {
170    base: PathBuf,
171    capacity: usize,
172    _state_file: std::fs::File,
173    state_mmap: MmapMut,
174    /// Holds `(version, generation, Backing)`. Used to detect when
175    /// the shared state's (version, generation) pair has changed and
176    /// the local backing handle needs to be re-opened.
177    backing: RwLock<(u32, u16, Backing<T>)>,
178    _phantom: PhantomData<T>,
179    header_sidecar: subetha_core::HandshakeHeader,
180    ring_sidecar: Box<subetha_core::ObservationRing>,
181}
182
183unsafe impl<T: Copy + Eq + Send + 'static> Send for SharedUniversal<T> {}
184unsafe impl<T: Copy + Eq + Sync + 'static> Sync for SharedUniversal<T> {}
185
186impl<T: Copy + Eq + Send + Sync + 'static> subetha_sidecar::AdaptiveInstance for SharedUniversal<T> {
187    fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
188    fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
189    fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
190        Box::new(subetha_sidecar::NoMigrationPolicy)
191    }
192}
193
194impl<T: Copy + Eq + 'static> SharedUniversal<T> {
195    fn state_path(base: &Path) -> PathBuf {
196        let mut p = base.to_path_buf();
197        let stem = p.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
198        p.set_file_name(format!("{stem}.state.bin"));
199        p
200    }
201
202    fn backing_path(base: &Path, generation: u16, version: u32, strategy: Strategy) -> PathBuf {
203        let mut p = base.to_path_buf();
204        let stem = p.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
205        p.set_file_name(format!(
206            "{stem}-g{generation}-v{version}-{}.bin",
207            strategy.file_suffix(),
208        ));
209        p
210    }
211
212    /// Create a new container. Starts in Vec strategy at
213    /// (generation=0, version=0).
214    pub fn create(base: impl AsRef<Path>, capacity: usize) -> Result<Self, UniversalError> {
215        let base = base.as_ref().to_path_buf();
216        assert!(capacity >= 1);
217        let state_p = Self::state_path(&base);
218        let state_file = OpenOptions::new()
219            .read(true).write(true).create(true).truncate(true)
220            .open(&state_p)?;
221        state_file.set_len(size_of::<UniversalHeader>() as u64)?;
222        let mut mmap = unsafe { MmapOptions::new().len(size_of::<UniversalHeader>()).map_mut(&state_file)? };
223        let hdr = mmap.as_mut_ptr() as *mut UniversalHeader;
224        unsafe {
225            std::ptr::write_bytes(hdr as *mut u8, 0, size_of::<UniversalHeader>());
226            (*hdr).magic = UNIVERSAL_MAGIC;
227            (*hdr).capacity = capacity as u32;
228            (*hdr).state.store(pack(0, 0, Strategy::Vec as u8), Ordering::Release);
229        }
230        let backing_p = Self::backing_path(&base, 0, 0, Strategy::Vec);
231        let vec: SharedVec<T> = SharedVec::create(&backing_p, capacity)
232            .map_err(|_| UniversalError::VecError)?;
233        Ok(Self {
234            base, capacity,
235            _state_file: state_file,
236            state_mmap: mmap,
237            backing: RwLock::new((0, 0, Backing::Vec(vec))),
238            _phantom: PhantomData,
239            header_sidecar: subetha_core::HandshakeHeader::new(),
240            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
241        })
242    }
243
244    /// Open an existing container. Reads the active (generation,
245    /// version, strategy) from the state header and opens the
246    /// matching backing file.
247    pub fn open(base: impl AsRef<Path>, capacity: usize) -> Result<Self, UniversalError> {
248        let base = base.as_ref().to_path_buf();
249        let state_p = Self::state_path(&base);
250        let state_file = OpenOptions::new().read(true).write(true).open(&state_p)?;
251        if state_file.metadata()?.len() < size_of::<UniversalHeader>() as u64 {
252            return Err(UniversalError::LayoutMismatch);
253        }
254        let mmap = unsafe { MmapOptions::new().len(size_of::<UniversalHeader>()).map_mut(&state_file)? };
255        let hdr = unsafe { &*(mmap.as_ptr() as *const UniversalHeader) };
256        if hdr.magic != UNIVERSAL_MAGIC || hdr.capacity != capacity as u32 {
257            return Err(UniversalError::LayoutMismatch);
258        }
259        let (version, generation, strategy_byte) = unpack(hdr.state.load(Ordering::Acquire));
260        let strategy = Strategy::from_u8(strategy_byte).ok_or(UniversalError::InvalidStrategy)?;
261        let backing = Self::open_backing(&base, generation, version, strategy, capacity)?;
262        Ok(Self {
263            base, capacity,
264            _state_file: state_file,
265            state_mmap: mmap,
266            backing: RwLock::new((version, generation, backing)),
267            _phantom: PhantomData,
268            header_sidecar: subetha_core::HandshakeHeader::new(),
269            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
270        })
271    }
272
273    fn open_backing(
274        base: &Path, generation: u16, version: u32, strategy: Strategy, capacity: usize,
275    ) -> Result<Backing<T>, UniversalError> {
276        let p = Self::backing_path(base, generation, version, strategy);
277        match strategy {
278            Strategy::Vec => {
279                let v: SharedVec<T> = SharedVec::open(&p, capacity)
280                    .map_err(|_| UniversalError::VecError)?;
281                Ok(Backing::Vec(v))
282            }
283            Strategy::Map => {
284                let m: SharedHashMap<T, ()> = SharedHashMap::open(&p, capacity)?;
285                Ok(Backing::Map(m))
286            }
287        }
288    }
289
290    fn header(&self) -> &UniversalHeader {
291        unsafe { &*(self.state_mmap.as_ptr() as *const UniversalHeader) }
292    }
293
294    /// The strategy currently active in shared state. May differ from
295    /// the locally-held backing if another writer just migrated; the
296    /// next op call will re-open transparently.
297    pub fn strategy(&self) -> Strategy {
298        let (_, _, s) = unpack(self.header().state.load(Ordering::Acquire));
299        Strategy::from_u8(s).expect("invalid strategy byte in state header")
300    }
301
302    /// The shared strategy version. Bumps on every migration; wraps
303    /// to 0 at u32::MAX with the generation counter incrementing.
304    pub fn strategy_version(&self) -> u32 {
305        unpack(self.header().state.load(Ordering::Acquire)).0
306    }
307
308    /// The shared generation counter. Bumps each time `version`
309    /// wraps from u32::MAX back to 0. Together with `version` it
310    /// forms the true monotonic migration counter.
311    pub fn strategy_generation(&self) -> u16 {
312        unpack(self.header().state.load(Ordering::Acquire)).1
313    }
314
315    /// Re-open the local backing handle if the shared state's
316    /// (version, generation) pair differs from the locally cached
317    /// pair. Comparing both fields means a wrap-around (same version
318    /// at a new generation) ALSO triggers re-open, preventing the
319    /// stale-reader race where a reused version points at new
320    /// content. Double-checked so concurrent ops don't trample each
321    /// other.
322    fn refresh_backing_if_stale(&self) -> Result<(), UniversalError> {
323        let (shared_v, shared_g, _) = unpack(self.header().state.load(Ordering::Acquire));
324        {
325            let g = self.backing.read();
326            if g.0 == shared_v && g.1 == shared_g { return Ok(()); }
327        }
328        let mut g = self.backing.write();
329        let (shared_v2, shared_g2, shared_s_byte2) =
330            unpack(self.header().state.load(Ordering::Acquire));
331        if g.0 == shared_v2 && g.1 == shared_g2 { return Ok(()); }
332        let strategy = Strategy::from_u8(shared_s_byte2).ok_or(UniversalError::InvalidStrategy)?;
333        let new_backing = Self::open_backing(
334            &self.base, shared_g2, shared_v2, strategy, self.capacity,
335        )?;
336        *g = (shared_v2, shared_g2, new_backing);
337        Ok(())
338    }
339
340    /// Insert `value`. For Vec strategy this is push_back; for Map
341    /// strategy this is insert(value, ()).
342    pub fn insert(&self, value: T) -> Result<(), UniversalError>
343    where T: std::hash::Hash,
344    {
345        self.refresh_backing_if_stale()?;
346        let g = self.backing.read();
347        let r: Result<(), UniversalError> = match &g.2 {
348            Backing::Vec(v) => {
349                v.push_back(value).map_err(|_| UniversalError::Full).map(|_| ())
350            }
351            Backing::Map(m) => {
352                m.insert(value, ()).map(|_| ()).map_err(Into::into)
353            }
354        };
355        self.header().insert_count.fetch_add(1, Ordering::Relaxed);
356        self.ring_sidecar.push_op(
357            crate::sidecar_ops::universal::OP_INSERT,
358            if r.is_err() { 1 } else { 0 },
359        );
360        r
361    }
362
363    /// Membership check. Bumps the contains counter so the local
364    /// policy can observe contains-heavy workloads.
365    pub fn contains(&self, value: &T) -> Result<bool, UniversalError>
366    where T: std::hash::Hash,
367    {
368        self.refresh_backing_if_stale()?;
369        let g = self.backing.read();
370        let hit = match &g.2 {
371            Backing::Vec(v) => v.snapshot().iter().any(|x| x == value),
372            Backing::Map(m) => m.contains_key(value),
373        };
374        self.header().contains_count.fetch_add(1, Ordering::Relaxed);
375        self.ring_sidecar.push_op(
376            crate::sidecar_ops::universal::OP_CONTAINS,
377            if hit { 0 } else { 2 },
378        );
379        Ok(hit)
380    }
381
382    /// Number of live entries.
383    pub fn len(&self) -> Result<usize, UniversalError> {
384        self.refresh_backing_if_stale()?;
385        let g = self.backing.read();
386        Ok(match &g.2 {
387            Backing::Vec(v) => v.len(),
388            Backing::Map(m) => m.len(),
389        })
390    }
391
392    pub fn is_empty(&self) -> Result<bool, UniversalError> {
393        Ok(self.len()? == 0)
394    }
395
396    /// Reset the universal to empty: clears whichever backing is
397    /// currently live (Vec or Map). Does not change the strategy.
398    /// Useful for steady-state benches that need to reset accumulated
399    /// state between iterations. Not thread-safe with concurrent
400    /// insert/remove from other threads.
401    pub fn clear(&self) -> Result<(), UniversalError> {
402        self.refresh_backing_if_stale()?;
403        let g = self.backing.read();
404        match &g.2 {
405            Backing::Vec(v) => v.clear(),
406            Backing::Map(m) => m.clear(),
407        }
408        Ok(())
409    }
410
411    /// Snapshot all live values into a `Vec<T>`. Best-effort under
412    /// concurrent writers.
413    pub fn snapshot(&self) -> Result<Vec<T>, UniversalError> {
414        self.refresh_backing_if_stale()?;
415        let g = self.backing.read();
416        Ok(match &g.2 {
417            Backing::Vec(v) => v.snapshot(),
418            Backing::Map(m) => m.snapshot().into_iter().map(|(k, _)| k).collect(),
419        })
420    }
421
422    /// Operation counts since creation. The writer's policy code
423    /// reads these to decide when to migrate.
424    pub fn op_histogram(&self) -> (u64, u64) {
425        let hdr = self.header();
426        (
427            hdr.insert_count.load(Ordering::Acquire),
428            hdr.contains_count.load(Ordering::Acquire),
429        )
430    }
431
432    /// Force a migration to `target`. Snapshots the current backing,
433    /// creates a new backing file at version+1, restores the snapshot,
434    /// then publishes the new (version, strategy) via Release CAS.
435    ///
436    /// # Concurrency
437    ///
438    /// **Single-writer ONLY.** Two processes calling `migrate_to`
439    /// concurrently will both build new backings and race on the CAS;
440    /// the loser orphans its backing file. Use ap-uvj's voting
441    /// protocol to coordinate when multiple writers are involved.
442    pub fn migrate_to(&self, target: Strategy) -> Result<(), UniversalError>
443    where T: std::hash::Hash,
444    {
445        let r = self.migrate_to_inner(target);
446        self.ring_sidecar.push_op(
447            crate::sidecar_ops::universal::OP_MIGRATE,
448            if r.is_err() { 1 } else { 0 },
449        );
450        r
451    }
452
453    fn migrate_to_inner(&self, target: Strategy) -> Result<(), UniversalError>
454    where T: std::hash::Hash,
455    {
456        let current_state = self.header().state.load(Ordering::Acquire);
457        let (current_v, current_g, current_s) = unpack(current_state);
458        let current = Strategy::from_u8(current_s).ok_or(UniversalError::InvalidStrategy)?;
459        if current == target { return Ok(()); }
460        // Bump version; on overflow, bump generation and reset
461        // version to 0. True exhaustion (both at max) returns
462        // VersionExhausted - that ceiling is 2^48 = 281 trillion
463        // migrations on the same base.
464        let (new_v, new_g) = match current_v.checked_add(1) {
465            Some(v) => (v, current_g),
466            None => {
467                let next_g = current_g.checked_add(1)
468                    .ok_or(UniversalError::VersionExhausted)?;
469                (0, next_g)
470            }
471        };
472        let snap = self.snapshot()?;
473        let mut g = self.backing.write();
474        let new_p = Self::backing_path(&self.base, new_g, new_v, target);
475        // Build the new backing inside a closure so any error path
476        // can clean up the partially-created file before returning.
477        let build_result: Result<Backing<T>, UniversalError> = (|| {
478            match target {
479                Strategy::Vec => {
480                    let v: SharedVec<T> = SharedVec::create(&new_p, self.capacity)
481                        .map_err(|_| UniversalError::VecError)?;
482                    for x in &snap {
483                        v.push_back(*x).map_err(|_| UniversalError::Full)?;
484                    }
485                    Ok(Backing::Vec(v))
486                }
487                Strategy::Map => {
488                    let m: SharedHashMap<T, ()> = SharedHashMap::create(&new_p, self.capacity)?;
489                    for x in &snap { m.insert(*x, ())?; }
490                    Ok(Backing::Map(m))
491                }
492            }
493        })();
494        let new_backing = match build_result {
495            Ok(b) => b,
496            Err(e) => {
497                std::fs::remove_file(&new_p).ok();
498                return Err(e);
499            }
500        };
501        let new_state = pack(new_v, new_g, target as u8);
502        match self.header().state.compare_exchange(
503            current_state, new_state, Ordering::AcqRel, Ordering::Acquire,
504        ) {
505            Ok(_) => {
506                *g = (new_v, new_g, new_backing);
507                Ok(())
508            }
509            Err(_) => {
510                drop(new_backing);
511                std::fs::remove_file(&new_p).ok();
512                Err(UniversalError::VecError)
513            }
514        }
515    }
516
517    /// Local-policy migration trigger. If the observed `contains` ops
518    /// outnumber `insert` ops by at least `contains_to_insert_ratio`,
519    /// AND total ops exceed `min_total_ops`, migrate Vec → Map. If
520    /// the inverse holds, migrate Map → Vec.
521    ///
522    /// Returns `Ok(Some(new_strategy))` if a migration happened,
523    /// `Ok(None)` if no policy threshold was crossed.
524    pub fn maybe_migrate_by_policy(
525        &self,
526        contains_to_insert_ratio: f64,
527        min_total_ops: u64,
528    ) -> Result<Option<Strategy>, UniversalError>
529    where T: std::hash::Hash,
530    {
531        let (ins, cnt) = self.op_histogram();
532        if ins + cnt < min_total_ops { return Ok(None); }
533        let current = self.strategy();
534        let ratio = cnt as f64 / (ins.max(1)) as f64;
535        let want = if ratio >= contains_to_insert_ratio { Strategy::Map } else { Strategy::Vec };
536        if want == current { return Ok(None); }
537        self.migrate_to(want)?;
538        Ok(Some(want))
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    fn tmp_base(name: &str) -> PathBuf {
547        let mut p = std::env::temp_dir();
548        let pid = std::process::id();
549        p.push(format!("subetha-universal-{name}-{pid}"));
550        p
551    }
552
553    fn cleanup(base: &Path) {
554        let stem = base.file_name().unwrap().to_string_lossy().to_string();
555        let parent = base.parent().unwrap_or_else(|| Path::new(""));
556        if let Ok(entries) = std::fs::read_dir(parent) {
557            for e in entries.flatten() {
558                let name = e.file_name().to_string_lossy().to_string();
559                if name.starts_with(&stem) {
560                    std::fs::remove_file(e.path()).ok();
561                }
562            }
563        }
564    }
565
566    #[test]
567    fn create_starts_in_vec_strategy() {
568        let base = tmp_base("starts-vec");
569        let u: SharedUniversal<u64> = SharedUniversal::create(&base, 64).unwrap();
570        assert_eq!(u.strategy(), Strategy::Vec);
571        assert_eq!(u.strategy_version(), 0);
572        assert_eq!(u.len().unwrap(), 0);
573        drop(u);
574        cleanup(&base);
575    }
576
577    #[test]
578    fn insert_and_contains_round_trip_in_vec_mode() {
579        let base = tmp_base("vec-roundtrip");
580        let u: SharedUniversal<u64> = SharedUniversal::create(&base, 64).unwrap();
581        for k in 0..10u64 { u.insert(k).unwrap(); }
582        for k in 0..10u64 { assert!(u.contains(&k).unwrap()); }
583        assert!(!u.contains(&999).unwrap());
584        assert_eq!(u.len().unwrap(), 10);
585        drop(u);
586        cleanup(&base);
587    }
588
589    #[test]
590    fn explicit_migrate_to_map_preserves_contents() {
591        let base = tmp_base("explicit-map");
592        let u: SharedUniversal<u64> = SharedUniversal::create(&base, 64).unwrap();
593        for k in 0..10u64 { u.insert(k).unwrap(); }
594        u.migrate_to(Strategy::Map).unwrap();
595        assert_eq!(u.strategy(), Strategy::Map);
596        assert_eq!(u.strategy_version(), 1);
597        for k in 0..10u64 { assert!(u.contains(&k).unwrap()); }
598        assert_eq!(u.len().unwrap(), 10);
599        drop(u);
600        cleanup(&base);
601    }
602
603    #[test]
604    fn migrate_back_and_forth_preserves_contents() {
605        let base = tmp_base("round-trip");
606        let u: SharedUniversal<u64> = SharedUniversal::create(&base, 64).unwrap();
607        for k in 0..5u64 { u.insert(k).unwrap(); }
608        u.migrate_to(Strategy::Map).unwrap();
609        u.migrate_to(Strategy::Vec).unwrap();
610        u.migrate_to(Strategy::Map).unwrap();
611        assert_eq!(u.strategy(), Strategy::Map);
612        assert_eq!(u.strategy_version(), 3);
613        let mut snap = u.snapshot().unwrap();
614        snap.sort();
615        assert_eq!(snap, vec![0, 1, 2, 3, 4]);
616        drop(u);
617        cleanup(&base);
618    }
619
620    #[test]
621    fn migrate_to_same_strategy_is_noop() {
622        let base = tmp_base("same");
623        let u: SharedUniversal<u64> = SharedUniversal::create(&base, 16).unwrap();
624        u.migrate_to(Strategy::Vec).unwrap();
625        assert_eq!(u.strategy_version(), 0);
626        u.migrate_to(Strategy::Map).unwrap();
627        u.migrate_to(Strategy::Map).unwrap();
628        assert_eq!(u.strategy_version(), 1);
629        drop(u);
630        cleanup(&base);
631    }
632
633    #[test]
634    fn local_policy_migrates_to_map_under_contains_load() {
635        let base = tmp_base("policy-map");
636        let u: SharedUniversal<u64> = SharedUniversal::create(&base, 64).unwrap();
637        for k in 0..5u64 { u.insert(k).unwrap(); }
638        // 100 contains, 5 inserts → ratio = 20, well above 0.5
639        for _ in 0..100 { u.contains(&3).unwrap(); }
640        let migrated = u.maybe_migrate_by_policy(0.5, 100).unwrap();
641        assert_eq!(migrated, Some(Strategy::Map));
642        assert_eq!(u.strategy(), Strategy::Map);
643        drop(u);
644        cleanup(&base);
645    }
646
647    #[test]
648    fn local_policy_keeps_vec_under_insert_load() {
649        let base = tmp_base("policy-vec");
650        let u: SharedUniversal<u64> = SharedUniversal::create(&base, 256).unwrap();
651        for k in 0..200u64 { u.insert(k).unwrap(); }
652        for _ in 0..10 { u.contains(&3).unwrap(); }
653        let migrated = u.maybe_migrate_by_policy(0.5, 100).unwrap();
654        assert_eq!(migrated, None);
655        assert_eq!(u.strategy(), Strategy::Vec);
656        drop(u);
657        cleanup(&base);
658    }
659
660    #[test]
661    fn reader_handle_observes_migration_via_version_bump() {
662        // Writer process equivalent: SharedUniversal::create.
663        // Reader process equivalent: SharedUniversal::open against
664        // the same base. After writer migrates, reader's next op
665        // must transparently re-open the new backing.
666        let base = tmp_base("cross-handle");
667        let writer: SharedUniversal<u64> = SharedUniversal::create(&base, 32).unwrap();
668        let reader: SharedUniversal<u64> = SharedUniversal::open(&base, 32).unwrap();
669        for k in 0..5u64 { writer.insert(k).unwrap(); }
670        // Reader sees the inserts in Vec mode.
671        assert_eq!(reader.strategy(), Strategy::Vec);
672        assert_eq!(reader.len().unwrap(), 5);
673        // Writer migrates.
674        writer.migrate_to(Strategy::Map).unwrap();
675        // Reader's next op transparently re-opens.
676        assert_eq!(reader.strategy(), Strategy::Map);
677        assert_eq!(reader.strategy_version(), 1);
678        assert_eq!(reader.len().unwrap(), 5);
679        for k in 0..5u64 { assert!(reader.contains(&k).unwrap()); }
680        drop(writer);
681        drop(reader);
682        cleanup(&base);
683    }
684
685    #[test]
686    fn snapshot_preserves_through_migration() {
687        let base = tmp_base("snap");
688        let u: SharedUniversal<u32> = SharedUniversal::create(&base, 32).unwrap();
689        for k in [10u32, 5, 7, 1, 99] { u.insert(k).unwrap(); }
690        let mut pre = u.snapshot().unwrap();
691        pre.sort();
692        u.migrate_to(Strategy::Map).unwrap();
693        let mut post = u.snapshot().unwrap();
694        post.sort();
695        assert_eq!(pre, post);
696        drop(u);
697        cleanup(&base);
698    }
699
700    #[test]
701    fn op_histogram_tracks_real_ops() {
702        let base = tmp_base("hist");
703        let u: SharedUniversal<u64> = SharedUniversal::create(&base, 32).unwrap();
704        u.insert(1).unwrap();
705        u.insert(2).unwrap();
706        u.insert(3).unwrap();
707        u.contains(&2).unwrap();
708        u.contains(&2).unwrap();
709        let (ins, cnt) = u.op_histogram();
710        assert_eq!(ins, 3);
711        assert_eq!(cnt, 2);
712        drop(u);
713        cleanup(&base);
714    }
715
716    #[test]
717    fn pack_unpack_round_trips_all_three_fields() {
718        // version, generation, strategy round-trip exactly through
719        // the u64 state encoding.
720        let v: u32 = 0xDEAD_BEEF;
721        let g: u16 = 0xCAFE;
722        let s: u8 = Strategy::Map as u8;
723        let packed = pack(v, g, s);
724        let (rv, rg, rs) = unpack(packed);
725        assert_eq!(rv, v);
726        assert_eq!(rg, g);
727        assert_eq!(rs, s);
728    }
729
730    #[test]
731    fn starts_at_generation_zero() {
732        let base = tmp_base("gen-zero");
733        let u: SharedUniversal<u64> = SharedUniversal::create(&base, 16).unwrap();
734        assert_eq!(u.strategy_version(), 0);
735        assert_eq!(u.strategy_generation(), 0);
736        drop(u);
737        cleanup(&base);
738    }
739
740    #[test]
741    fn migration_within_generation_keeps_generation_zero() {
742        let base = tmp_base("same-gen");
743        let u: SharedUniversal<u64> = SharedUniversal::create(&base, 16).unwrap();
744        u.migrate_to(Strategy::Map).unwrap();
745        u.migrate_to(Strategy::Vec).unwrap();
746        u.migrate_to(Strategy::Map).unwrap();
747        assert_eq!(u.strategy_version(), 3);
748        assert_eq!(u.strategy_generation(), 0);
749        drop(u);
750        cleanup(&base);
751    }
752
753    #[test]
754    fn version_wrap_bumps_generation_and_resets_version() {
755        // Synthesize a near-wrap state by creating a backing file
756        // at (g=0, v=u32::MAX, Vec) on disk, pointing the state
757        // header there, and forcing the local backing to re-open
758        // at that synthetic state. Then migrate once and verify
759        // version wraps to 0 and generation bumps to 1.
760        let base = tmp_base("wrap-gen");
761        let u: SharedUniversal<u64> = SharedUniversal::create(&base, 16).unwrap();
762        let synth_p = SharedUniversal::<u64>::backing_path(
763            u.base.as_path(), 0, u32::MAX, Strategy::Vec,
764        );
765        // Pre-create the synthetic backing file so refresh_backing
766        // can open it.
767        let synth: SharedVec<u64> = SharedVec::create(&synth_p, 16).unwrap();
768        drop(synth);
769        u.header().state.store(
770            pack(u32::MAX, 0, Strategy::Vec as u8),
771            Ordering::Release,
772        );
773        u.refresh_backing_if_stale().unwrap();
774        // Now migrate: version wraps to 0; generation bumps to 1.
775        u.migrate_to(Strategy::Map).unwrap();
776        assert_eq!(u.strategy_version(), 0);
777        assert_eq!(u.strategy_generation(), 1);
778        assert_eq!(u.strategy(), Strategy::Map);
779        drop(u);
780        cleanup(&base);
781    }
782
783    #[test]
784    fn true_exhaustion_returns_version_exhausted() {
785        // generation = u16::MAX, version = u32::MAX → next migrate
786        // can't bump either; returns VersionExhausted.
787        let base = tmp_base("exhausted");
788        let u: SharedUniversal<u64> = SharedUniversal::create(&base, 16).unwrap();
789        u.header().state.store(
790            pack(u32::MAX, u16::MAX, Strategy::Vec as u8),
791            Ordering::Release,
792        );
793        u.refresh_backing_if_stale().unwrap_or(());
794        let r = u.migrate_to(Strategy::Map);
795        assert_eq!(r.err(), Some(UniversalError::VersionExhausted));
796        drop(u);
797        cleanup(&base);
798    }
799
800    #[test]
801    fn reader_re_opens_on_generation_change_even_at_same_version() {
802        // This is the load-bearing safety property: if a writer
803        // wraps version back to 0 (bumping generation), an old
804        // reader whose cached (v, g) is (0, 0) MUST re-open when
805        // the shared state changes to (0, 1, new_strategy).
806        let base = tmp_base("reader-gen");
807        let writer: SharedUniversal<u64> = SharedUniversal::create(&base, 16).unwrap();
808        let reader: SharedUniversal<u64> = SharedUniversal::open(&base, 16).unwrap();
809        // Both at (v=0, g=0). Synthesize a wrap by writing the
810        // post-wrap state directly + creating a matching backing.
811        writer.insert(11).unwrap();
812        writer.insert(22).unwrap();
813        // Now wrap: pretend writer just completed a migration that
814        // wrapped version to 0 and bumped generation to 1, with
815        // strategy Map. Create the new-gen backing file the same
816        // way migrate_to does, then publish state.
817        let new_p = SharedUniversal::<u64>::backing_path(
818            writer.base.as_path(), 1, 0, Strategy::Map,
819        );
820        let m: SharedHashMap<u64, ()> = SharedHashMap::create(&new_p, 16).unwrap();
821        m.insert(99u64, ()).unwrap();
822        drop(m);
823        writer.header().state.store(
824            pack(0, 1, Strategy::Map as u8),
825            Ordering::Release,
826        );
827        // Reader's local backing is at (v=0, g=0). Without the
828        // generation check, it sees "v=0 == 0, no re-open
829        // needed" and return stale results. With the generation
830        // check, refresh_backing_if_stale re-opens at (0, 1, Map).
831        assert!(reader.contains(&99u64).unwrap());
832        // Old keys are NOT in the new Map backing (it was created
833        // fresh with only 99).
834        assert!(!reader.contains(&11u64).unwrap());
835        assert_eq!(reader.strategy(), Strategy::Map);
836        assert_eq!(reader.strategy_version(), 0);
837        assert_eq!(reader.strategy_generation(), 1);
838        drop(writer);
839        drop(reader);
840        cleanup(&base);
841    }
842}