Skip to main content

nedb_engine/
segment.rs

1// SPDX-FileCopyrightText: 2026 INTERCHAINED LLC
2// SPDX-License-Identifier: BUSL-1.1
3// NEDB · © 2026 INTERCHAINED LLC × Eth-Interchained × Vex (Claude Opus 5)
4
5//! segment.rs — NEDB v3 packed object substrate.
6//!
7//! v3 keeps the v2 logical model intact (content-addressed, immutable,
8//! BLAKE2b-verified DAG nodes) and changes only *where the bytes live*: instead
9//! of one filesystem object per node — which caps throughput at the OS
10//! small-file metadata rate — many immutable objects are appended into
11//! **segment files** addressed through an in-memory `hash -> (segment, offset,
12//! len)` index. The hash is still `BLAKE2b(content)` and is re-verified on every
13//! read, so content-addressing and tamper-evidence are unchanged.
14//!
15//! This module knows nothing about `Node`, JSON, or encryption: callers
16//! (`ObjectStore`) pass already-serialized/encrypted `content` bytes and the
17//! precomputed hash. That keeps the segment store a pure content<->location
18//! layer and leaves all crypto/serialization in `store.rs`.
19//!
20//! Phases:
21//!   1. Segment append + in-memory index + startup scan + tail-truncation.
22//!   2. Compaction/pruning — rewrite the live object set into fresh segments,
23//!      reclaiming dead (superseded/spent) records. `compact(live)`.
24//!   3. On-disk `.idx` sidecars — a sealed segment gets a checksummed
25//!      `hash -> (offset,len)` index file so cold start loads it instead of
26//!      rescanning every record. Missing/corrupt `.idx` falls back to a scan.
27//!
28//! Opt-in only: `ObjectStore` instantiates this when `NEDB_DAG_V3` is set
29//! (surfaced as the `--dag-v3` flag). Default storage is byte-for-byte v2.
30
31use std::collections::HashSet;
32use std::fs::{self, File, OpenOptions};
33use std::io::{Read, Seek, SeekFrom, Write};
34use std::path::{Path, PathBuf};
35use std::sync::{Arc, Mutex};
36
37use anyhow::{bail, Context, Result};
38use blake2::{Blake2b512, Digest};
39use dashmap::DashMap;
40
41/// Default segment rollover size (256 MiB).
42const DEFAULT_MAX_SEGMENT_BYTES: u64 = 256 * 1024 * 1024;
43
44/// Magic prefix for `.idx` sidecar files (NEDB v3 index, format 1).
45const IDX_MAGIC: &[u8; 4] = b"NIX1";
46/// `.idx` on-disk entry: 32-byte raw digest + u64 offset + u32 len.
47const IDX_ENTRY_BYTES: usize = 32 + 8 + 4;
48/// `.idx` header: magic(4) + count(8). Trailer: blake2b-256 checksum (32).
49const IDX_HEADER_BYTES: usize = 4 + 8;
50const IDX_CHECKSUM_BYTES: usize = 32;
51
52/// Location of one content record inside the segment set.
53#[derive(Clone, Copy, Debug)]
54struct SegmentLocation {
55    segment_id: u32,
56    /// Byte offset of the CONTENT (immediately after the u32 length prefix).
57    offset: u64,
58    len: u32,
59}
60
61/// Result of a `compact()` pass.
62#[derive(Clone, Copy, Debug, Default)]
63pub struct CompactStats {
64    /// Live objects copied forward into fresh segments.
65    pub live_objects: usize,
66    /// Dead objects dropped (superseded versions / pruned history).
67    pub dropped_objects: usize,
68    /// Bytes reclaimed by deleting the old segment files.
69    pub bytes_reclaimed: u64,
70    /// Number of segment files after compaction.
71    pub segments_after: usize,
72}
73
74/// BLAKE2b-256 (first 32 bytes of Blake2b-512) raw digest.
75fn blake2b_raw(data: &[u8]) -> [u8; 32] {
76    let mut h = Blake2b512::new();
77    h.update(data);
78    let out = h.finalize();
79    let mut a = [0u8; 32];
80    a.copy_from_slice(&out[..32]);
81    a
82}
83
84/// Hex-encoded BLAKE2b-256. MUST match `store::blake2b` so segment hashes equal
85/// loose-object hashes.
86fn blake2b(data: &[u8]) -> String {
87    hex::encode(blake2b_raw(data))
88}
89
90/// Positional read at an explicit offset — no shared-cursor state, so one
91/// handle serves any number of concurrent readers without locking.
92#[cfg(unix)]
93fn read_at(f: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
94    use std::os::unix::fs::FileExt;
95    f.read_exact_at(buf, offset)
96}
97
98/// Windows: `seek_read` takes an explicit offset per call (ReadFile with an
99/// OVERLAPPED offset). It may return short reads, so loop to fill the buffer.
100/// It does move that handle's file pointer — harmless here, because cached
101/// read handles are used exclusively through this function (every call passes
102/// its own absolute offset) and the appender writes through a different
103/// handle with its own cursor.
104#[cfg(windows)]
105fn read_at(f: &File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
106    use std::os::windows::fs::FileExt;
107    let mut done = 0usize;
108    while done < buf.len() {
109        let n = f.seek_read(&mut buf[done..], offset + done as u64)?;
110        if n == 0 {
111            return Err(std::io::Error::new(
112                std::io::ErrorKind::UnexpectedEof,
113                "eof mid-record in segment read",
114            ));
115        }
116        done += n;
117    }
118    Ok(())
119}
120
121/// The currently-appended-to segment.
122struct Active {
123    id: u32,
124    file: File,
125    /// End-of-file = next append position (kept in sync with the file cursor).
126    offset: u64,
127}
128
129/// Append-only, content-addressed packed object store with an in-memory index.
130pub struct SegmentStore {
131    dir: PathBuf,
132    index: DashMap<String, SegmentLocation>,
133    active: Mutex<Active>,
134    max_segment_bytes: u64,
135    /// macOS fast-fsync opt-in (from `NEDB_FAST_FSYNC`). When true, durability
136    /// points use a plain `fsync(2)` instead of std's `sync_all` (= F_FULLFSYNC
137    /// on macOS). Off by default → identical, full-durability behavior.
138    fast_fsync: bool,
139    /// Read-handle cache: one shared, read-only `File` per segment. Reads go
140    /// through positional I/O (`pread` on Unix, `seek_read` on Windows) with an
141    /// explicit offset per call, so a single handle serves any number of threads
142    /// with no cursor state and no lock. Before this cache every `get()` paid
143    /// `open + seek + read + close` — 3-4 syscalls and fd churn per point read,
144    /// on the path itcd's -dagv3 chainstate reads live on (and `CreateFile` is
145    /// the expensive one on Windows). These handles are NEVER used for writes:
146    /// the active segment's writer keeps its own separate handle (own cursor)
147    /// behind the `active` mutex. Cleared on `compact()` — old segment ids are
148    /// never referenced again after the index swap.
149    read_handles: DashMap<u32, Arc<File>>,
150}
151
152/// Durability point for a segment file.
153///
154/// Default (`fast = false`) is `File::sync_all()` everywhere — the safe,
155/// power-loss-durable choice. On macOS that maps to `F_FULLFSYNC` (a full
156/// hardware-cache flush to platter), which is often 10-100x slower than the
157/// plain `fsync(2)` Linux/Windows already use, especially on Fusion/SATA disks.
158///
159/// With `fast = true` (set via `NEDB_FAST_FSYNC`), macOS uses plain `fsync(2)`:
160/// the write reaches the drive (crash-safe) but the drive may still hold it in a
161/// volatile cache, so a sudden power cut could lose the last unsynced batch. NEDB
162/// v3 tolerates that — content-addressed objects, torn-tail truncation on open,
163/// and a reconstructible chainstate (re-sync from peers) — which is exactly why
164/// Bitcoin Core itself uses plain fsync for LevelDB on macOS. On non-macOS the
165/// flag is a no-op (`sync_all` is already a plain fsync / FlushFileBuffers).
166fn durable_sync(file: &File, fast: bool) -> std::io::Result<()> {
167    #[cfg(target_os = "macos")]
168    if fast {
169        use std::os::unix::io::AsRawFd;
170        // SAFETY: `file` is a valid, open descriptor for the duration of the call.
171        let rc = unsafe { libc::fsync(file.as_raw_fd()) };
172        return if rc == 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) };
173    }
174    #[cfg(not(target_os = "macos"))]
175    let _ = fast;
176    file.sync_all()
177}
178
179impl SegmentStore {
180    fn seg_path(dir: &Path, id: u32) -> PathBuf {
181        dir.join(format!("seg-{:06}.dat", id))
182    }
183    fn idx_path(dir: &Path, id: u32) -> PathBuf {
184        dir.join(format!("seg-{:06}.idx", id))
185    }
186
187    /// Open (or create) the segment store under `{objects_root}/segments`.
188    pub fn open(objects_root: &Path) -> Result<Self> {
189        Self::open_with_max(objects_root, DEFAULT_MAX_SEGMENT_BYTES)
190    }
191
192    /// Like `open`, with an explicit rollover size (used by tests).
193    pub fn open_with_max(objects_root: &Path, max_segment_bytes: u64) -> Result<Self> {
194        let dir = objects_root.join("segments");
195        fs::create_dir_all(&dir).context("create objects/segments dir")?;
196
197        // Discover existing segment ids.
198        let mut ids: Vec<u32> = Vec::new();
199        for entry in fs::read_dir(&dir).context("read segments dir")? {
200            let entry = entry?;
201            let name = entry.file_name().to_string_lossy().to_string();
202            if let Some(rest) = name.strip_prefix("seg-") {
203                if let Some(num) = rest.strip_suffix(".dat") {
204                    if let Ok(id) = num.parse::<u32>() {
205                        ids.push(id);
206                    }
207                }
208            }
209        }
210        ids.sort_unstable();
211
212        let index: DashMap<String, SegmentLocation> = DashMap::new();
213        let mut active_id: u32 = 0;
214        let mut active_end: u64 = 0;
215
216        for (pos, &id) in ids.iter().enumerate() {
217            let is_last = pos + 1 == ids.len();
218            if is_last {
219                // Active (last) segment: always scan (no .idx — it's still
220                // mutable) and truncate any torn tail from a crash.
221                let (valid_end, entries) = Self::scan_segment(&dir, id)?;
222                for (h, o, l) in entries {
223                    index.insert(h, SegmentLocation { segment_id: id, offset: o, len: l });
224                }
225                let path = Self::seg_path(&dir, id);
226                let file_len = fs::metadata(&path)?.len();
227                if valid_end < file_len {
228                    let f = OpenOptions::new().write(true).open(&path)?;
229                    f.set_len(valid_end)?;
230                }
231                active_id = id;
232                active_end = valid_end;
233            } else {
234                // Sealed segment: load its checksummed .idx if present+valid;
235                // otherwise scan it and heal by writing a fresh .idx.
236                match Self::load_idx(&dir, id) {
237                    Ok(Some(entries)) => {
238                        for (h, o, l) in entries {
239                            index.insert(h, SegmentLocation { segment_id: id, offset: o, len: l });
240                        }
241                    }
242                    _ => {
243                        let (_ve, entries) = Self::scan_segment(&dir, id)?;
244                        for (h, o, l) in &entries {
245                            index.insert(h.clone(), SegmentLocation { segment_id: id, offset: *o, len: *l });
246                        }
247                        let _ = Self::write_idx(&dir, id, &entries); // best-effort heal
248                    }
249                }
250            }
251        }
252
253        // Open (creating if necessary) the active segment for appending.
254        let active_path = Self::seg_path(&dir, active_id);
255        let mut file = OpenOptions::new()
256            .create(true)
257            .read(true)
258            .write(true)
259            .open(&active_path)
260            .with_context(|| format!("open active segment {:?}", active_path))?;
261        file.seek(SeekFrom::Start(active_end))?;
262
263        let fast_fsync = std::env::var("NEDB_FAST_FSYNC")
264            .map(|v| {
265                let v = v.trim();
266                v == "1" || v.eq_ignore_ascii_case("true")
267                         || v.eq_ignore_ascii_case("on")
268                         || v.eq_ignore_ascii_case("yes")
269            })
270            .unwrap_or(false);
271
272        Ok(Self {
273            dir,
274            index,
275            active: Mutex::new(Active { id: active_id, file, offset: active_end }),
276            max_segment_bytes,
277            fast_fsync,
278            read_handles: DashMap::new(),
279        })
280    }
281
282    /// Scan one segment, returning (valid_end_offset, records). Records past a
283    /// torn tail are not returned; `valid_end` marks where to truncate.
284    fn scan_segment(dir: &Path, id: u32) -> Result<(u64, Vec<(String, u64, u32)>)> {
285        let path = Self::seg_path(dir, id);
286        let mut f = match File::open(&path) {
287            Ok(f) => f,
288            Err(_) => return Ok((0, Vec::new())),
289        };
290        let file_len = f.metadata()?.len();
291        let mut pos: u64 = 0;
292        let mut entries: Vec<(String, u64, u32)> = Vec::new();
293        loop {
294            if pos + 4 > file_len {
295                break; // no room for a length prefix → torn tail
296            }
297            f.seek(SeekFrom::Start(pos))?;
298            let mut len_buf = [0u8; 4];
299            if f.read_exact(&mut len_buf).is_err() {
300                break;
301            }
302            let len = u32::from_le_bytes(len_buf);
303            let content_off = pos + 4;
304            if content_off + (len as u64) > file_len {
305                break; // declared length overruns EOF → torn content
306            }
307            let mut content = vec![0u8; len as usize];
308            if f.read_exact(&mut content).is_err() {
309                break;
310            }
311            entries.push((blake2b(&content), content_off, len));
312            pos = content_off + len as u64;
313        }
314        Ok((pos, entries))
315    }
316
317    /// Get (or open and cache) the shared read-only handle for a segment.
318    /// A double-open race between two missing threads is harmless: `or_insert`
319    /// keeps the first handle and the loser's `File` is simply dropped (closed).
320    fn read_handle(&self, id: u32) -> Result<Arc<File>> {
321        if let Some(h) = self.read_handles.get(&id) {
322            return Ok(Arc::clone(h.value()));
323        }
324        let path = Self::seg_path(&self.dir, id);
325        let f = Arc::new(File::open(&path).with_context(|| format!("open segment {:?}", path))?);
326        Ok(Arc::clone(self.read_handles.entry(id).or_insert(f).value()))
327    }
328
329    /// Read+verify the raw content at a location through the shared handle
330    /// cache. Positional reads only — no seek, no cursor, no per-read open.
331    /// Errors on tamper. Safe against a concurrent appender on the active
332    /// segment: index entries are inserted only AFTER `write_all` returns, and
333    /// read-after-write through a second handle of the same file is coherent
334    /// through the page cache on both Unix and Windows.
335    fn read_content(&self, loc: &SegmentLocation, expect_hash: &str) -> Result<Vec<u8>> {
336        let f = self.read_handle(loc.segment_id)?;
337        let mut content = vec![0u8; loc.len as usize];
338        read_at(&f, &mut content, loc.offset)
339            .with_context(|| format!("read record from segment {}", loc.segment_id))?;
340        let actual = blake2b(&content);
341        if actual != expect_hash {
342            bail!("segment object {} tampered: recomputed {}", expect_hash, actual);
343        }
344        Ok(content)
345    }
346
347    // ── Phase 3: .idx sidecars ────────────────────────────────────────────────
348
349    /// Write a checksummed `.idx` for a SEALED segment (atomic via tmp→rename).
350    /// Best-effort: a failure just means the next open scans the segment.
351    fn write_idx(dir: &Path, id: u32, entries: &[(String, u64, u32)]) -> Result<()> {
352        let mut body: Vec<u8> = Vec::with_capacity(IDX_HEADER_BYTES + entries.len() * IDX_ENTRY_BYTES);
353        body.extend_from_slice(IDX_MAGIC);
354        body.extend_from_slice(&(entries.len() as u64).to_le_bytes());
355        for (hash, off, len) in entries {
356            let raw = hex::decode(hash).map_err(|_| anyhow::anyhow!("bad hash hex in idx write"))?;
357            if raw.len() != 32 {
358                bail!("idx write: hash not 32 bytes");
359            }
360            body.extend_from_slice(&raw);
361            body.extend_from_slice(&off.to_le_bytes());
362            body.extend_from_slice(&len.to_le_bytes());
363        }
364        let checksum = blake2b_raw(&body);
365        body.extend_from_slice(&checksum);
366
367        let path = Self::idx_path(dir, id);
368        let tmp = path.with_extension("idx.tmp");
369        fs::write(&tmp, &body)?;
370        fs::rename(&tmp, &path)?;
371        Ok(())
372    }
373
374    /// Load a `.idx` if present and checksum-valid. Returns Ok(None) if absent
375    /// or in any way unusable (caller then scans the segment).
376    fn load_idx(dir: &Path, id: u32) -> Result<Option<Vec<(String, u64, u32)>>> {
377        let path = Self::idx_path(dir, id);
378        let data = match fs::read(&path) {
379            Ok(d) => d,
380            Err(_) => return Ok(None),
381        };
382        if data.len() < IDX_HEADER_BYTES + IDX_CHECKSUM_BYTES {
383            return Ok(None);
384        }
385        if &data[0..4] != IDX_MAGIC {
386            return Ok(None);
387        }
388        let count = u64::from_le_bytes(data[4..12].try_into().unwrap()) as usize;
389        let expected = IDX_HEADER_BYTES + count * IDX_ENTRY_BYTES + IDX_CHECKSUM_BYTES;
390        if data.len() != expected {
391            return Ok(None);
392        }
393        let body = &data[..data.len() - IDX_CHECKSUM_BYTES];
394        let stored: [u8; 32] = match data[data.len() - IDX_CHECKSUM_BYTES..].try_into() {
395            Ok(a) => a,
396            Err(_) => return Ok(None),
397        };
398        if blake2b_raw(body) != stored {
399            return Ok(None); // corrupt/stale → fall back to scan
400        }
401        let mut entries = Vec::with_capacity(count);
402        let mut p = IDX_HEADER_BYTES;
403        for _ in 0..count {
404            let hash = hex::encode(&data[p..p + 32]);
405            let off = u64::from_le_bytes(data[p + 32..p + 40].try_into().unwrap());
406            let len = u32::from_le_bytes(data[p + 40..p + 44].try_into().unwrap());
407            entries.push((hash, off, len));
408            p += IDX_ENTRY_BYTES;
409        }
410        Ok(Some(entries))
411    }
412
413    /// Collect the index entries belonging to one segment (for sealing → .idx).
414    fn entries_for_segment(&self, id: u32) -> Vec<(String, u64, u32)> {
415        self.index
416            .iter()
417            .filter(|e| e.value().segment_id == id)
418            .map(|e| (e.key().clone(), e.value().offset, e.value().len))
419            .collect()
420    }
421
422    // ── core API ──────────────────────────────────────────────────────────────
423
424    /// True if this hash is already stored in a segment.
425    pub fn contains(&self, hash: &str) -> bool {
426        self.index.contains_key(hash)
427    }
428
429    /// Append `content` under `hash` (idempotent). `hash` must equal
430    /// `BLAKE2b(content)`; the caller computes it (parallel, outside the lock).
431    pub fn put(&self, hash: &str, content: &[u8]) -> Result<()> {
432        if self.index.contains_key(hash) {
433            return Ok(());
434        }
435        let len = content.len() as u32;
436        let record_size = 4u64 + content.len() as u64;
437
438        let mut active = self.active.lock().unwrap();
439        if self.index.contains_key(hash) {
440            return Ok(());
441        }
442
443        // Roll over if this record would push the active segment past the cap.
444        if active.offset > 0 && active.offset + record_size > self.max_segment_bytes {
445            let _ = active.file.flush();
446            let _ = durable_sync(&active.file, self.fast_fsync);
447            // Seal: write the .idx for the segment we're leaving behind.
448            let sealed_id = active.id;
449            let entries = self.entries_for_segment(sealed_id);
450            let _ = Self::write_idx(&self.dir, sealed_id, &entries);
451            let next_id = sealed_id + 1;
452            let path = Self::seg_path(&self.dir, next_id);
453            let file = OpenOptions::new()
454                .create(true)
455                .read(true)
456                .write(true)
457                .open(&path)
458                .with_context(|| format!("open new segment {:?}", path))?;
459            *active = Active { id: next_id, file, offset: 0 };
460        }
461
462        let content_off = active.offset + 4;
463        let mut rec = Vec::with_capacity(4 + content.len());
464        rec.extend_from_slice(&len.to_le_bytes());
465        rec.extend_from_slice(content);
466        active.file.write_all(&rec)?;
467
468        let seg_id = active.id;
469        active.offset += record_size;
470        self.index.insert(
471            hash.to_string(),
472            SegmentLocation { segment_id: seg_id, offset: content_off, len },
473        );
474        Ok(())
475    }
476
477    /// Read the raw content bytes for `hash`, or `None` if not stored in any
478    /// segment (caller then falls back to the loose-object path). Re-verifies.
479    pub fn get(&self, hash: &str) -> Result<Option<Vec<u8>>> {
480        let loc = match self.index.get(hash) {
481            Some(entry) => *entry.value(),
482            None => return Ok(None),
483        };
484        Ok(Some(self.read_content(&loc, hash)?))
485    }
486
487    /// All hashes currently stored in segments.
488    pub fn all_hashes(&self) -> Vec<String> {
489        self.index.iter().map(|e| e.key().clone()).collect()
490    }
491
492    /// Flush + fsync the active segment. One durability point per batch.
493    pub fn sync(&self) -> Result<()> {
494        let mut active = self.active.lock().unwrap();
495        let _ = active.file.flush();
496        durable_sync(&active.file, self.fast_fsync).context("fsync active segment")?;
497        Ok(())
498    }
499
500    // ── Phase 2: compaction / pruning ─────────────────────────────────────────
501
502    /// Rewrite the **live** object set into fresh segments and drop everything
503    /// else, reclaiming dead (superseded/spent/pruned) records.
504    ///
505    /// `live` is the set of hashes to KEEP — typically the current version of
506    /// every document (from the id-index). Hashes not in `live` are pruned, so
507    /// historical versions / AS OF / TRACE for dropped objects are discarded by
508    /// design (that is what reclaims space).
509    ///
510    /// Crash-safe: new segments are written + fsynced BEFORE any old segment is
511    /// deleted, so live data is never lost. A crash mid-compaction leaves both
512    /// the old and new segments (a re-open re-indexes the union — dead objects
513    /// merely linger until the next compaction); it never loses a live object.
514    ///
515    /// Must be called when the store is quiescent (no concurrent reads): writes
516    /// are blocked for the duration via the active lock, and the in-memory index
517    /// is swapped in place.
518    pub fn compact(&self, live: &HashSet<String>) -> Result<CompactStats> {
519        let mut active = self.active.lock().unwrap();
520
521        let total_before = self.index.len();
522        let old_max = active.id;
523        let new_base = old_max + 1;
524
525        // Snapshot the live entries to copy forward.
526        let to_copy: Vec<(String, SegmentLocation)> = self
527            .index
528            .iter()
529            .filter(|e| live.contains(e.key()))
530            .map(|e| (e.key().clone(), *e.value()))
531            .collect();
532
533        // Write live objects into fresh segments starting at new_base.
534        let new_index: DashMap<String, SegmentLocation> = DashMap::new();
535        let mut cur_id = new_base;
536        let mut cur_path = Self::seg_path(&self.dir, cur_id);
537        let mut cur_file = OpenOptions::new()
538            .create(true)
539            .truncate(true)
540            .read(true)
541            .write(true)
542            .open(&cur_path)
543            .with_context(|| format!("open compaction segment {:?}", cur_path))?;
544        let mut cur_off: u64 = 0;
545
546        for (hash, loc) in &to_copy {
547            let content = self.read_content(loc, hash)?;
548            let len = content.len() as u32;
549            let record_size = 4u64 + content.len() as u64;
550
551            if cur_off > 0 && cur_off + record_size > self.max_segment_bytes {
552                let _ = cur_file.flush();
553                durable_sync(&cur_file, self.fast_fsync).context("fsync sealed compaction segment")?;
554                let entries: Vec<(String, u64, u32)> = new_index
555                    .iter()
556                    .filter(|e| e.value().segment_id == cur_id)
557                    .map(|e| (e.key().clone(), e.value().offset, e.value().len))
558                    .collect();
559                let _ = Self::write_idx(&self.dir, cur_id, &entries);
560                cur_id += 1;
561                cur_path = Self::seg_path(&self.dir, cur_id);
562                cur_file = OpenOptions::new()
563                    .create(true)
564                    .truncate(true)
565                    .read(true)
566                    .write(true)
567                    .open(&cur_path)
568                    .with_context(|| format!("open compaction segment {:?}", cur_path))?;
569                cur_off = 0;
570            }
571
572            let content_off = cur_off + 4;
573            let mut rec = Vec::with_capacity(4 + content.len());
574            rec.extend_from_slice(&len.to_le_bytes());
575            rec.extend_from_slice(&content);
576            cur_file.write_all(&rec)?;
577            new_index.insert(hash.clone(), SegmentLocation { segment_id: cur_id, offset: content_off, len });
578            cur_off += record_size;
579        }
580        let _ = cur_file.flush();
581        durable_sync(&cur_file, self.fast_fsync).context("fsync active compaction segment")?;
582
583        // The last new segment becomes the active one (reuse its handle).
584        let live_objects = to_copy.len();
585
586        // Swap the in-memory index to the rebuilt one.
587        self.index.clear();
588        for e in new_index.iter() {
589            self.index.insert(e.key().clone(), *e.value());
590        }
591        *active = Active { id: cur_id, file: cur_file, offset: cur_off };
592
593        // Drop every cached read handle: old segment ids are never referenced
594        // again after the index swap (ids strictly increase), and releasing the
595        // handles frees their fds before the files are deleted below. (Deletion
596        // would succeed even with handles open — Unix unlink semantics; Rust's
597        // std opens with FILE_SHARE_DELETE on Windows — this is hygiene, not
598        // correctness.) Fresh handles for the new segments open lazily on the
599        // next read.
600        self.read_handles.clear();
601
602        // Delete every old segment (id < new_base) + its .idx, after the new
603        // ones are durable. Re-list so we only touch files that actually exist.
604        let mut bytes_reclaimed: u64 = 0;
605        if let Ok(rd) = fs::read_dir(&self.dir) {
606            for entry in rd.flatten() {
607                let name = entry.file_name().to_string_lossy().to_string();
608                let id_of = name
609                    .strip_prefix("seg-")
610                    .and_then(|r| r.strip_suffix(".dat").or_else(|| r.strip_suffix(".idx")))
611                    .and_then(|n| n.parse::<u32>().ok());
612                if let Some(id) = id_of {
613                    if id < new_base {
614                        if name.ends_with(".dat") {
615                            if let Ok(m) = entry.metadata() {
616                                bytes_reclaimed += m.len();
617                            }
618                        }
619                        let _ = fs::remove_file(entry.path());
620                    }
621                }
622            }
623        }
624
625        let segments_after = (cur_id - new_base + 1) as usize;
626        Ok(CompactStats {
627            live_objects,
628            dropped_objects: total_before.saturating_sub(live_objects),
629            bytes_reclaimed,
630            segments_after,
631        })
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638    use tempfile::tempdir;
639
640    fn put_get_hash(s: &SegmentStore, content: &[u8]) -> String {
641        let h = blake2b(content);
642        s.put(&h, content).unwrap();
643        h
644    }
645
646    #[test]
647    fn put_get_roundtrip() {
648        let dir = tempdir().unwrap();
649        let s = SegmentStore::open(dir.path()).unwrap();
650        let h = put_get_hash(&s, b"hello nedb v3");
651        assert_eq!(s.get(&h).unwrap().unwrap(), b"hello nedb v3");
652        assert!(s.contains(&h));
653        assert!(s.get(&"0".repeat(64)).unwrap().is_none());
654    }
655
656    #[test]
657    fn idempotent_put() {
658        let dir = tempdir().unwrap();
659        let s = SegmentStore::open(dir.path()).unwrap();
660        let h1 = put_get_hash(&s, b"dup");
661        let h2 = put_get_hash(&s, b"dup");
662        assert_eq!(h1, h2);
663        assert_eq!(s.all_hashes().len(), 1);
664    }
665
666    #[test]
667    fn index_rebuilt_on_reopen() {
668        let dir = tempdir().unwrap();
669        let h = {
670            let s = SegmentStore::open(dir.path()).unwrap();
671            let h = put_get_hash(&s, b"persisted");
672            s.sync().unwrap();
673            h
674        };
675        let s2 = SegmentStore::open(dir.path()).unwrap();
676        assert_eq!(s2.get(&h).unwrap().unwrap(), b"persisted");
677    }
678
679    #[test]
680    fn rollover_writes_idx_and_reopen_uses_it() {
681        let dir = tempdir().unwrap();
682        let s = SegmentStore::open_with_max(dir.path(), 32).unwrap();
683        let mut hashes = Vec::new();
684        for i in 0..8u32 {
685            hashes.push(put_get_hash(&s, format!("record-{}", i).as_bytes()));
686        }
687        s.sync().unwrap();
688        // Rollover should have produced sealed .idx sidecars.
689        let idx_files = fs::read_dir(dir.path().join("segments"))
690            .unwrap()
691            .flatten()
692            .filter(|e| e.file_name().to_string_lossy().ends_with(".idx"))
693            .count();
694        assert!(idx_files >= 1, "expected at least one sealed .idx");
695        // Reopen (loads sealed segments via .idx) — every object still reads.
696        let s2 = SegmentStore::open(dir.path()).unwrap();
697        for h in &hashes {
698            assert!(s2.get(h).unwrap().is_some());
699        }
700    }
701
702    #[test]
703    fn corrupt_idx_falls_back_to_scan() {
704        let dir = tempdir().unwrap();
705        let mut hashes = Vec::new();
706        {
707            let s = SegmentStore::open_with_max(dir.path(), 32).unwrap();
708            for i in 0..6u32 {
709                hashes.push(put_get_hash(&s, format!("rec-{}", i).as_bytes()));
710            }
711            s.sync().unwrap();
712        }
713        // Corrupt every .idx (truncate to garbage). Reopen must still work via scan.
714        for e in fs::read_dir(dir.path().join("segments")).unwrap().flatten() {
715            if e.file_name().to_string_lossy().ends_with(".idx") {
716                fs::write(e.path(), b"garbage").unwrap();
717            }
718        }
719        let s2 = SegmentStore::open(dir.path()).unwrap();
720        for h in &hashes {
721            assert!(s2.get(h).unwrap().is_some(), "scan fallback must recover the object");
722        }
723    }
724
725    #[test]
726    fn torn_tail_is_truncated_on_open() {
727        let dir = tempdir().unwrap();
728        let good = {
729            let s = SegmentStore::open(dir.path()).unwrap();
730            let h = put_get_hash(&s, b"good record");
731            s.sync().unwrap();
732            h
733        };
734        let seg = dir.path().join("segments").join("seg-000000.dat");
735        {
736            let mut f = OpenOptions::new().append(true).open(&seg).unwrap();
737            f.write_all(&9999u32.to_le_bytes()).unwrap();
738            f.write_all(b"short").unwrap();
739        }
740        let s2 = SegmentStore::open(dir.path()).unwrap();
741        assert_eq!(s2.get(&good).unwrap().unwrap(), b"good record");
742        let h2 = put_get_hash(&s2, b"after recovery");
743        assert!(s2.get(&h2).unwrap().is_some());
744    }
745
746    #[test]
747    fn tamper_detected_on_read() {
748        let dir = tempdir().unwrap();
749        let h = {
750            let s = SegmentStore::open(dir.path()).unwrap();
751            let h = put_get_hash(&s, b"authentic");
752            s.sync().unwrap();
753            h
754        };
755        let seg = dir.path().join("segments").join("seg-000000.dat");
756        let mut bytes = fs::read(&seg).unwrap();
757        let n = bytes.len();
758        bytes[n - 1] ^= 0xff;
759        fs::write(&seg, bytes).unwrap();
760        let s2 = SegmentStore::open(dir.path()).unwrap();
761        match s2.get(&h) {
762            Ok(None) => {}
763            Err(_) => {}
764            Ok(Some(_)) => panic!("tampered content must not verify under original hash"),
765        }
766    }
767
768    #[test]
769    fn compaction_keeps_live_drops_dead() {
770        let dir = tempdir().unwrap();
771        let s = SegmentStore::open(dir.path()).unwrap();
772        let keep = put_get_hash(&s, b"keep me");
773        let _drop1 = put_get_hash(&s, b"drop me 1");
774        let _drop2 = put_get_hash(&s, b"drop me 2");
775        s.sync().unwrap();
776        assert_eq!(s.all_hashes().len(), 3);
777
778        let mut live = HashSet::new();
779        live.insert(keep.clone());
780        let stats = s.compact(&live).unwrap();
781        assert_eq!(stats.live_objects, 1);
782        assert_eq!(stats.dropped_objects, 2);
783
784        // Live object survives; dead ones are gone.
785        assert_eq!(s.get(&keep).unwrap().unwrap(), b"keep me");
786        assert_eq!(s.all_hashes().len(), 1);
787
788        // And it survives a reopen (new segments + index swap persisted).
789        let s2 = SegmentStore::open(dir.path()).unwrap();
790        assert_eq!(s2.get(&keep).unwrap().unwrap(), b"keep me");
791        assert!(s2.get(&_drop1).unwrap().is_none());
792
793        // Writes still work after compaction.
794        let after = put_get_hash(&s, b"post-compaction");
795        assert!(s.get(&after).unwrap().is_some());
796    }
797
798    #[test]
799    fn compaction_reclaims_and_writes_still_read() {
800        let dir = tempdir().unwrap();
801        let s = SegmentStore::open_with_max(dir.path(), 64).unwrap();
802        let mut all = Vec::new();
803        for i in 0..20u32 {
804            all.push(put_get_hash(&s, format!("obj-{:03}", i).as_bytes()));
805        }
806        s.sync().unwrap();
807        // Keep only the even-indexed ones.
808        let mut live = HashSet::new();
809        for (i, h) in all.iter().enumerate() {
810            if i % 2 == 0 {
811                live.insert(h.clone());
812            }
813        }
814        let stats = s.compact(&live).unwrap();
815        assert_eq!(stats.live_objects, 10);
816        assert_eq!(stats.dropped_objects, 10);
817        for (i, h) in all.iter().enumerate() {
818            let got = s.get(h).unwrap();
819            if i % 2 == 0 {
820                assert!(got.is_some(), "live object {} must survive", i);
821            } else {
822                assert!(got.is_none(), "dead object {} must be pruned", i);
823            }
824        }
825    }
826
827    /// The read-handle cache serves many threads through ONE shared handle per
828    /// segment via positional reads — no cursor, no lock, no per-read open.
829    /// Small rollover size forces multiple segments so the cache holds several
830    /// handles, and reads cover both sealed segments and the active one
831    /// (read-after-write coherence through a second handle of the same file).
832    #[test]
833    fn concurrent_reads_share_cached_handles() {
834        let dir = tempdir().unwrap();
835        let s = Arc::new(SegmentStore::open_with_max(dir.path(), 256).unwrap());
836        let mut hashes = Vec::new();
837        for i in 0..64u32 {
838            hashes.push(put_get_hash(&s, format!("concurrent-record-{:04}", i).as_bytes()));
839        }
840        s.sync().unwrap();
841
842        let hashes = Arc::new(hashes);
843        let mut joins = vec![];
844        for t in 0..4 {
845            let s2 = Arc::clone(&s);
846            let hs = Arc::clone(&hashes);
847            joins.push(std::thread::spawn(move || {
848                // Every thread reads every record, twice — the second pass runs
849                // entirely on warm cached handles.
850                for pass in 0..2 {
851                    for (i, h) in hs.iter().enumerate() {
852                        let got = s2.get(h).unwrap()
853                            .unwrap_or_else(|| panic!("thread {} pass {} record {}: missing", t, pass, i));
854                        assert_eq!(got, format!("concurrent-record-{:04}", i).as_bytes(),
855                                   "thread {} pass {} record {}: wrong bytes", t, pass, i);
856                    }
857                }
858            }));
859        }
860        for j in joins { j.join().unwrap(); }
861
862        // And the cache must not survive a compaction (old ids never return).
863        let live: HashSet<String> = hashes.iter().cloned().collect();
864        let stats = s.compact(&live).unwrap();
865        assert_eq!(stats.live_objects, 64);
866        for (i, h) in hashes.iter().enumerate() {
867            assert_eq!(s.get(h).unwrap().unwrap(),
868                       format!("concurrent-record-{:04}", i).as_bytes(),
869                       "record {} must read correctly through fresh post-compact handles", i);
870        }
871    }
872}