1use 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
72enum 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 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#[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 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 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 #[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 pub fn intern(&self, s: &str) -> Result<StringRef, ArenaError> {
293 self.intern_bytes(s.as_bytes())
294 }
295
296 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 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 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 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 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 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 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(); 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 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 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 let r = a.intern("hello").unwrap();
587 assert!(a.get(r).is_ok());
588 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 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 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 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 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 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}