Skip to main content

subetha_cxc/
shared_string_arena.rs

1//! `SharedStringArena` - append-only position-independent string
2//! pool backed by an MMF.
3//!
4//! # Why this exists
5//!
6//! Variable-length strings can't be stored inline in fixed-size
7//! slots (SharedHashMap, SharedVec, etc.) without padding waste or
8//! truncation. The natural cross-process solution is a shared byte
9//! arena: every process maps the same file at (potentially)
10//! different base addresses, and string references are
11//! position-independent `(offset, len)` pairs. Adding
12//! `mmap_base + offset` in any process resolves to the same bytes.
13//!
14//! # Layout
15//!
16//! ```text
17//! +---------------------------+
18//! | ArenaHeader (64B)         |
19//! |   magic, capacity_bytes   |
20//! |   used_bytes: AtomicU64   |
21//! +---------------------------+
22//! | bytes[0 .. capacity]      |
23//! +---------------------------+
24//! ```
25//!
26//! # Protocol
27//!
28//! `intern(s)`:
29//! 1. `offset = used_bytes.fetch_add(len)`.
30//! 2. If `offset + len > capacity`, rollback with
31//!    `fetch_sub(len)` and return `Full`. (Note: the
32//!    rollback is best-effort; if two threads race-overflow
33//!    simultaneously, both fetch_subs leave the counter
34//!    deterministic without "losing" bytes.)
35//! 3. Memcpy `s.bytes()` into `arena[offset..offset+len]`.
36//! 4. Return `StringRef { offset, len }`.
37//!
38//! `get(r)`:
39//! - Bounds-check `r.offset + r.len <= used_bytes` (sanity), then
40//!   return `&arena[r.offset..r.offset+r.len]` as a `&str`.
41//!
42//! # Concurrency
43//!
44//! Concurrent interners get distinct slices via fetch_add. Once
45//! the bytes are written, they are never moved (append-only). A
46//! reader holding a StringRef can always resolve it correctly,
47//! provided their `get` happens AFTER the interner returned the
48//! ref (which is the natural happens-before edge: the interner
49//! does the write, then makes the ref visible to the reader).
50//!
51//! # Deduplication
52//!
53//! Not provided here. For dedup, layer a `SharedHashMap<u64 hash,
54//! StringRef>` over the arena and consult it before each intern.
55//!
56//! # No deletion
57//!
58//! Append-only. The whole arena is reclaimed via `clear` (callers
59//! must ensure no concurrent readers); fine-grained deletion
60//! requires a free-list / compaction protocol that defeats the
61//! point of an arena.
62
63use std::fs::{File, OpenOptions};
64use std::mem::size_of;
65use std::path::Path;
66use std::sync::atomic::{AtomicU64, Ordering};
67
68use memmap2::{Mmap, MmapMut, MmapOptions};
69
70pub const ARENA_MAGIC: u64 = 0x4150_5341_524E_4131;
71
72/// How this process mapped the file. `MmapMut` demands a read+write
73/// file handle, which a consumer holding read access alone cannot get.
74enum Mapping {
75    Writable(MmapMut),
76    ReadOnly(Mmap),
77}
78
79impl Mapping {
80    #[inline]
81    fn as_ptr(&self) -> *const u8 {
82        match self {
83            Mapping::Writable(m) => m.as_ptr(),
84            Mapping::ReadOnly(m) => m.as_ptr(),
85        }
86    }
87
88    #[inline]
89    fn is_writable(&self) -> bool {
90        matches!(self, Mapping::Writable(_))
91    }
92
93    fn flush(&self) -> Result<(), std::io::Error> {
94        match self {
95            Mapping::Writable(m) => m.flush(),
96            Mapping::ReadOnly(_) => Ok(()),
97        }
98    }
99
100    fn flush_async(&self) -> Result<(), std::io::Error> {
101        match self {
102            Mapping::Writable(m) => m.flush_async(),
103            Mapping::ReadOnly(_) => Ok(()),
104        }
105    }
106}
107
108#[repr(C, align(64))]
109pub struct ArenaHeader {
110    pub magic: u64,
111    pub capacity_bytes: u64,
112    pub used_bytes: AtomicU64,
113    _pad: [u8; 40],
114}
115
116const _: () = {
117    assert!(size_of::<ArenaHeader>() == 64);
118};
119
120pub const fn arena_file_size(capacity_bytes: usize) -> usize {
121    size_of::<ArenaHeader>() + capacity_bytes
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum ArenaError {
126    Full,
127    InvalidRef,
128    InvalidUtf8,
129    LayoutMismatch,
130    /// The arena was opened read-only and something tried to write it.
131    ReadOnly,
132    IoError(std::io::ErrorKind),
133}
134
135impl From<std::io::Error> for ArenaError {
136    fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
137}
138
139/// Position-independent reference to a string in a SharedStringArena.
140/// Encoded as a `u64` (offset:u32, len:u32) for stable cross-process
141/// passing (the same u64 resolves to the same bytes in every process
142/// that maps the arena).
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
144pub struct StringRef {
145    pub offset: u32,
146    pub len: u32,
147}
148
149impl StringRef {
150    #[inline]
151    pub fn to_u64(self) -> u64 {
152        ((self.offset as u64) << 32) | (self.len as u64)
153    }
154    #[inline]
155    pub fn from_u64(v: u64) -> Self {
156        Self {
157            offset: (v >> 32) as u32,
158            len: v as u32,
159        }
160    }
161}
162
163pub struct SharedStringArena {
164    _file: File,
165    mmap: Mapping,
166    capacity_bytes: usize,
167    header_sidecar: subetha_core::HandshakeHeader,
168    ring_sidecar: Box<subetha_core::ObservationRing>,
169}
170
171unsafe impl Send for SharedStringArena {}
172unsafe impl Sync for SharedStringArena {}
173
174impl subetha_sidecar::AdaptiveInstance for SharedStringArena {
175    fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
176    fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
177    fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
178        Box::new(subetha_sidecar::NoMigrationPolicy)
179    }
180}
181
182impl SharedStringArena {
183    pub fn create(
184        path: impl AsRef<Path>, capacity_bytes: usize,
185    ) -> Result<Self, ArenaError> {
186        assert!(capacity_bytes >= 1);
187        assert!(capacity_bytes <= u32::MAX as usize,
188            "capacity_bytes must fit in u32 for StringRef offset");
189        let total = arena_file_size(capacity_bytes);
190        let file = OpenOptions::new()
191            .read(true).write(true).create(true).truncate(true)
192            .open(path.as_ref())?;
193        file.set_len(total as u64)?;
194        let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
195        let hdr = mmap.as_mut_ptr() as *mut ArenaHeader;
196        unsafe {
197            std::ptr::write(hdr, ArenaHeader {
198                magic: ARENA_MAGIC,
199                capacity_bytes: capacity_bytes as u64,
200                used_bytes: AtomicU64::new(0),
201                _pad: [0; 40],
202            });
203        }
204        Ok(Self {
205            _file: file, mmap: Mapping::Writable(mmap), capacity_bytes,
206            header_sidecar: subetha_core::HandshakeHeader::new(),
207            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
208        })
209    }
210
211    pub fn open(
212        path: impl AsRef<Path>, expected_capacity_bytes: usize,
213    ) -> Result<Self, ArenaError> {
214        let total = arena_file_size(expected_capacity_bytes);
215        let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
216        if file.metadata()?.len() < total as u64 {
217            return Err(ArenaError::LayoutMismatch);
218        }
219        let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
220        let this = Self {
221            _file: file, mmap: Mapping::Writable(mmap),
222            capacity_bytes: expected_capacity_bytes,
223            header_sidecar: subetha_core::HandshakeHeader::new(),
224            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
225        };
226        this.validate(expected_capacity_bytes)?;
227        Ok(this)
228    }
229
230    /// Open an arena this process may only read.
231    ///
232    /// [`open`](Self::open) needs a read+write file handle, which a
233    /// consumer of a privileged producer's arena does not have. Reads
234    /// behave identically; [`intern`](Self::intern) and friends return
235    /// [`ArenaError::ReadOnly`].
236    pub fn open_read_only(
237        path: impl AsRef<Path>, expected_capacity_bytes: usize,
238    ) -> Result<Self, ArenaError> {
239        let total = arena_file_size(expected_capacity_bytes);
240        let file = OpenOptions::new().read(true).open(path.as_ref())?;
241        if file.metadata()?.len() < total as u64 {
242            return Err(ArenaError::LayoutMismatch);
243        }
244        let mmap = unsafe { MmapOptions::new().len(total).map(&file)? };
245        let this = Self {
246            _file: file, mmap: Mapping::ReadOnly(mmap),
247            capacity_bytes: expected_capacity_bytes,
248            header_sidecar: subetha_core::HandshakeHeader::new(),
249            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
250        };
251        this.validate(expected_capacity_bytes)?;
252        Ok(this)
253    }
254
255    /// Whether the header on disk is the one this mapping expects.
256    fn validate(&self, expected_capacity_bytes: usize) -> Result<(), ArenaError> {
257        let hdr = self.header();
258        if hdr.magic != ARENA_MAGIC || hdr.capacity_bytes != expected_capacity_bytes as u64 {
259            return Err(ArenaError::LayoutMismatch);
260        }
261        Ok(())
262    }
263
264    /// Whether this mapping may be written.
265    #[inline]
266    pub fn is_writable(&self) -> bool {
267        self.mmap.is_writable()
268    }
269
270    #[inline]
271    pub fn capacity_bytes(&self) -> usize { self.capacity_bytes }
272
273    #[inline]
274    pub fn used_bytes(&self) -> usize {
275        self.header().used_bytes.load(Ordering::Acquire) as usize
276    }
277
278    #[inline]
279    pub fn remaining_bytes(&self) -> usize {
280        self.capacity_bytes.saturating_sub(self.used_bytes())
281    }
282
283    fn header(&self) -> &ArenaHeader {
284        unsafe { &*(self.mmap.as_ptr() as *const ArenaHeader) }
285    }
286
287    /// Append a string to the arena. Returns a StringRef that
288    /// resolves to the bytes in any mapping of the same file.
289    ///
290    /// Returns `Err(Full)` when the arena has no room. The empty
291    /// string `""` interns at the current offset with `len = 0`.
292    pub fn intern(&self, s: &str) -> Result<StringRef, ArenaError> {
293        self.intern_bytes(s.as_bytes())
294    }
295
296    /// Append arbitrary bytes (not necessarily UTF-8) to the arena.
297    /// Useful for storing binary blobs alongside strings. Retrieve
298    /// with `get_bytes`; `get` will reject non-UTF-8 with
299    /// `InvalidUtf8`.
300    pub fn intern_bytes(&self, bytes: &[u8]) -> Result<StringRef, ArenaError> {
301        if !self.mmap.is_writable() {
302            return Err(ArenaError::ReadOnly);
303        }
304        let len = bytes.len() as u64;
305        if len > self.capacity_bytes as u64 {
306            self.ring_sidecar
307                .push_op(crate::sidecar_ops::string_arena::OP_INTERN, 1);
308            return Err(ArenaError::Full);
309        }
310        let offset = self.header().used_bytes.fetch_add(len, Ordering::AcqRel);
311        if offset.saturating_add(len) > self.capacity_bytes as u64 {
312            self.header().used_bytes.fetch_sub(len, Ordering::AcqRel);
313            self.ring_sidecar
314                .push_op(crate::sidecar_ops::string_arena::OP_INTERN, 1);
315            return Err(ArenaError::Full);
316        }
317        let dst = unsafe {
318            self.mmap.as_ptr()
319                .add(size_of::<ArenaHeader>())
320                .add(offset as usize)
321                as *mut u8
322        };
323        unsafe {
324            std::ptr::copy_nonoverlapping(bytes.as_ptr(), dst, bytes.len());
325        }
326        self.ring_sidecar
327            .push_op(crate::sidecar_ops::string_arena::OP_INTERN, 0);
328        Ok(StringRef { offset: offset as u32, len: len as u32 })
329    }
330
331    /// Resolve a StringRef to its `&[u8]`. Returns `Err(InvalidRef)`
332    /// when the ref doesn't fall inside the arena's used region.
333    pub fn get_bytes(&self, r: StringRef) -> Result<&[u8], ArenaError> {
334        let end = (r.offset as u64).saturating_add(r.len as u64);
335        if end > self.header().used_bytes.load(Ordering::Acquire) {
336            self.ring_sidecar
337                .push_op(crate::sidecar_ops::string_arena::OP_GET_BYTES, 1);
338            return Err(ArenaError::InvalidRef);
339        }
340        if end > self.capacity_bytes as u64 {
341            self.ring_sidecar
342                .push_op(crate::sidecar_ops::string_arena::OP_GET_BYTES, 1);
343            return Err(ArenaError::InvalidRef);
344        }
345        self.ring_sidecar
346            .push_op(crate::sidecar_ops::string_arena::OP_GET_BYTES, 0);
347        let base = unsafe {
348            self.mmap.as_ptr()
349                .add(size_of::<ArenaHeader>())
350                .add(r.offset as usize)
351        };
352        Ok(unsafe { std::slice::from_raw_parts(base, r.len as usize) })
353    }
354
355    /// Resolve a StringRef to a `&str`. Returns `Err(InvalidUtf8)`
356    /// when the bytes aren't valid UTF-8 (the arena doesn't enforce
357    /// validity per-segment; it's checked on read).
358    pub fn get(&self, r: StringRef) -> Result<&str, ArenaError> {
359        let bytes = self.get_bytes(r)?;
360        std::str::from_utf8(bytes).map_err(|_| ArenaError::InvalidUtf8)
361    }
362
363    /// Convenience: intern AND return a `&str` view into the
364    /// just-written bytes plus the ref.
365    pub fn intern_and_get(&self, s: &str) -> Result<(StringRef, &str), ArenaError> {
366        let r = self.intern(s)?;
367        let got = self.get(r)?;
368        Ok((r, got))
369    }
370
371    /// Reset the arena to empty. NOT concurrency-safe; callers must
372    /// ensure no other threads/processes are interning or reading.
373    /// Existing StringRefs become invalid (their bytes may be
374    /// overwritten by subsequent interns).
375    pub fn clear(&self) {
376        if !self.mmap.is_writable() {
377            return;
378        }
379        self.header().used_bytes.store(0, Ordering::Release);
380        self.ring_sidecar
381            .push_op(crate::sidecar_ops::string_arena::OP_CLEAR, 0);
382    }
383
384    pub fn flush(&self) -> Result<(), ArenaError> {
385        self.mmap.flush()?;
386        Ok(())
387    }
388
389    /// Non-blocking flush: schedules a writeback via the OS.
390    /// Note: Windows is only partially async (sync to page cache,
391    /// not to disk).
392    pub fn flush_async(&self) -> Result<(), ArenaError> {
393        self.mmap.flush_async()?;
394        Ok(())
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use std::sync::Arc;
402    use std::thread;
403
404    fn tmp(name: &str) -> std::path::PathBuf {
405        let mut p = std::env::temp_dir();
406        let pid = std::process::id();
407        p.push(format!("subetha-arena-{name}-{pid}.bin"));
408        p
409    }
410
411    #[test]
412    fn a_read_only_arena_resolves_refs_and_refuses_interning() {
413        let p = tmp("readonly");
414        let r = {
415            let w = SharedStringArena::create(&p, 4096).unwrap();
416            let r = w.intern("notepad.exe").unwrap();
417            w.flush().unwrap();
418            r
419        };
420        let ro = SharedStringArena::open_read_only(&p, 4096).unwrap();
421        assert!(!ro.is_writable());
422        assert_eq!(ro.get(r), Ok("notepad.exe"));
423        assert_eq!(ro.get_bytes(r), Ok(&b"notepad.exe"[..]));
424        assert_eq!(ro.used_bytes(), 11);
425        assert_eq!(ro.intern("more"), Err(ArenaError::ReadOnly));
426        assert_eq!(ro.intern_bytes(b"more"), Err(ArenaError::ReadOnly));
427        ro.clear();
428        assert_eq!(ro.used_bytes(), 11, "clear on a read-only arena is inert");
429        ro.flush().unwrap();
430        std::fs::remove_file(&p).ok();
431    }
432
433    #[test]
434    fn a_read_only_open_still_validates_the_header() {
435        let p = tmp("readonly-mismatch");
436        {
437            let w = SharedStringArena::create(&p, 4096).unwrap();
438            w.intern("x").unwrap();
439            w.flush().unwrap();
440        }
441        assert_eq!(
442            SharedStringArena::open_read_only(&p, 2048).err(),
443            Some(ArenaError::LayoutMismatch)
444        );
445        std::fs::remove_file(&p).ok();
446    }
447
448    #[test]
449    fn create_initial_state_is_empty() {
450        let p = tmp("init");
451        let a = SharedStringArena::create(&p, 1024).unwrap();
452        assert_eq!(a.capacity_bytes(), 1024);
453        assert_eq!(a.used_bytes(), 0);
454        assert_eq!(a.remaining_bytes(), 1024);
455        std::fs::remove_file(&p).ok();
456    }
457
458    #[test]
459    fn intern_and_get_round_trip() {
460        let p = tmp("rt");
461        let a = SharedStringArena::create(&p, 1024).unwrap();
462        let r1 = a.intern("hello").unwrap();
463        let r2 = a.intern("world").unwrap();
464        assert_eq!(a.get(r1).unwrap(), "hello");
465        assert_eq!(a.get(r2).unwrap(), "world");
466        assert_eq!(a.used_bytes(), 10);
467        std::fs::remove_file(&p).ok();
468    }
469
470    #[test]
471    fn empty_string_interns_with_zero_len() {
472        let p = tmp("empty");
473        let a = SharedStringArena::create(&p, 16).unwrap();
474        let r = a.intern("").unwrap();
475        assert_eq!(r.len, 0);
476        assert_eq!(a.get(r).unwrap(), "");
477        assert_eq!(a.used_bytes(), 0);
478        std::fs::remove_file(&p).ok();
479    }
480
481    #[test]
482    fn full_arena_returns_error() {
483        let p = tmp("full");
484        let a = SharedStringArena::create(&p, 10).unwrap();
485        a.intern("hello").unwrap();
486        a.intern("world").unwrap();
487        assert_eq!(a.intern("more").err(), Some(ArenaError::Full));
488        // Used bytes rolled back, not 14.
489        assert_eq!(a.used_bytes(), 10);
490        std::fs::remove_file(&p).ok();
491    }
492
493    #[test]
494    fn string_too_large_returns_full() {
495        let p = tmp("too-large");
496        let a = SharedStringArena::create(&p, 8).unwrap();
497        let big = "x".repeat(100);
498        assert_eq!(a.intern(&big).err(), Some(ArenaError::Full));
499        assert_eq!(a.used_bytes(), 0);
500        std::fs::remove_file(&p).ok();
501    }
502
503    #[test]
504    fn string_ref_packs_and_unpacks() {
505        let r = StringRef { offset: 0x1234_5678, len: 42 };
506        let packed = r.to_u64();
507        let unpacked = StringRef::from_u64(packed);
508        assert_eq!(unpacked, r);
509    }
510
511    #[test]
512    fn cross_handle_visibility() {
513        let p = tmp("cross-handle");
514        let writer = SharedStringArena::create(&p, 1024).unwrap();
515        let reader = SharedStringArena::open(&p, 1024).unwrap();
516        let r = writer.intern("cross-process").unwrap();
517        assert_eq!(reader.get(r).unwrap(), "cross-process");
518        std::fs::remove_file(&p).ok();
519    }
520
521    #[test]
522    fn invalid_ref_beyond_used_rejected() {
523        let p = tmp("invalid");
524        let a = SharedStringArena::create(&p, 1024).unwrap();
525        a.intern("hi").unwrap();  // used = 2
526        let bad = StringRef { offset: 100, len: 5 };
527        assert_eq!(a.get(bad).err(), Some(ArenaError::InvalidRef));
528        std::fs::remove_file(&p).ok();
529    }
530
531    #[test]
532    fn concurrent_interners_get_distinct_refs() {
533        let p = tmp("concurrent");
534        let a: Arc<SharedStringArena> = Arc::new(SharedStringArena::create(&p, 4096).unwrap());
535        let n_threads = 4;
536        let per_thread = 20;
537        let mut handles = vec![];
538        for t in 0..n_threads {
539            let a = a.clone();
540            handles.push(thread::spawn(move || {
541                let mut refs = vec![];
542                for i in 0..per_thread {
543                    let s = format!("thread-{t}-msg-{i:03}");
544                    let r = a.intern(&s).unwrap();
545                    refs.push((s, r));
546                }
547                refs
548            }));
549        }
550        let all: Vec<(String, StringRef)> = handles.into_iter()
551            .flat_map(|h| h.join().unwrap())
552            .collect();
553        // Every interned string must read back to its original value.
554        for (expected, r) in &all {
555            let got = a.get(*r).unwrap();
556            assert_eq!(got, expected,
557                "ref offset={} len={} should resolve to {expected}",
558                r.offset, r.len);
559        }
560        // No two refs overlap.
561        let mut refs: Vec<StringRef> = all.iter().map(|(_, r)| *r).collect();
562        refs.sort_by_key(|r| r.offset);
563        for w in refs.windows(2) {
564            let r1_end = w[0].offset + w[0].len;
565            assert!(r1_end <= w[1].offset,
566                "ref {:?} overlaps with ref {:?}", w[0], w[1]);
567        }
568        std::fs::remove_file(&p).ok();
569    }
570
571    #[test]
572    fn intern_and_get_helper_returns_both() {
573        let p = tmp("intern-and-get");
574        let a = SharedStringArena::create(&p, 1024).unwrap();
575        let (r, s) = a.intern_and_get("composite").unwrap();
576        assert_eq!(s, "composite");
577        assert_eq!(a.get(r).unwrap(), "composite");
578        std::fs::remove_file(&p).ok();
579    }
580
581    #[test]
582    fn utf8_validation_on_get() {
583        let p = tmp("utf8");
584        let a = SharedStringArena::create(&p, 128).unwrap();
585        // Intern valid UTF-8.
586        let r = a.intern("hello").unwrap();
587        assert!(a.get(r).is_ok());
588        // intern_bytes accepts arbitrary bytes; get() then rejects
589        // non-UTF-8 with InvalidUtf8 while get_bytes returns the raw
590        // bytes without validation.
591        let r2 = a.intern_bytes(&[0xFF, 0xFE, 0xFD]).unwrap();
592        assert_eq!(a.get(r2).err(), Some(ArenaError::InvalidUtf8));
593        assert_eq!(a.get_bytes(r2).unwrap(), &[0xFF, 0xFE, 0xFD]);
594        std::fs::remove_file(&p).ok();
595    }
596
597    #[test]
598    fn clear_resets_used_bytes() {
599        let p = tmp("clear");
600        let a = SharedStringArena::create(&p, 128).unwrap();
601        a.intern("first").unwrap();
602        a.intern("second").unwrap();
603        assert!(a.used_bytes() > 0);
604        a.clear();
605        assert_eq!(a.used_bytes(), 0);
606        // Fresh interns work.
607        let r = a.intern("after-clear").unwrap();
608        assert_eq!(a.get(r).unwrap(), "after-clear");
609        assert_eq!(r.offset, 0);
610        std::fs::remove_file(&p).ok();
611    }
612
613    #[test]
614    fn disk_persistence_survives_reopen() {
615        let p = tmp("disk");
616        let r_persist;
617        {
618            let a = SharedStringArena::create(&p, 1024).unwrap();
619            r_persist = a.intern("persisted-string").unwrap();
620            a.flush().unwrap();
621        }
622        let a2 = SharedStringArena::open(&p, 1024).unwrap();
623        assert_eq!(a2.get(r_persist).unwrap(), "persisted-string");
624        // And it can keep interning.
625        let r2 = a2.intern("more-after-reopen").unwrap();
626        assert_eq!(a2.get(r2).unwrap(), "more-after-reopen");
627        std::fs::remove_file(&p).ok();
628    }
629
630    #[test]
631    fn deduplication_via_hashmap_composition() {
632        // Demonstrate the dedup pattern: layer SharedHashMap<u64, u64>
633        // (hash -> StringRef.to_u64) over the arena.
634        use crate::SharedHashMap;
635        use crate::shared_hash_map::fnv1a_64;
636
637        let p_arena = tmp("dedup-arena");
638        let p_index = tmp("dedup-index");
639        let arena = SharedStringArena::create(&p_arena, 256).unwrap();
640        let index: SharedHashMap<u64, u64> = SharedHashMap::create(&p_index, 32).unwrap();
641
642        let s = "deduplicate-me";
643        let h = fnv1a_64(s.as_bytes());
644
645        // First intern: check index, miss, intern + insert into index.
646        let r = if let Some(packed) = index.get(&h) {
647            StringRef::from_u64(packed)
648        } else {
649            let r = arena.intern(s).unwrap();
650            index.insert(h, r.to_u64()).unwrap();
651            r
652        };
653        let used_after_first = arena.used_bytes();
654
655        // Second intern of same string: hit in index, no arena append.
656        let r2 = if let Some(packed) = index.get(&h) {
657            StringRef::from_u64(packed)
658        } else {
659            let r = arena.intern(s).unwrap();
660            index.insert(h, r.to_u64()).unwrap();
661            r
662        };
663        assert_eq!(r, r2, "dedup should return the same ref");
664        assert_eq!(arena.used_bytes(), used_after_first,
665            "second intern should not consume more bytes");
666
667        std::fs::remove_file(&p_arena).ok();
668        std::fs::remove_file(&p_index).ok();
669    }
670}