Skip to main content

graph_r/
store.rs

1// Author: support@lituus.io
2// Copyright: lituus-io, all rights reserved
3//
4//! The store: a directory holding `graph.base` (immutable snapshot),
5//! `graph.wal` (append-only op log) and `graph.lock` (writer exclusion).
6//!
7//! # Concurrency model
8//!
9//! - Any number of snapshots; at most one writer per directory. Two layers
10//!   enforce that, and they cover different scopes. The `flock` taken at
11//!   [`Store::open`] admits only one read-write *handle* per directory —
12//!   `flock` is per open file description, so a second handle is refused with
13//!   [`Error::Locked`] whether it is opened by another process **or by this
14//!   one**. The mutex inside a handle then serializes [`Writer`]s across
15//!   threads sharing it. [`Store::open_read_only`] takes no lock, so readers
16//!   always coexist with a live writer.
17//! - A fixed ring of generation slots each holds an mmapped base plus its
18//!   committed-op overlay behind an `RwLock`. Readers only ever `try_read` —
19//!   the sole moment a `try_read` can fail is the microseconds in which a
20//!   writer installs a *different* generation into a free slot, so readers
21//!   spin-retry instead of blocking. Snapshots are `RwLockReadGuard`-backed
22//!   borrow guards: a pinned generation cannot be recycled under them, and
23//!   nothing is reference-counted.
24//! - Compaction writes a new base + fresh WAL, installs it into a free slot,
25//!   and swaps the current index; existing snapshots keep reading the old
26//!   generation untouched until they drop.
27
28use crate::compact;
29use crate::error::{Error, Result};
30use crate::format::{base, wal};
31use crate::mmap::MappedFile;
32use crate::overlay::Arena;
33use crate::snapshot::Snapshot;
34use crate::ttl::TtlConfig;
35use crate::writer::{WriteState, Writer};
36use std::fs;
37use std::path::{Path, PathBuf};
38use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
39use std::sync::{Mutex, RwLock};
40
41/// Number of generation ring slots. Three retired generations may stay pinned
42/// by long-lived snapshots while a fourth is installed.
43const GEN_SLOTS: usize = 4;
44
45/// When `commit` must fsync the WAL.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum Durability {
48    /// fsync on every commit (default).
49    Always,
50    /// fsync every `n` commits; a crash may lose up to the uncommitted tail.
51    Batch(u32),
52    /// Never fsync explicitly; durability follows the OS page cache.
53    Never,
54}
55
56/// Store configuration.
57#[derive(Clone, Copy, Debug)]
58pub struct Config {
59    /// WAL fsync policy.
60    pub durability: Durability,
61    /// Fold the WAL into a new base after this many committed ops.
62    pub compact_after_ops: usize,
63    /// … or after the WAL grows past this many bytes, whichever first.
64    pub compact_after_wal_bytes: u64,
65    /// Adaptive-freshness policy applied by [`Writer::touch`].
66    pub ttl: TtlConfig,
67}
68
69impl Default for Config {
70    fn default() -> Self {
71        Self {
72            durability: Durability::Always,
73            compact_after_ops: 4096,
74            compact_after_wal_bytes: 4 << 20,
75            ttl: TtlConfig::default(),
76        }
77    }
78}
79
80/// One loaded generation: the mmapped base and its overlay arena.
81#[derive(Debug)]
82pub(crate) struct GenInner {
83    map: MappedFile,
84    pub(crate) dir: base::BaseDir,
85    pub(crate) arena: Arena,
86}
87
88impl GenInner {
89    pub(crate) fn bytes(&self) -> &[u8] {
90        self.map.bytes()
91    }
92
93    pub(crate) fn from_parts(map: MappedFile, dir: base::BaseDir) -> Self {
94        Self { map, dir, arena: Arena::new() }
95    }
96}
97
98#[derive(Debug)]
99pub(crate) struct GenSlot {
100    /// Generation number for staleness checks; 0 = never populated.
101    pub(crate) seq: AtomicU64,
102    pub(crate) inner: RwLock<Option<GenInner>>,
103}
104
105/// An embedded, persistent knowledge-graph store.
106#[derive(Debug)]
107pub struct Store {
108    pub(crate) dir: PathBuf,
109    pub(crate) cfg: Config,
110    read_only: bool,
111    _lock: Option<LockFile>,
112    pub(crate) current: AtomicUsize,
113    pub(crate) gens: [GenSlot; GEN_SLOTS],
114    pub(crate) write: Mutex<WriteState>,
115    /// Compact inside `commit` past the thresholds (the 0.2 behaviour).
116    pub(crate) compact_inline: AtomicBool,
117    /// Thresholds reached while inline compaction is off.
118    pub(crate) due: AtomicBool,
119    /// What a reader saw at its last load: the file stamp for cheap
120    /// staleness checks and how far into the log it has consumed. The mutex
121    /// also makes reloads on one handle one-at-a-time (the overlay arena is
122    /// single-producer); snapshots never take it.
123    reload: Mutex<Reload>,
124}
125
126#[derive(Clone, Copy, Debug, Default)]
127struct Reload {
128    stamp: Option<Stamp>,
129    /// Byte offset just past the last intact log frame consumed.
130    wal_pos: u64,
131}
132
133/// The cross-process writer lock, held for the store's lifetime.
134#[derive(Debug)]
135struct LockFile {
136    _file: fs::File,
137}
138
139impl LockFile {
140    fn acquire(path: &Path) -> Result<Self> {
141        match crate::fsio::try_lock(path)? {
142            Some(file) => Ok(Self { _file: file }),
143            None => Err(Error::Locked),
144        }
145    }
146}
147
148/// Re-reads tolerated when a reader catches a compaction between its two
149/// renames (new log, old base): the base rename always lands first.
150const RACE_RETRIES: usize = 3;
151
152/// The base file's identity plus the WAL length. Equal stamps mean nothing a
153/// reader could observe has changed: the base is only ever replaced by rename
154/// (new inode, or new creation time where there are no inodes) and the WAL
155/// only grows within a generation.
156#[derive(Clone, Copy, Debug, PartialEq, Eq)]
157struct Stamp {
158    base: FileId,
159    wal_len: u64,
160}
161
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163struct FileId {
164    len: u64,
165    modified: Option<std::time::SystemTime>,
166    #[cfg(unix)]
167    ino: (u64, u64),
168    #[cfg(not(unix))]
169    created: Option<std::time::SystemTime>,
170}
171
172impl FileId {
173    fn of(md: &fs::Metadata) -> Self {
174        #[cfg(unix)]
175        use std::os::unix::fs::MetadataExt as _;
176        Self {
177            len: md.len(),
178            modified: md.modified().ok(),
179            #[cfg(unix)]
180            ino: (md.dev(), md.ino()),
181            #[cfg(not(unix))]
182            created: md.created().ok(),
183        }
184    }
185}
186
187impl Stamp {
188    /// Two `stat` calls; `None` when either file cannot be examined (the
189    /// caller then falls back to a full load, which reports the real error).
190    fn read(dir: &Path) -> Option<Self> {
191        let base = FileId::of(&fs::metadata(base_path(dir)).ok()?);
192        let wal_len = match fs::metadata(wal_path(dir)) {
193            Ok(md) => md.len(),
194            Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0,
195            Err(_) => return None,
196        };
197        Some(Self { base, wal_len })
198    }
199}
200
201/// File name of the immutable base snapshot inside a store directory.
202pub const BASE_FILE: &str = "graph.base";
203/// File name of the append-only op log inside a store directory.
204pub const WAL_FILE: &str = "graph.wal";
205/// File name of the writer lock inside a store directory.
206pub const LOCK_FILE: &str = "graph.lock";
207
208/// Paths inside a store directory.
209pub(crate) fn base_path(dir: &Path) -> PathBuf {
210    dir.join(BASE_FILE)
211}
212pub(crate) fn wal_path(dir: &Path) -> PathBuf {
213    dir.join(WAL_FILE)
214}
215fn lock_path(dir: &Path) -> PathBuf {
216    dir.join(LOCK_FILE)
217}
218
219/// Write `bytes` to `path` atomically (see [`crate::fsio::write_atomic`]).
220use crate::fsio::now_ms;
221
222pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
223    crate::fsio::write_atomic(path, &[bytes]).map_err(Into::into)
224}
225
226impl Store {
227    /// Create a fresh store in `dir` (created if absent; must not already
228    /// contain a store) and open it read-write.
229    pub fn create(dir: impl AsRef<Path>, cfg: Config) -> Result<Self> {
230        let dir = dir.as_ref();
231        fs::create_dir_all(dir)?;
232        if base_path(dir).exists() {
233            return Err(Error::format("store already exists; use open"));
234        }
235        let empty = compact::render_empty_base(1);
236        atomic_write(&base_path(dir), &empty)?;
237        atomic_write(&wal_path(dir), &wal::encode_header(1, now_ms()))?;
238        Self::open_with(dir, cfg, false)
239    }
240
241    /// Open an existing store read-write. Takes the exclusive writer lock
242    /// (`Error::Locked` if another process holds it) and repairs a torn WAL
243    /// tail by truncating to the last intact frame.
244    pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
245        Self::open_with(dir.as_ref(), Config::default(), false)
246    }
247
248    /// Open with an explicit configuration.
249    pub fn open_cfg(dir: impl AsRef<Path>, cfg: Config) -> Result<Self> {
250        Self::open_with(dir.as_ref(), cfg, false)
251    }
252
253    /// Open read-only: no lock taken, no repair performed; replay simply
254    /// stops at a torn tail. Use [`Store::reload_if_stale`] to observe
255    /// another process's commits and compactions.
256    pub fn open_read_only(dir: impl AsRef<Path>) -> Result<Self> {
257        Self::open_with(dir.as_ref(), Config::default(), true)
258    }
259
260    fn open_with(dir: &Path, cfg: Config, read_only: bool) -> Result<Self> {
261        let lock = if read_only { None } else { Some(LockFile::acquire(&lock_path(dir))?) };
262        let stamp = Stamp::read(dir);
263        let built = load_generation(dir, read_only)?;
264        Ok(Self::assemble(dir.to_path_buf(), cfg, read_only, lock, stamp, built))
265    }
266
267    /// Serve a store straight from memory: `base` is a `graph.base` image and
268    /// `wal` a `graph.wal` image (possibly empty), e.g. objects fetched from a
269    /// bucket. The base is read in place — no copy, no file, no mapping — and
270    /// fully validated exactly like [`Store::open`]. The store is read-only
271    /// and has no directory ([`Store::dir`] is empty); keep it current with
272    /// [`Store::reload_image`].
273    pub fn open_image(base: bytes::Bytes, wal: &[u8]) -> Result<Self> {
274        let built = build_generation(MappedFile::from_bytes(base), wal)?;
275        if built.log == LogState::Newer {
276            return Err(newer_image());
277        }
278        Ok(Self::assemble(PathBuf::new(), Config::default(), true, None, None, built))
279    }
280
281    /// Keep an image store current. Pass the new base when the generation
282    /// changed (a compaction), else `None` and the current log image: frames
283    /// not yet visible are published. Returns `true` when anything new
284    /// became visible. Works on file-backed read-only stores too.
285    pub fn reload_image(&self, base: Option<bytes::Bytes>, wal: &[u8]) -> Result<bool> {
286        if !self.read_only {
287            return Ok(false);
288        }
289        let _one_at_a_time = self.reload.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
290        match base {
291            Some(b) => {
292                self.install_or_apply(MappedFile::from_bytes(b), wal)?.ok_or_else(newer_image)
293            }
294            None => match self.apply_wal_bytes(wal)? {
295                Some(changed) => Ok(changed),
296                None => {
297                    Err(Error::format("log image belongs to another generation; pass its base"))
298                }
299            },
300        }
301    }
302
303    /// True for a store served from memory ([`Store::open_image`]).
304    #[must_use]
305    pub fn is_image(&self) -> bool {
306        self.dir.as_os_str().is_empty()
307    }
308
309    fn assemble(
310        dir: PathBuf,
311        cfg: Config,
312        read_only: bool,
313        lock: Option<LockFile>,
314        stamp: Option<Stamp>,
315        built: Built,
316    ) -> Self {
317        let generation = built.inner.dir.header.generation;
318        let reload = Reload { stamp, wal_pos: built.good_len as u64 };
319        let store = Self {
320            dir,
321            cfg,
322            read_only,
323            _lock: lock,
324            current: AtomicUsize::new(0),
325            gens: std::array::from_fn(|_| GenSlot {
326                seq: AtomicU64::new(0),
327                inner: RwLock::new(None),
328            }),
329            write: Mutex::new(WriteState::new(
330                built.next_seq,
331                built.replayed,
332                built.good_len as u64,
333                generation,
334            )),
335            compact_inline: AtomicBool::new(true),
336            due: AtomicBool::new(false),
337            reload: Mutex::new(reload),
338        };
339        *store.gens[0].inner.write().expect("fresh lock") = Some(built.inner);
340        store.gens[0].seq.store(generation, Ordering::Release);
341        store
342    }
343
344    /// Number of live (non-tombstoned, non-stub) documents in the current
345    /// snapshot view.
346    #[must_use]
347    pub fn len(&self) -> usize {
348        self.snapshot().len()
349    }
350
351    /// True when the store holds no live documents.
352    #[must_use]
353    pub fn is_empty(&self) -> bool {
354        self.len() == 0
355    }
356
357    /// The directory this store lives in.
358    #[must_use]
359    pub fn dir(&self) -> &Path {
360        &self.dir
361    }
362
363    /// The generation currently served (advances by one per compaction, or
364    /// when [`Store::reload_if_stale`] installs a newer one).
365    #[must_use]
366    pub fn generation(&self) -> u64 {
367        self.gens[self.current.load(Ordering::Acquire)].seq.load(Ordering::Acquire)
368    }
369
370    /// Whether [`Writer::commit`] compacts inline once the configured
371    /// thresholds are reached (the default, and the 0.2 behaviour). Services
372    /// that must keep commit latency flat turn it off and call
373    /// [`Store::compact`] off the hot path whenever [`Store::compaction_due`]
374    /// says so.
375    pub fn set_compact_inline(&self, inline: bool) {
376        self.compact_inline.store(inline, Ordering::Relaxed);
377    }
378
379    /// True when a threshold was reached while inline compaction was off and
380    /// no compaction has run since.
381    #[must_use]
382    pub fn compaction_due(&self) -> bool {
383        self.due.load(Ordering::Acquire)
384    }
385
386    /// Pin the current generation and return a read view. Never blocks in
387    /// steady state; spins only across a writer's slot-install window.
388    #[must_use]
389    pub fn snapshot(&self) -> Snapshot<'_> {
390        loop {
391            let cur = self.current.load(Ordering::Acquire);
392            if let Ok(guard) = self.gens[cur].inner.try_read() {
393                if guard.is_some() && self.current.load(Ordering::Acquire) == cur {
394                    return Snapshot::new(self, guard);
395                }
396            }
397            std::hint::spin_loop();
398        }
399    }
400
401    /// Acquire the single writer. `Error::ReadOnly` on read-only stores.
402    ///
403    /// Blocks while another thread holds a [`Writer`] on *this* handle. A second
404    /// handle on the same directory cannot exist at all — [`Store::open`] would
405    /// have been refused with [`Error::Locked`] — so this mutex is the only
406    /// contention a caller can encounter.
407    pub fn writer(&self) -> Result<Writer<'_>> {
408        if self.read_only {
409            return Err(Error::ReadOnly);
410        }
411        let state = self.write.lock().map_err(|_| Error::corrupt("writer mutex poisoned"))?;
412        Ok(Writer::new(self, state))
413    }
414
415    /// Fold the WAL into a new base generation now. Also triggered
416    /// automatically by [`Writer::commit`] past the configured thresholds.
417    pub fn compact(&self) -> Result<compact::CompactStats> {
418        if self.read_only {
419            return Err(Error::ReadOnly);
420        }
421        let mut state = self.write.lock().map_err(|_| Error::corrupt("writer mutex poisoned"))?;
422        compact::compact_locked(self, &mut state)
423    }
424
425    /// For read-only stores: pick up commits/compactions made by the writing
426    /// process. Returns `true` when anything new became visible.
427    ///
428    /// Cheap when nothing changed: two `stat` calls, no mapping, no reads.
429    /// When only the log grew, only the appended bytes are read. Reloads on
430    /// one handle run one at a time; snapshots are never blocked. On a
431    /// writable handle this is a no-op (`Ok(false)`): its own view is always
432    /// current.
433    pub fn reload_if_stale(&self) -> Result<bool> {
434        if self.is_image() || !self.read_only {
435            return Ok(false); // images: see reload_image; writers: always current
436        }
437        let now = Stamp::read(&self.dir);
438        let mut st = self.reload.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
439        if let (Some(n), Some(l)) = (now, st.stamp) {
440            if n == l {
441                return Ok(false);
442            }
443            // Same base and a log that only grew: read just the new bytes.
444            if n.base == l.base && n.wal_len >= st.wal_pos && st.wal_pos >= wal::HEADER_LEN as u64 {
445                let (changed, pos) = self.reload_wal_tail(st.wal_pos)?;
446                *st = Reload { stamp: now, wal_pos: pos };
447                return Ok(changed);
448            }
449        }
450        let (changed, pos) = self.reload_full()?;
451        *st = Reload { stamp: now, wal_pos: pos };
452        Ok(changed)
453    }
454
455    /// Same base file: read the log from `pos` (a frame boundary already
456    /// consumed) and publish the new frames. Returns whether anything became
457    /// visible and the new consumed offset.
458    fn reload_wal_tail(&self, pos: u64) -> Result<(bool, u64)> {
459        use std::io::{Read as _, Seek as _, SeekFrom};
460        let mut tail = Vec::new();
461        match fs::File::open(wal_path(&self.dir)) {
462            Ok(mut f) => {
463                f.seek(SeekFrom::Start(pos))?;
464                f.read_to_end(&mut tail)?;
465            }
466            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((false, pos)),
467            Err(e) => return Err(e.into()),
468        }
469        let (ops, used) = wal::replay_frames(&tail)?;
470        Ok((self.publish_new(ops)?, pos + used as u64))
471    }
472
473    /// Publish `ops` (in sequence order) that the served generation has not
474    /// seen: sequence above both the base's folded ops and the overlay's last.
475    fn publish_new(&self, ops: Vec<(u64, wal::Op)>) -> Result<bool> {
476        let cur = self.current.load(Ordering::Acquire);
477        let guard = self.gens[cur].inner.try_read().map_err(|_| Error::busy("slot busy"))?;
478        let Some(g) = guard.as_ref() else { return Ok(false) };
479        let seen = g.dir.header.wal_applied_seq.max(g.arena.last_seq());
480        let mut changed = false;
481        for (seq, op) in ops.into_iter().filter(|&(s, _)| s > seen) {
482            if !g.arena.publish(seq, op) {
483                return Err(Error::busy("overlay arena full"));
484            }
485            changed = true;
486        }
487        Ok(changed)
488    }
489
490    /// Publish the frames of a log image that the current generation has not
491    /// seen. `None` when the image belongs to another generation.
492    fn apply_wal_bytes(&self, raw: &[u8]) -> Result<Option<bool>> {
493        if raw.is_empty() {
494            return Ok(Some(false));
495        }
496        let replayed = wal::replay(raw)?;
497        if replayed.base_generation != self.generation() {
498            return Ok(None);
499        }
500        self.publish_new(replayed.ops).map(Some)
501    }
502
503    /// Map the base and read the log, retrying across a compaction caught
504    /// between its renames. Returns whether anything changed and the log
505    /// offset consumed.
506    fn reload_full(&self) -> Result<(bool, u64)> {
507        for _ in 0..RACE_RETRIES {
508            let map = MappedFile::open(&base_path(&self.dir))?;
509            let raw = match fs::read(wal_path(&self.dir)) {
510                Ok(raw) => raw,
511                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
512                Err(e) => return Err(e.into()),
513            };
514            if let Some(changed) = self.install_or_apply(map, &raw)? {
515                return Ok((changed, wal::committed_len(&raw) as u64));
516            }
517        }
518        Err(Error::busy("compaction in progress; retry"))
519    }
520
521    /// Same generation as served: publish new frames. Otherwise rebuild the
522    /// generation from `map` + `raw` and install it into a free ring slot.
523    /// `None` when the log is newer than `map` (a compaction between the
524    /// reads): the caller re-reads.
525    fn install_or_apply(&self, map: MappedFile, raw: &[u8]) -> Result<Option<bool>> {
526        let cur_gen = self.generation();
527        let new_gen = base::Header::parse(map.bytes())?.generation;
528        if new_gen == cur_gen {
529            // Generations are never reused, so the same number is the same
530            // base: only the log can have grown.
531            return match self.apply_wal_bytes(raw)? {
532                Some(changed) => Ok(Some(changed)),
533                // Another generation's log: newer means a compaction landed
534                // between the reads (re-read); older is already folded in.
535                None if wal::replay(raw)?.base_generation > new_gen => Ok(None),
536                None => Ok(Some(false)),
537            };
538        }
539        let built = build_generation(map, raw)?;
540        if built.log == LogState::Newer {
541            return Ok(None);
542        }
543        let cur = self.current.load(Ordering::Acquire);
544        for (i, slot) in self.gens.iter().enumerate() {
545            if i == cur {
546                continue;
547            }
548            if let Ok(mut w) = slot.inner.try_write() {
549                *w = Some(built.inner);
550                drop(w);
551                slot.seq.store(new_gen, Ordering::Release);
552                self.current.store(i, Ordering::Release);
553                return Ok(Some(true));
554            }
555        }
556        Err(Error::busy("all generation slots pinned by snapshots"))
557    }
558}
559
560fn newer_image() -> Error {
561    Error::busy("log image is newer than its base; fetch the base again")
562}
563
564/// A generation rebuilt from a base image and a log image, before any disk
565/// side effects.
566struct Built {
567    inner: GenInner,
568    next_seq: u64,
569    replayed: usize,
570    good_len: usize,
571    log: LogState,
572}
573
574/// What the log image turned out to be.
575#[derive(Clone, Copy, PartialEq, Eq)]
576enum LogState {
577    /// No log at all.
578    Empty,
579    /// A log from before the base's generation: every op is already folded.
580    Stale,
581    /// The base's own log; `torn` when bytes follow the last intact frame.
582    Current { torn: bool },
583    /// A log of a later generation than the base: a compaction happened
584    /// between reading the two (readers re-read; a writer never sees this).
585    Newer,
586}
587
588/// Validate `map` as a base and replay `raw` against it. Pure: shared by the
589/// file-backed and in-memory (image) paths.
590fn build_generation(map: MappedFile, raw: &[u8]) -> Result<Built> {
591    let bdir = base::BaseDir::parse(map.bytes())?;
592    let header = bdir.header;
593    let inner = GenInner { map, dir: bdir, arena: Arena::new() };
594    if raw.is_empty() {
595        return Ok(Built {
596            inner,
597            next_seq: header.wal_applied_seq + 1,
598            replayed: 0,
599            good_len: 0,
600            log: LogState::Empty,
601        });
602    }
603    let replayed = wal::replay(raw)?;
604    if replayed.base_generation > header.generation {
605        return Ok(Built {
606            inner,
607            next_seq: header.wal_applied_seq + 1,
608            replayed: 0,
609            good_len: 0,
610            log: LogState::Newer,
611        });
612    }
613    if replayed.base_generation < header.generation {
614        return Ok(Built {
615            inner,
616            next_seq: header.wal_applied_seq + 1,
617            replayed: 0,
618            good_len: wal::HEADER_LEN,
619            log: LogState::Stale,
620        });
621    }
622    let torn = replayed.good_len < raw.len();
623    let next_seq = replayed
624        .ops
625        .last()
626        .map_or(header.wal_applied_seq + 1, |&(s, _)| s.max(header.wal_applied_seq) + 1);
627    let mut count = 0;
628    for (seq, op) in replayed.ops.into_iter().filter(|&(s, _)| s > header.wal_applied_seq) {
629        if !inner.arena.publish(seq, op) {
630            return Err(Error::busy("overlay arena full during replay"));
631        }
632        count += 1;
633    }
634    Ok(Built {
635        inner,
636        next_seq,
637        replayed: count,
638        good_len: replayed.good_len,
639        log: LogState::Current { torn },
640    })
641}
642
643/// Load `graph.base` + replay `graph.wal` for `dir`; a writable opener also
644/// resets a stale log and truncates a torn tail.
645fn load_generation(dir: &Path, read_only: bool) -> Result<Built> {
646    let wal_file = wal_path(dir);
647    let mut attempt = 0;
648    let mut built = loop {
649        let map = MappedFile::open(&base_path(dir))?;
650        let raw = match fs::read(&wal_file) {
651            Ok(raw) => raw,
652            Err(e) if e.kind() == std::io::ErrorKind::NotFound && read_only => Vec::new(),
653            Err(e) => return Err(e.into()),
654        };
655        let built = build_generation(map, &raw)?;
656        if built.log != LogState::Newer {
657            break built;
658        }
659        // A writer holds the lock, so nothing can compact under it: a newer
660        // log is real damage. A reader caught a compaction mid-way: re-read.
661        attempt += 1;
662        if !read_only {
663            return Err(Error::corrupt("wal is newer than base"));
664        }
665        if attempt == RACE_RETRIES {
666            return Err(Error::busy("compaction in progress; retry"));
667        }
668    };
669    if !read_only {
670        match built.log {
671            // An empty log (external truncation) gets its header back, or
672            // the next commit would append frames to a headerless file.
673            LogState::Stale | LogState::Empty => {
674                let generation = built.inner.dir.header.generation;
675                atomic_write(&wal_file, &wal::encode_header(generation, now_ms()))?;
676                built.good_len = wal::HEADER_LEN;
677            }
678            LogState::Current { torn: true } => {
679                let f = fs::OpenOptions::new().write(true).open(&wal_file)?;
680                f.set_len(built.good_len as u64)?;
681                f.sync_all()?;
682            }
683            LogState::Current { torn: false } | LogState::Newer => {}
684        }
685    }
686    Ok(built)
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use crate::writer::DocRecord;
693    use crate::UrlKey;
694
695    fn doc(url: &str, hash: u64) -> DocRecord<'_> {
696        DocRecord {
697            url,
698            url_key: UrlKey::of(url),
699            content_hash: hash,
700            fetched_at_ms: 1,
701            title: None,
702            snippet: None,
703            etag: None,
704            pinned: false,
705        }
706    }
707
708    fn write(store: &Store, url: &str, hash: u64) {
709        let mut w = store.writer().unwrap();
710        w.upsert_node(&doc(url, hash)).unwrap();
711        w.commit().unwrap();
712    }
713
714    /// A snapshot is frozen at the moment it is taken. Commits landing afterwards
715    /// are invisible to it, which is the whole reason readers never need a lock.
716    #[test]
717    fn a_snapshot_does_not_observe_later_commits() {
718        let dir = tempfile::tempdir().unwrap();
719        let store = Store::create(dir.path(), Config::default()).unwrap();
720        write(&store, "https://x.dev/a", 1);
721
722        let before = store.snapshot();
723        assert_eq!(before.len(), 1);
724
725        write(&store, "https://x.dev/b", 2);
726        assert_eq!(before.len(), 1, "an existing snapshot must not grow");
727        assert_eq!(store.snapshot().len(), 2, "a fresh snapshot sees the commit");
728    }
729
730    /// Compaction installs a new generation into a free ring slot and swaps the
731    /// current pointer. A snapshot taken beforehand keeps reading the generation
732    /// it pinned, untouched, until it drops.
733    #[test]
734    fn compaction_leaves_an_existing_snapshot_on_its_own_generation() {
735        let dir = tempfile::tempdir().unwrap();
736        let store = Store::create(dir.path(), Config::default()).unwrap();
737        write(&store, "https://x.dev/a", 1);
738        store.compact().unwrap();
739
740        let pinned = store.snapshot();
741        let pinned_gen = pinned.generation();
742
743        write(&store, "https://x.dev/b", 2);
744        store.compact().unwrap();
745
746        assert_eq!(pinned.generation(), pinned_gen, "a pinned generation must not move");
747        assert_eq!(pinned.len(), 1, "the old generation still reads its own state");
748        let fresh = store.snapshot();
749        assert!(fresh.generation() > pinned_gen, "the store advanced");
750        assert_eq!(fresh.len(), 2);
751    }
752
753    /// The generation number increments once per compaction and is what the WAL
754    /// checks itself against on replay.
755    #[test]
756    fn each_compaction_advances_the_generation_exactly_once() {
757        let dir = tempfile::tempdir().unwrap();
758        let store = Store::create(dir.path(), Config::default()).unwrap();
759        write(&store, "https://x.dev/a", 1);
760
761        let mut prev = store.snapshot().generation();
762        for _ in 0..6 {
763            store.compact().unwrap();
764            let now = store.snapshot().generation();
765            assert_eq!(now, prev + 1, "a compaction must advance the generation by one");
766            prev = now;
767        }
768    }
769
770    /// The ring is finite. Holding a snapshot on every slot leaves compaction
771    /// nowhere to install the new generation — a documented, retryable refusal
772    /// rather than a stall or a corrupted swap. The new base is already durable
773    /// on disk, so the retry only has to redo the in-memory install.
774    #[test]
775    fn exhausting_the_generation_ring_is_a_retryable_refusal() {
776        let dir = tempfile::tempdir().unwrap();
777        let store = Store::create(dir.path(), Config::default()).unwrap();
778        write(&store, "https://x.dev/a", 1);
779
780        // Compaction never reuses the current slot, so with GEN_SLOTS slots it
781        // can install GEN_SLOTS - 1 new generations while each previous one
782        // stays pinned. Pin before every compaction so none can be recycled.
783        let mut pins = Vec::new();
784        for round in 0..GEN_SLOTS - 1 {
785            pins.push(store.snapshot());
786            store.compact().unwrap_or_else(|e| panic!("round {round} should still fit: {e}"));
787        }
788
789        // Pinning the newest generation too leaves the next install nowhere to go.
790        pins.push(store.snapshot());
791        assert!(
792            matches!(store.compact(), Err(Error::Busy { .. })),
793            "a full ring must refuse with Busy, not stall or corrupt the swap"
794        );
795
796        // Releasing the pins makes the very same call succeed.
797        drop(pins);
798        store.compact().expect("compaction must succeed once a slot frees up");
799        assert_eq!(store.snapshot().len(), 1);
800    }
801
802    /// A read-only handle must be able to pick up another process's work without
803    /// reopening, and report whether anything actually changed.
804    #[test]
805    fn a_read_only_handle_reloads_a_newer_generation() {
806        let dir = tempfile::tempdir().unwrap();
807        let store = Store::create(dir.path(), Config::default()).unwrap();
808        write(&store, "https://x.dev/a", 1);
809        store.compact().unwrap();
810
811        let reader = Store::open_read_only(dir.path()).unwrap();
812        assert_eq!(reader.snapshot().len(), 1);
813        assert!(!reader.reload_if_stale().unwrap(), "nothing has changed yet");
814
815        write(&store, "https://x.dev/b", 2);
816        store.compact().unwrap();
817
818        assert!(reader.reload_if_stale().unwrap(), "a new generation is visible");
819        assert_eq!(reader.snapshot().len(), 2);
820    }
821}