Skip to main content

graph_r/
store.rs

1// Author: spicyzhug@gmail.com
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` (commit exclusion).
6//!
7//! # Concurrency model
8//!
9//! - **Any number of writers.** Any number of read-write handles may be open
10//!   on one directory, in one process or many; opening takes no lock. What is
11//!   exclusive is the *commit*: [`Writer::commit`], [`Store::transact`] and
12//!   compaction take an exclusive `flock` on `graph.lock` only around the
13//!   append (and its fsync) and release it straight after. A busy lock is
14//!   retried with exponential backoff (1 ms doubling to 50 ms) for up to
15//!   [`Config::lock_wait`], then refused with [`Error::Locked`]. `flock` is
16//!   per open file description, so two handles in one process exclude each
17//!   other exactly like two processes; within one handle a mutex serializes
18//!   [`Writer`]s across threads.
19//! - **Catch-up under the lock.** Holding the lock, a committer first
20//!   compares the files' stamp (two `stat`s) with what its handle last saw.
21//!   A new base (another handle compacted) is installed into a free
22//!   generation slot; a grown log has just its appended frames read and
23//!   published; the sequence counter and log offset advance to the file's.
24//!   Only then are the new frames encoded and appended, so sequence numbers
25//!   stay strictly increasing across every writer. A torn tail left by a
26//!   writer that crashed mid-append is truncated here, by the next committer
27//!   (and opportunistically at open when the lock happens to be free).
28//! - **Serializable transactions.** [`Store::transact`] holds the lock from
29//!   before its caught-up read view is taken until its single append lands,
30//!   so a read-modify-write is serializable across processes. One
31//!   transaction is one append and one fsync however many ops it carries.
32//! - **Readers never lock.** [`Store::open_read_only`] takes no lock and
33//!   never writes; [`Store::reload_if_stale`] picks up other handles' work on
34//!   any handle (a writable one also catches up at every commit).
35//! - A fixed ring of generation slots each holds an mmapped base plus its
36//!   committed-op overlay behind an `RwLock`. Readers only ever `try_read` —
37//!   the sole moment a `try_read` can fail is the microseconds in which a
38//!   handle installs a *different* generation into a free slot, so readers
39//!   spin-retry instead of blocking. Snapshots are `RwLockReadGuard`-backed
40//!   borrow guards: a pinned generation cannot be recycled under them, and
41//!   nothing is reference-counted.
42//! - Compaction writes a new base + fresh WAL under the lock, installs it
43//!   into a free slot, and swaps the current index; existing snapshots keep
44//!   reading the old generation untouched until they drop. Other handles
45//!   install the new base at their next catch-up.
46//!
47//! A transaction is atomic with respect to other writers and to a failed
48//! append (the log is cut back to where it was). The log format has no batch
49//! marker, so a power loss in the middle of a multi-op append can still leave
50//! a prefix of that transaction's frames — the same guarantee a multi-op
51//! [`Writer::commit`] has always had.
52
53use crate::compact;
54use crate::error::{Error, Result};
55use crate::format::{base, wal};
56use crate::mmap::MappedFile;
57use crate::overlay::Arena;
58use crate::snapshot::Snapshot;
59use crate::ttl::TtlConfig;
60use crate::writer::{Txn, WriteState, Writer};
61use std::fs;
62use std::path::{Path, PathBuf};
63use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
64use std::sync::{Mutex, MutexGuard, RwLock};
65use std::time::{Duration, Instant};
66
67/// Number of generation ring slots. Three retired generations may stay pinned
68/// by long-lived snapshots while a fourth is installed.
69const GEN_SLOTS: usize = 4;
70
71/// When `commit` must fsync the WAL.
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum Durability {
74    /// fsync on every commit (default).
75    Always,
76    /// fsync every `n` commits; a crash may lose up to the uncommitted tail.
77    Batch(u32),
78    /// Never fsync explicitly; durability follows the OS page cache.
79    Never,
80}
81
82/// Store configuration.
83#[derive(Clone, Copy, Debug)]
84pub struct Config {
85    /// WAL fsync policy.
86    pub durability: Durability,
87    /// Fold the WAL into a new base after this many committed ops.
88    pub compact_after_ops: usize,
89    /// … or after the WAL grows past this many bytes, whichever first.
90    pub compact_after_wal_bytes: u64,
91    /// Adaptive-freshness policy applied by [`Writer::touch`].
92    pub ttl: TtlConfig,
93    /// How long a commit waits for another writer's commit lock before
94    /// giving up with [`Error::Locked`].
95    pub lock_wait: Duration,
96}
97
98impl Default for Config {
99    fn default() -> Self {
100        Self {
101            durability: Durability::Always,
102            compact_after_ops: 4096,
103            compact_after_wal_bytes: 4 << 20,
104            ttl: TtlConfig::default(),
105            lock_wait: Duration::from_secs(10),
106        }
107    }
108}
109
110/// One loaded generation: the mmapped base and its overlay arena.
111#[derive(Debug)]
112pub(crate) struct GenInner {
113    map: MappedFile,
114    pub(crate) dir: base::BaseDir,
115    pub(crate) arena: Arena,
116}
117
118impl GenInner {
119    pub(crate) fn bytes(&self) -> &[u8] {
120        self.map.bytes()
121    }
122
123    pub(crate) fn from_parts(map: MappedFile, dir: base::BaseDir) -> Self {
124        Self { map, dir, arena: Arena::new() }
125    }
126}
127
128#[derive(Debug)]
129pub(crate) struct GenSlot {
130    /// Generation number for staleness checks; 0 = never populated.
131    pub(crate) seq: AtomicU64,
132    pub(crate) inner: RwLock<Option<GenInner>>,
133}
134
135/// An embedded, persistent knowledge-graph store.
136#[derive(Debug)]
137pub struct Store {
138    pub(crate) dir: PathBuf,
139    pub(crate) cfg: Config,
140    read_only: bool,
141    pub(crate) current: AtomicUsize,
142    pub(crate) gens: [GenSlot; GEN_SLOTS],
143    /// Per-handle write state, including what the handle last saw of the
144    /// files. Also makes reloads on one handle one-at-a-time (the overlay
145    /// arena is single-producer); snapshots never take it.
146    pub(crate) write: Mutex<WriteState>,
147    /// Compact inside `commit` past the thresholds (the 0.2 behaviour).
148    pub(crate) compact_inline: AtomicBool,
149    /// Thresholds reached while inline compaction is off.
150    pub(crate) due: AtomicBool,
151}
152
153/// What a handle saw at its last load or catch-up.
154#[derive(Clone, Copy, Debug, Default)]
155pub(crate) struct Seen {
156    /// The file stamp, for cheap staleness checks; `None` forces a full
157    /// reload.
158    pub(crate) stamp: Option<Stamp>,
159    /// Byte offset just past the last intact log frame consumed.
160    pub(crate) wal_pos: u64,
161    /// The log's header names the base's generation (false for a missing,
162    /// empty or older log, which a committer rewrites).
163    pub(crate) log_current: bool,
164    /// Generation of the base file on disk (the served one, unless every
165    /// ring slot was pinned when it appeared).
166    pub(crate) base_gen: u64,
167}
168
169impl Seen {
170    /// Account for `n` bytes this handle appended itself.
171    pub(crate) fn appended(&mut self, n: u64) {
172        self.wal_pos += n;
173        if let Some(w) = self.stamp.as_mut().and_then(|s| s.wal.as_mut()) {
174            w.1 += n;
175        }
176    }
177}
178
179/// The commit lock, held until dropped. Owned, so a caller (a language
180/// binding, say) can keep it across several calls and then commit under it
181/// with [`Store::transact_with`].
182#[derive(Debug)]
183pub struct CommitLock {
184    _held: Held,
185    dir: PathBuf,
186}
187
188/// A held `flock` on `graph.lock`; dropping the file releases it.
189#[derive(Debug)]
190pub(crate) struct Held(pub(crate) fs::File);
191
192/// Open `dir`'s lock file. The path is fixed (`dir/graph.lock`), and a lock
193/// file that is not a regular file — a symlink planted to make the store
194/// create or lock a file elsewhere — is refused.
195fn open_lock_file(dir: &Path) -> Result<fs::File> {
196    let path = lock_path(dir);
197    match fs::symlink_metadata(&path) {
198        Ok(md) if !md.file_type().is_file() => {
199            return Err(Error::corrupt(format!("{LOCK_FILE} is not a regular file")));
200        }
201        _ => {}
202    }
203    Ok(fs::OpenOptions::new().create(true).truncate(false).write(true).open(path)?)
204}
205
206/// Lock `file`, retrying with exponential backoff (1 ms doubling to 50 ms)
207/// for up to `wait`; `Error::Locked` past it.
208fn lock_within(file: &fs::File, wait: Duration) -> Result<()> {
209    let mut start = None;
210    let mut nap = Duration::from_millis(1);
211    loop {
212        #[cfg(test)]
213        probe::bump(&probe::LOCKS, 1);
214        match file.try_lock() {
215            Ok(()) => return Ok(()),
216            Err(fs::TryLockError::WouldBlock) => {}
217            Err(fs::TryLockError::Error(e)) => return Err(e.into()),
218        }
219        let left = wait.saturating_sub(start.get_or_insert_with(Instant::now).elapsed());
220        if left.is_zero() {
221            return Err(Error::Locked);
222        }
223        std::thread::sleep(nap.min(left));
224        nap = (nap * 2).min(Duration::from_millis(50));
225    }
226}
227
228/// Take `dir`'s commit lock on a fresh descriptor.
229fn lock_dir(dir: &Path, wait: Duration) -> Result<Held> {
230    let file = open_lock_file(dir)?;
231    lock_within(&file, wait)?;
232    Ok(Held(file))
233}
234
235/// Counters for the counted (never clocked) performance tests: per thread,
236/// so parallel tests do not see each other's work.
237#[cfg(test)]
238pub(crate) mod probe {
239    use std::cell::Cell;
240    use std::thread::LocalKey;
241    thread_local! {
242        /// Lock attempts (`try_lock` calls).
243        pub(crate) static LOCKS: Cell<u64> = const { Cell::new(0) };
244        /// Log fsyncs by commits.
245        pub(crate) static FSYNCS: Cell<u64> = const { Cell::new(0) };
246        /// Log bytes read by tail catch-ups.
247        pub(crate) static TAIL_BYTES: Cell<u64> = const { Cell::new(0) };
248    }
249    pub(crate) fn bump(c: &'static LocalKey<Cell<u64>>, n: u64) {
250        c.with(|x| x.set(x.get() + n));
251    }
252    pub(crate) fn take(c: &'static LocalKey<Cell<u64>>) -> u64 {
253        c.with(|x| x.replace(0))
254    }
255}
256
257/// Re-reads tolerated when a reader catches a compaction between its two
258/// renames (new log, old base): the base rename always lands first.
259const RACE_RETRIES: usize = 3;
260
261/// The base file's identity plus the log's identity and length. Equal stamps
262/// mean nothing a reader could observe has changed: the base is only ever
263/// replaced by rename (new inode, or new creation time where there are no
264/// inodes), and the log only grows in place — it is replaced (by
265/// compaction, or a committer's repair) or cut back (a torn tail), never
266/// rewritten in place to the same length.
267#[derive(Clone, Copy, Debug, PartialEq, Eq)]
268pub(crate) struct Stamp {
269    base: FileId,
270    /// The log's identity and length; `None` when it does not exist.
271    wal: Option<(Ident, u64)>,
272}
273
274#[derive(Clone, Copy, Debug, PartialEq, Eq)]
275struct FileId {
276    len: u64,
277    modified: Option<std::time::SystemTime>,
278    ident: Ident,
279}
280
281/// Which file a path names, independent of its contents.
282#[derive(Clone, Copy, Debug, PartialEq, Eq)]
283struct Ident {
284    #[cfg(unix)]
285    ino: (u64, u64),
286    #[cfg(not(unix))]
287    created: Option<std::time::SystemTime>,
288}
289
290impl Ident {
291    fn of(md: &fs::Metadata) -> Self {
292        #[cfg(unix)]
293        use std::os::unix::fs::MetadataExt as _;
294        Self {
295            #[cfg(unix)]
296            ino: (md.dev(), md.ino()),
297            #[cfg(not(unix))]
298            created: md.created().ok(),
299        }
300    }
301}
302
303impl FileId {
304    fn of(md: &fs::Metadata) -> Self {
305        Self { len: md.len(), modified: md.modified().ok(), ident: Ident::of(md) }
306    }
307}
308
309impl Stamp {
310    /// Two `stat` calls; `None` when either file cannot be examined (the
311    /// caller then falls back to a full load, which reports the real error).
312    pub(crate) fn read(dir: &Path) -> Option<Self> {
313        let base = FileId::of(&fs::metadata(base_path(dir)).ok()?);
314        let wal = match fs::metadata(wal_path(dir)) {
315            Ok(md) => Some((Ident::of(&md), md.len())),
316            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
317            Err(_) => return None,
318        };
319        Some(Self { base, wal })
320    }
321}
322
323/// How a catch-up brought a handle up to the files.
324#[derive(Clone, Copy, Debug, PartialEq, Eq)]
325enum Caught {
326    /// The stamp was unchanged: nothing read.
327    Nothing,
328    /// Same base and log file: only the appended bytes were read.
329    Tail,
330    /// The base or the log file was replaced: both re-read.
331    Full,
332}
333
334/// File name of the immutable base snapshot inside a store directory.
335pub const BASE_FILE: &str = "graph.base";
336/// File name of the append-only op log inside a store directory.
337pub const WAL_FILE: &str = "graph.wal";
338/// File name of the commit lock inside a store directory.
339pub const LOCK_FILE: &str = "graph.lock";
340
341/// Paths inside a store directory.
342pub(crate) fn base_path(dir: &Path) -> PathBuf {
343    dir.join(BASE_FILE)
344}
345pub(crate) fn wal_path(dir: &Path) -> PathBuf {
346    dir.join(WAL_FILE)
347}
348fn lock_path(dir: &Path) -> PathBuf {
349    dir.join(LOCK_FILE)
350}
351
352/// Write `bytes` to `path` atomically (see [`crate::fsio::write_atomic`]).
353use crate::fsio::now_ms;
354
355pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
356    crate::fsio::write_atomic(path, &[bytes]).map_err(Into::into)
357}
358
359/// Lay down the files of a fresh store in `dir`, under its commit lock.
360/// Never replaces a file that exists: the log first (so a base on disk
361/// always has its log beside it), then the base, which is what marks the
362/// store as created.
363fn lay_down(dir: &Path) -> Result<()> {
364    if !wal_path(dir).exists() {
365        atomic_write(&wal_path(dir), &wal::encode_header(1, now_ms()))?;
366    }
367    atomic_write(&base_path(dir), &compact::render_empty_base(1))
368}
369
370impl Store {
371    /// Create a fresh store in `dir` (created if absent; must not already
372    /// contain a store) and open it read-write. Creation runs under the
373    /// commit lock, so a racing creator either finds the store already made
374    /// (and is refused) or waits for this one — it never lays fresh files
375    /// over a store another process has committed to.
376    pub fn create(dir: impl AsRef<Path>, cfg: Config) -> Result<Self> {
377        let dir = dir.as_ref();
378        fs::create_dir_all(dir)?;
379        {
380            let _held = lock_dir(dir, cfg.lock_wait)?;
381            if base_path(dir).exists() {
382                return Err(Error::format("store already exists; use open"));
383            }
384            lay_down(dir)?;
385        }
386        Self::open_with(dir, cfg, false)
387    }
388
389    /// Open the store in `dir` read-write, creating it first when absent.
390    /// Safe when many processes race to create the same store: creation
391    /// happens once, under the commit lock (waiting up to
392    /// [`Config::lock_wait`] for a creator in progress), and everyone opens
393    /// the result. An existing store is opened without taking any lock.
394    pub fn open_or_create(dir: impl AsRef<Path>, cfg: Config) -> Result<Self> {
395        let dir = dir.as_ref();
396        if !base_path(dir).exists() {
397            fs::create_dir_all(dir)?;
398            let _held = lock_dir(dir, cfg.lock_wait)?;
399            if !base_path(dir).exists() {
400                lay_down(dir)?;
401            }
402        }
403        Self::open_with(dir, cfg, false)
404    }
405
406    /// Open an existing store read-write. Takes no lock: any number of
407    /// read-write handles may be open at once (see the module docs). A torn
408    /// log tail is repaired by the next commit, or right here when no other
409    /// writer holds the commit lock.
410    pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
411        Self::open_with(dir.as_ref(), Config::default(), false)
412    }
413
414    /// Open with an explicit configuration.
415    pub fn open_cfg(dir: impl AsRef<Path>, cfg: Config) -> Result<Self> {
416        Self::open_with(dir.as_ref(), cfg, false)
417    }
418
419    /// Open read-only: no lock taken, no repair performed; replay simply
420    /// stops at a torn tail. Use [`Store::reload_if_stale`] to observe
421    /// other handles' commits and compactions.
422    pub fn open_read_only(dir: impl AsRef<Path>) -> Result<Self> {
423        Self::open_with(dir.as_ref(), Config::default(), true)
424    }
425
426    fn open_with(dir: &Path, cfg: Config, read_only: bool) -> Result<Self> {
427        let stamp = Stamp::read(dir);
428        let built = match load_generation(dir, false) {
429            // A log newer than its base, on every re-read. Without the lock
430            // that may be a compaction in flight; a writable opener checks
431            // under the lock, where it can only be damage.
432            Err(Error::Busy { .. }) if !read_only => match lock_now(dir)? {
433                Some(_held) => load_generation(dir, true)?,
434                None => return Err(Error::busy("compaction in progress; retry")),
435            },
436            other => other?,
437        };
438        let repair = !read_only && built.log != LogState::Current { torn: false };
439        let store = Self::assemble(dir.to_path_buf(), cfg, read_only, stamp, built);
440        if repair {
441            store.repair_now()?;
442        }
443        Ok(store)
444    }
445
446    /// Repair the log (torn tail, missing or stale header) if the commit lock
447    /// is free right now; otherwise leave it to whoever holds it or commits
448    /// next.
449    fn repair_now(&self) -> Result<()> {
450        let Some(held) = lock_now(&self.dir)? else { return Ok(()) };
451        let mut state = self.write_state()?;
452        let r = self.sync(&mut state, true).map(|_| ());
453        drop(held);
454        r
455    }
456
457    /// Serve a store straight from memory: `base` is a `graph.base` image and
458    /// `wal` a `graph.wal` image (possibly empty), e.g. objects fetched from a
459    /// bucket. The base is read in place — no copy, no file, no mapping — and
460    /// fully validated exactly like [`Store::open`]. The store is read-only
461    /// and has no directory ([`Store::dir`] is empty); keep it current with
462    /// [`Store::reload_image`].
463    pub fn open_image(base: bytes::Bytes, wal: &[u8]) -> Result<Self> {
464        let built = build_generation(MappedFile::from_bytes(base), wal)?;
465        if built.log == LogState::Newer {
466            return Err(newer_image());
467        }
468        Ok(Self::assemble(PathBuf::new(), Config::default(), true, None, built))
469    }
470
471    /// Keep an image store current. Pass the new base when the generation
472    /// changed (a compaction), else `None` and the current log image: frames
473    /// not yet visible are published. Returns `true` when anything new
474    /// became visible. Works on file-backed read-only stores too.
475    pub fn reload_image(&self, base: Option<bytes::Bytes>, wal: &[u8]) -> Result<bool> {
476        if !self.read_only {
477            return Ok(false);
478        }
479        let _one_at_a_time = self.write.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
480        match base {
481            Some(b) => {
482                self.install_or_apply(MappedFile::from_bytes(b), wal)?.ok_or_else(newer_image)
483            }
484            None => match self.apply_wal_bytes(wal)? {
485                Some(changed) => Ok(changed),
486                None => {
487                    Err(Error::format("log image belongs to another generation; pass its base"))
488                }
489            },
490        }
491    }
492
493    /// True for a store served from memory ([`Store::open_image`]).
494    #[must_use]
495    pub fn is_image(&self) -> bool {
496        self.dir.as_os_str().is_empty()
497    }
498
499    fn assemble(
500        dir: PathBuf,
501        cfg: Config,
502        read_only: bool,
503        stamp: Option<Stamp>,
504        built: Built,
505    ) -> Self {
506        let generation = built.inner.dir.header.generation;
507        let seen = Seen {
508            stamp,
509            wal_pos: built.good_len as u64,
510            log_current: matches!(built.log, LogState::Current { .. }),
511            base_gen: generation,
512        };
513        let store = Self {
514            dir,
515            cfg,
516            read_only,
517            current: AtomicUsize::new(0),
518            gens: std::array::from_fn(|_| GenSlot {
519                seq: AtomicU64::new(0),
520                inner: RwLock::new(None),
521            }),
522            write: Mutex::new(WriteState::new(
523                built.next_seq,
524                built.replayed,
525                built.good_len as u64,
526                generation,
527                seen,
528            )),
529            compact_inline: AtomicBool::new(true),
530            due: AtomicBool::new(false),
531        };
532        *store.gens[0].inner.write().expect("fresh lock") = Some(built.inner);
533        store.gens[0].seq.store(generation, Ordering::Release);
534        store
535    }
536
537    /// Number of live (non-tombstoned, non-stub) documents in the current
538    /// snapshot view.
539    #[must_use]
540    pub fn len(&self) -> usize {
541        self.snapshot().len()
542    }
543
544    /// True when the store holds no live documents.
545    #[must_use]
546    pub fn is_empty(&self) -> bool {
547        self.len() == 0
548    }
549
550    /// The directory this store lives in.
551    #[must_use]
552    pub fn dir(&self) -> &Path {
553        &self.dir
554    }
555
556    /// The generation currently served (advances by one per compaction, or
557    /// when a catch-up installs a newer one).
558    #[must_use]
559    pub fn generation(&self) -> u64 {
560        self.gens[self.current.load(Ordering::Acquire)].seq.load(Ordering::Acquire)
561    }
562
563    /// Whether [`Writer::commit`] compacts inline once the configured
564    /// thresholds are reached (the default, and the 0.2 behaviour). Services
565    /// that must keep commit latency flat turn it off and call
566    /// [`Store::compact`] off the hot path whenever [`Store::compaction_due`]
567    /// says so.
568    pub fn set_compact_inline(&self, inline: bool) {
569        self.compact_inline.store(inline, Ordering::Relaxed);
570    }
571
572    /// True when a threshold was reached while inline compaction was off and
573    /// no compaction has run since.
574    #[must_use]
575    pub fn compaction_due(&self) -> bool {
576        self.due.load(Ordering::Acquire)
577    }
578
579    /// Pin the current generation and return a read view. Never blocks in
580    /// steady state; spins only across a slot-install window.
581    #[must_use]
582    pub fn snapshot(&self) -> Snapshot<'_> {
583        loop {
584            let cur = self.current.load(Ordering::Acquire);
585            if let Ok(guard) = self.gens[cur].inner.try_read() {
586                if guard.is_some() && self.current.load(Ordering::Acquire) == cur {
587                    return Snapshot::new(self, guard);
588                }
589            }
590            std::hint::spin_loop();
591        }
592    }
593
594    fn write_state(&self) -> Result<MutexGuard<'_, WriteState>> {
595        self.write.lock().map_err(|_| Error::corrupt("writer mutex poisoned"))
596    }
597
598    /// This handle's writer. `Error::ReadOnly` on read-only stores.
599    ///
600    /// Blocks while another thread holds a [`Writer`] on *this* handle.
601    /// Writers on other handles — in this process or another — stage freely;
602    /// only their commits take turns, on the commit lock.
603    pub fn writer(&self) -> Result<Writer<'_>> {
604        if self.read_only {
605            return Err(Error::ReadOnly);
606        }
607        Ok(Writer::new(self, self.write_state()?))
608    }
609
610    /// Run `f` as one serializable transaction and commit what it stages.
611    ///
612    /// The commit lock is taken (waiting up to [`Config::lock_wait`]) and
613    /// this handle caught up *before* `f` runs, and held until its single
614    /// append (one fsync) lands: [`Txn::snapshot`] is the latest committed
615    /// state of every writer, and no other writer can commit between that
616    /// read and this transaction's write. `Err` from `f` aborts with nothing
617    /// written. The view shows committed state only — ops staged by `f` are
618    /// not visible in it.
619    pub fn transact<F, R>(&self, f: F) -> Result<R>
620    where
621        F: for<'t> FnOnce(&mut Txn<'t>) -> Result<R>,
622    {
623        crate::writer::transact(self, None, self.cfg.lock_wait, f)
624    }
625
626    /// [`Store::transact`] that never waits: `Error::Locked` at once when
627    /// another writer holds the commit lock (`f` does not run), so an async
628    /// caller can retry on its own schedule instead of parking a thread.
629    pub fn try_transact<F, R>(&self, f: F) -> Result<R>
630    where
631        F: for<'t> FnOnce(&mut Txn<'t>) -> Result<R>,
632    {
633        crate::writer::transact(self, None, Duration::ZERO, f)
634    }
635
636    /// Take the commit lock now and catch this handle up, for a transaction
637    /// that spans several calls (a language binding's `with` block). Commit
638    /// under it with [`Store::transact_with`]; drop it to release. While it
639    /// is held, a commit on this same handle from another thread waits for it
640    /// like any other writer would.
641    pub fn commit_lock(&self) -> Result<CommitLock> {
642        self.commit_lock_within(self.cfg.lock_wait)
643    }
644
645    /// [`Store::commit_lock`] that never waits: `Error::Locked` at once when
646    /// another writer holds it.
647    pub fn try_commit_lock(&self) -> Result<CommitLock> {
648        self.commit_lock_within(Duration::ZERO)
649    }
650
651    fn commit_lock_within(&self, wait: Duration) -> Result<CommitLock> {
652        if self.read_only {
653            return Err(Error::ReadOnly);
654        }
655        let mut state = self.write_state()?;
656        let held = lock_dir(&self.dir, wait)?;
657        self.sync(&mut state, true)?;
658        Ok(CommitLock { _held: held, dir: self.dir.clone() })
659    }
660
661    /// [`Store::transact`] under a lock already taken with
662    /// [`Store::commit_lock`] on this store (refused for another store's).
663    pub fn transact_with<F, R>(&self, lock: &CommitLock, f: F) -> Result<R>
664    where
665        F: for<'t> FnOnce(&mut Txn<'t>) -> Result<R>,
666    {
667        if lock.dir != self.dir {
668            return Err(Error::format("the commit lock belongs to another store"));
669        }
670        crate::writer::transact(self, Some(lock), Duration::ZERO, f)
671    }
672
673    /// Fold the WAL into a new base generation now. Also triggered
674    /// automatically by [`Writer::commit`] past the configured thresholds.
675    /// Runs under the commit lock, on a caught-up handle.
676    pub fn compact(&self) -> Result<compact::CompactStats> {
677        if self.read_only {
678            return Err(Error::ReadOnly);
679        }
680        let mut state = self.write_state()?;
681        let held = self.acquire(&mut state, self.cfg.lock_wait)?;
682        let r = self.sync(&mut state, true).and_then(|_| compact::compact_locked(self, &mut state));
683        state.release(held);
684        r
685    }
686
687    /// Pick up commits and compactions made through other handles (other
688    /// processes, or other handles in this one). Returns `true` when
689    /// anything new became visible.
690    ///
691    /// Cheap when nothing changed: two `stat` calls, no mapping, no reads.
692    /// When only the log grew, only the appended bytes are read. Reloads on
693    /// one handle run one at a time; snapshots are never blocked. A writable
694    /// handle whose [`Writer`] is held by another thread returns `Ok(false)`
695    /// rather than wait (it catches up at that writer's commit anyway).
696    pub fn reload_if_stale(&self) -> Result<bool> {
697        if self.is_image() {
698            return Ok(false); // images: see reload_image
699        }
700        let mut state = if self.read_only {
701            self.write.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
702        } else {
703            match self.write.try_lock() {
704                Ok(g) => g,
705                Err(std::sync::TryLockError::WouldBlock) => return Ok(false),
706                Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(),
707            }
708        };
709        self.sync(&mut state, false)
710    }
711
712    /// Take the commit lock with this handle's cached descriptor, waiting up
713    /// to `wait`.
714    pub(crate) fn acquire(&self, state: &mut WriteState, wait: Duration) -> Result<Held> {
715        let file = match state.lock_file.take() {
716            Some(f) => f,
717            None => open_lock_file(&self.dir)?,
718        };
719        match lock_within(&file, wait) {
720            Ok(()) => Ok(Held(file)),
721            Err(e) => {
722                state.lock_file = Some(file);
723                Err(e)
724            }
725        }
726    }
727
728    /// Bring this handle up to the files: a new base installed, appended
729    /// frames published, the write counters advanced to the file's. With
730    /// `repair` — only ever under the commit lock, where nobody can be
731    /// mid-append — a torn tail is truncated and a missing or stale log
732    /// header rewritten. Returns whether anything new became visible.
733    pub(crate) fn sync(&self, state: &mut WriteState, repair: bool) -> Result<bool> {
734        let (changed, caught) = self.catch_up(&mut state.seen, repair)?;
735        if caught == Caught::Nothing && !repair {
736            return Ok(false);
737        }
738        if caught == Caught::Full {
739            state.drop_wal_handle(); // the log may be another file now
740        }
741        if repair {
742            self.repair(state)?;
743        }
744        let cur = self.current.load(Ordering::Acquire);
745        let guard = self.gens[cur].inner.try_read().map_err(|_| Error::busy("slot busy"))?;
746        let g = guard.as_ref().ok_or_else(|| Error::corrupt("current slot empty"))?;
747        let next_seq = g.dir.header.wal_applied_seq.max(g.arena.last_seq()) + 1;
748        state.adopt(next_seq, g.arena.len(), state.seen.base_gen);
749        if caught == Caught::Full {
750            let over = state.ops_since_compact >= self.cfg.compact_after_ops
751                || state.wal_bytes >= self.cfg.compact_after_wal_bytes;
752            self.due.store(over && !self.compact_inline.load(Ordering::Relaxed), Ordering::Release);
753        }
754        Ok(changed)
755    }
756
757    /// Cut a torn tail back to the last intact frame, or give a missing,
758    /// empty or older log a header for the served generation. Under the
759    /// commit lock only.
760    fn repair(&self, state: &mut WriteState) -> Result<()> {
761        let seen = &mut state.seen;
762        let file = wal_path(&self.dir);
763        if !seen.log_current {
764            atomic_write(&file, &wal::encode_header(seen.base_gen, now_ms()))?;
765            *seen = Seen {
766                stamp: Stamp::read(&self.dir),
767                wal_pos: wal::HEADER_LEN as u64,
768                log_current: true,
769                base_gen: seen.base_gen,
770            };
771            state.drop_wal_handle();
772            return Ok(());
773        }
774        if let Some((ident, len)) = seen.stamp.and_then(|s| s.wal) {
775            if len > seen.wal_pos {
776                let f = fs::OpenOptions::new().write(true).open(&file)?;
777                f.set_len(seen.wal_pos)?;
778                f.sync_all()?;
779                if let Some(s) = seen.stamp.as_mut() {
780                    s.wal = Some((ident, seen.wal_pos));
781                }
782            }
783        }
784        Ok(())
785    }
786
787    /// Compare the files with what `seen` recorded and read only what
788    /// changed. Under the commit lock (`locked`) a log newer than its base
789    /// is damage; without it, a compaction caught mid-way (re-read, then
790    /// `Busy`).
791    fn catch_up(&self, seen: &mut Seen, locked: bool) -> Result<(bool, Caught)> {
792        let now = Stamp::read(&self.dir);
793        if let (Some(n), Some(l)) = (now, seen.stamp) {
794            if n == l {
795                return Ok((false, Caught::Nothing));
796            }
797            // Same base and log file, and a log that only grew: read just
798            // the new bytes.
799            if let (Some((ni, nlen)), Some((li, _))) = (n.wal, l.wal) {
800                if n.base == l.base
801                    && ni == li
802                    && nlen >= seen.wal_pos
803                    && seen.wal_pos >= wal::HEADER_LEN as u64
804                {
805                    let (changed, pos) = self.reload_wal_tail(seen.wal_pos)?;
806                    *seen = Seen { stamp: now, wal_pos: pos, ..*seen };
807                    return Ok((changed, Caught::Tail));
808                }
809            }
810        }
811        let (changed, pos, log_current, base_gen) = self.reload_full(locked)?;
812        *seen = Seen { stamp: now, wal_pos: pos, log_current, base_gen };
813        Ok((changed, Caught::Full))
814    }
815
816    /// Same base file: read the log from `pos` (a frame boundary already
817    /// consumed) and publish the new frames. Returns whether anything became
818    /// visible and the new consumed offset.
819    fn reload_wal_tail(&self, pos: u64) -> Result<(bool, u64)> {
820        use std::io::{Read as _, Seek as _, SeekFrom};
821        let mut tail = Vec::new();
822        match fs::File::open(wal_path(&self.dir)) {
823            Ok(mut f) => {
824                f.seek(SeekFrom::Start(pos))?;
825                f.read_to_end(&mut tail)?;
826            }
827            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((false, pos)),
828            Err(e) => return Err(e.into()),
829        }
830        #[cfg(test)]
831        probe::bump(&probe::TAIL_BYTES, tail.len() as u64);
832        let (ops, used) = wal::replay_frames(&tail)?;
833        Ok((self.publish_new(ops)?, pos + used as u64))
834    }
835
836    /// Publish `ops` (in sequence order) that the served generation has not
837    /// seen: sequence above both the base's folded ops and the overlay's last.
838    fn publish_new(&self, ops: Vec<(u64, wal::Op)>) -> Result<bool> {
839        let cur = self.current.load(Ordering::Acquire);
840        let guard = self.gens[cur].inner.try_read().map_err(|_| Error::busy("slot busy"))?;
841        let Some(g) = guard.as_ref() else { return Ok(false) };
842        let seen = g.dir.header.wal_applied_seq.max(g.arena.last_seq());
843        let mut changed = false;
844        for (seq, op) in ops.into_iter().filter(|&(s, _)| s > seen) {
845            if !g.arena.publish(seq, op) {
846                return Err(Error::busy("overlay arena full"));
847            }
848            changed = true;
849        }
850        Ok(changed)
851    }
852
853    /// Publish the frames of a log image that the current generation has not
854    /// seen. `None` when the image belongs to another generation.
855    fn apply_wal_bytes(&self, raw: &[u8]) -> Result<Option<bool>> {
856        if raw.is_empty() {
857            return Ok(Some(false));
858        }
859        let replayed = wal::replay(raw)?;
860        if replayed.base_generation != self.generation() {
861            return Ok(None);
862        }
863        self.publish_new(replayed.ops).map(Some)
864    }
865
866    /// Map the base and read the log, retrying across a compaction caught
867    /// between its renames (under the commit lock there is none: a newer log
868    /// is damage). Returns whether anything changed, the log offset consumed,
869    /// whether the log belongs to the base and the base's generation.
870    fn reload_full(&self, locked: bool) -> Result<(bool, u64, bool, u64)> {
871        for _ in 0..RACE_RETRIES {
872            let map = MappedFile::open(&base_path(&self.dir))?;
873            let base_gen = base::Header::parse(map.bytes())?.generation;
874            let raw = match fs::read(wal_path(&self.dir)) {
875                Ok(raw) => raw,
876                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
877                Err(e) => return Err(e.into()),
878            };
879            if let Some(changed) = self.install_or_apply(map, &raw)? {
880                let current = wal::parse_header(&raw).ok() == Some(base_gen);
881                let pos = if current { wal::committed_len(&raw) } else { wal::HEADER_LEN };
882                return Ok((changed, pos as u64, current, base_gen));
883            }
884            if locked {
885                return Err(Error::corrupt("wal is newer than base"));
886            }
887        }
888        Err(Error::busy("compaction in progress; retry"))
889    }
890
891    /// Same generation as served: publish new frames. Otherwise rebuild the
892    /// generation from `map` + `raw` and install it into a free ring slot.
893    /// `None` when the log is newer than `map` (a compaction between the
894    /// reads): the caller re-reads.
895    fn install_or_apply(&self, map: MappedFile, raw: &[u8]) -> Result<Option<bool>> {
896        let cur_gen = self.generation();
897        let new_gen = base::Header::parse(map.bytes())?.generation;
898        if new_gen == cur_gen {
899            // Generations are never reused, so the same number is the same
900            // base: only the log can have grown.
901            return match self.apply_wal_bytes(raw)? {
902                Some(changed) => Ok(Some(changed)),
903                // Another generation's log: newer means a compaction landed
904                // between the reads (re-read); older is already folded in.
905                None if wal::replay(raw)?.base_generation > new_gen => Ok(None),
906                None => Ok(Some(false)),
907            };
908        }
909        let built = build_generation(map, raw)?;
910        if built.log == LogState::Newer {
911            return Ok(None);
912        }
913        let folded = built.inner.dir.header.wal_applied_seq;
914        match self.install(built.inner, new_gen) {
915            Ok(()) => Ok(Some(true)),
916            // Every other slot is pinned by a snapshot. If the served
917            // generation already holds every op the new base folded (this
918            // handle's own compaction, typically), keep serving it and
919            // publish the new base's log on top: the same state, one
920            // generation behind on disk. Otherwise there is no way to serve
921            // it yet.
922            Err(busy) => {
923                if folded > self.served_seq()? {
924                    return Err(busy);
925                }
926                match built.log {
927                    LogState::Current { .. } => self.publish_new(wal::replay(raw)?.ops).map(Some),
928                    _ => Ok(Some(false)), // nothing past what the base folded
929                }
930            }
931        }
932    }
933
934    /// The last sequence number the served generation holds.
935    fn served_seq(&self) -> Result<u64> {
936        let cur = self.current.load(Ordering::Acquire);
937        let guard = self.gens[cur].inner.try_read().map_err(|_| Error::busy("slot busy"))?;
938        Ok(guard.as_ref().map_or(0, |g| g.dir.header.wal_applied_seq.max(g.arena.last_seq())))
939    }
940
941    /// Install a loaded generation into a free ring slot and serve it. The
942    /// one install path: catch-ups and compaction both come through here.
943    pub(crate) fn install(&self, inner: GenInner, generation: u64) -> Result<()> {
944        let cur = self.current.load(Ordering::Acquire);
945        for (i, slot) in self.gens.iter().enumerate() {
946            if i == cur {
947                continue;
948            }
949            if let Ok(mut w) = slot.inner.try_write() {
950                *w = Some(inner);
951                drop(w);
952                slot.seq.store(generation, Ordering::Release);
953                self.current.store(i, Ordering::Release);
954                return Ok(());
955            }
956        }
957        Err(Error::busy("all generation slots pinned by snapshots"))
958    }
959}
960
961/// Take `dir`'s commit lock if it is free this instant (no waiting).
962fn lock_now(dir: &Path) -> Result<Option<Held>> {
963    match lock_dir(dir, Duration::ZERO) {
964        Ok(h) => Ok(Some(h)),
965        Err(Error::Locked) => Ok(None),
966        Err(e) => Err(e),
967    }
968}
969
970fn newer_image() -> Error {
971    Error::busy("log image is newer than its base; fetch the base again")
972}
973
974/// A generation rebuilt from a base image and a log image, before any disk
975/// side effects.
976struct Built {
977    inner: GenInner,
978    next_seq: u64,
979    replayed: usize,
980    good_len: usize,
981    log: LogState,
982}
983
984/// What the log image turned out to be.
985#[derive(Clone, Copy, PartialEq, Eq)]
986enum LogState {
987    /// No log at all.
988    Empty,
989    /// A log from before the base's generation: every op is already folded.
990    Stale,
991    /// The base's own log; `torn` when bytes follow the last intact frame.
992    Current { torn: bool },
993    /// A log of a later generation than the base: a compaction happened
994    /// between reading the two (readers re-read; under the lock, damage).
995    Newer,
996}
997
998/// Validate `map` as a base and replay `raw` against it. Pure: shared by the
999/// file-backed and in-memory (image) paths.
1000fn build_generation(map: MappedFile, raw: &[u8]) -> Result<Built> {
1001    let bdir = base::BaseDir::parse(map.bytes())?;
1002    let header = bdir.header;
1003    let inner = GenInner { map, dir: bdir, arena: Arena::new() };
1004    if raw.is_empty() {
1005        return Ok(Built {
1006            inner,
1007            next_seq: header.wal_applied_seq + 1,
1008            replayed: 0,
1009            good_len: 0,
1010            log: LogState::Empty,
1011        });
1012    }
1013    let replayed = wal::replay(raw)?;
1014    if replayed.base_generation > header.generation {
1015        return Ok(Built {
1016            inner,
1017            next_seq: header.wal_applied_seq + 1,
1018            replayed: 0,
1019            good_len: 0,
1020            log: LogState::Newer,
1021        });
1022    }
1023    if replayed.base_generation < header.generation {
1024        return Ok(Built {
1025            inner,
1026            next_seq: header.wal_applied_seq + 1,
1027            replayed: 0,
1028            good_len: wal::HEADER_LEN,
1029            log: LogState::Stale,
1030        });
1031    }
1032    let torn = replayed.good_len < raw.len();
1033    let next_seq = replayed
1034        .ops
1035        .last()
1036        .map_or(header.wal_applied_seq + 1, |&(s, _)| s.max(header.wal_applied_seq) + 1);
1037    let mut count = 0;
1038    for (seq, op) in replayed.ops.into_iter().filter(|&(s, _)| s > header.wal_applied_seq) {
1039        if !inner.arena.publish(seq, op) {
1040            return Err(Error::busy("overlay arena full during replay"));
1041        }
1042        count += 1;
1043    }
1044    Ok(Built {
1045        inner,
1046        next_seq,
1047        replayed: count,
1048        good_len: replayed.good_len,
1049        log: LogState::Current { torn },
1050    })
1051}
1052
1053/// Load `graph.base` + replay `graph.wal` for `dir`. Pure reading: repair is
1054/// a committer's job, under the commit lock. A log newer than its base is
1055/// re-read (a compaction caught between its renames) and then `Busy` —
1056/// unless the caller holds the lock (`locked`), where it is damage.
1057fn load_generation(dir: &Path, locked: bool) -> Result<Built> {
1058    let wal_file = wal_path(dir);
1059    for _ in 0..RACE_RETRIES {
1060        let map = MappedFile::open(&base_path(dir))?;
1061        let raw = match fs::read(&wal_file) {
1062            Ok(raw) => raw,
1063            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
1064            Err(e) => return Err(e.into()),
1065        };
1066        let built = build_generation(map, &raw)?;
1067        if built.log != LogState::Newer {
1068            return Ok(built);
1069        }
1070        if locked {
1071            return Err(Error::corrupt("wal is newer than base"));
1072        }
1073    }
1074    Err(Error::busy("compaction in progress; retry"))
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079    use super::*;
1080    use crate::writer::DocRecord;
1081    use crate::UrlKey;
1082    use std::time::{Duration, Instant};
1083
1084    fn doc(url: &str, hash: u64) -> DocRecord<'_> {
1085        DocRecord {
1086            url,
1087            url_key: UrlKey::of(url),
1088            content_hash: hash,
1089            fetched_at_ms: 1,
1090            title: None,
1091            snippet: None,
1092            etag: None,
1093            pinned: false,
1094        }
1095    }
1096
1097    fn write(store: &Store, url: &str, hash: u64) {
1098        let mut w = store.writer().unwrap();
1099        w.upsert_node(&doc(url, hash)).unwrap();
1100        w.commit().unwrap();
1101    }
1102
1103    /// A snapshot is frozen at the moment it is taken. Commits landing afterwards
1104    /// are invisible to it, which is the whole reason readers never need a lock.
1105    #[test]
1106    fn a_snapshot_does_not_observe_later_commits() {
1107        let dir = tempfile::tempdir().unwrap();
1108        let store = Store::create(dir.path(), Config::default()).unwrap();
1109        write(&store, "https://x.dev/a", 1);
1110
1111        let before = store.snapshot();
1112        assert_eq!(before.len(), 1);
1113
1114        write(&store, "https://x.dev/b", 2);
1115        assert_eq!(before.len(), 1, "an existing snapshot must not grow");
1116        assert_eq!(store.snapshot().len(), 2, "a fresh snapshot sees the commit");
1117    }
1118
1119    /// Compaction installs a new generation into a free ring slot and swaps the
1120    /// current pointer. A snapshot taken beforehand keeps reading the generation
1121    /// it pinned, untouched, until it drops.
1122    #[test]
1123    fn compaction_leaves_an_existing_snapshot_on_its_own_generation() {
1124        let dir = tempfile::tempdir().unwrap();
1125        let store = Store::create(dir.path(), Config::default()).unwrap();
1126        write(&store, "https://x.dev/a", 1);
1127        store.compact().unwrap();
1128
1129        let pinned = store.snapshot();
1130        let pinned_gen = pinned.generation();
1131
1132        write(&store, "https://x.dev/b", 2);
1133        store.compact().unwrap();
1134
1135        assert_eq!(pinned.generation(), pinned_gen, "a pinned generation must not move");
1136        assert_eq!(pinned.len(), 1, "the old generation still reads its own state");
1137        let fresh = store.snapshot();
1138        assert!(fresh.generation() > pinned_gen, "the store advanced");
1139        assert_eq!(fresh.len(), 2);
1140    }
1141
1142    /// The generation number increments once per compaction and is what the WAL
1143    /// checks itself against on replay.
1144    #[test]
1145    fn each_compaction_advances_the_generation_exactly_once() {
1146        let dir = tempfile::tempdir().unwrap();
1147        let store = Store::create(dir.path(), Config::default()).unwrap();
1148        write(&store, "https://x.dev/a", 1);
1149
1150        let mut prev = store.snapshot().generation();
1151        for _ in 0..6 {
1152            store.compact().unwrap();
1153            let now = store.snapshot().generation();
1154            assert_eq!(now, prev + 1, "a compaction must advance the generation by one");
1155            prev = now;
1156        }
1157    }
1158
1159    /// The ring is finite. Holding a snapshot on every slot leaves compaction
1160    /// nowhere to install the new generation — a documented, retryable refusal
1161    /// rather than a stall or a corrupted swap. The new base is already durable
1162    /// on disk, so the retry only has to redo the in-memory install.
1163    #[test]
1164    fn exhausting_the_generation_ring_is_a_retryable_refusal() {
1165        let dir = tempfile::tempdir().unwrap();
1166        let store = Store::create(dir.path(), Config::default()).unwrap();
1167        write(&store, "https://x.dev/a", 1);
1168
1169        // Compaction never reuses the current slot, so with GEN_SLOTS slots it
1170        // can install GEN_SLOTS - 1 new generations while each previous one
1171        // stays pinned. Pin before every compaction so none can be recycled.
1172        let mut pins = Vec::new();
1173        for round in 0..GEN_SLOTS - 1 {
1174            pins.push(store.snapshot());
1175            store.compact().unwrap_or_else(|e| panic!("round {round} should still fit: {e}"));
1176        }
1177
1178        // Pinning the newest generation too leaves the next install nowhere to go.
1179        pins.push(store.snapshot());
1180        assert!(
1181            matches!(store.compact(), Err(Error::Busy { .. })),
1182            "a full ring must refuse with Busy, not stall or corrupt the swap"
1183        );
1184
1185        // Releasing the pins makes the very same call succeed.
1186        drop(pins);
1187        store.compact().expect("compaction must succeed once a slot frees up");
1188        assert_eq!(store.snapshot().len(), 1);
1189    }
1190
1191    /// A read-only handle must be able to pick up another process's work without
1192    /// reopening, and report whether anything actually changed.
1193    #[test]
1194    fn a_read_only_handle_reloads_a_newer_generation() {
1195        let dir = tempfile::tempdir().unwrap();
1196        let store = Store::create(dir.path(), Config::default()).unwrap();
1197        write(&store, "https://x.dev/a", 1);
1198        store.compact().unwrap();
1199
1200        let reader = Store::open_read_only(dir.path()).unwrap();
1201        assert_eq!(reader.snapshot().len(), 1);
1202        assert!(!reader.reload_if_stale().unwrap(), "nothing has changed yet");
1203
1204        write(&store, "https://x.dev/b", 2);
1205        store.compact().unwrap();
1206
1207        assert!(reader.reload_if_stale().unwrap(), "a new generation is visible");
1208        assert_eq!(reader.snapshot().len(), 2);
1209    }
1210
1211    // ---- several writers ---------------------------------------------------
1212
1213    fn quick() -> Config {
1214        Config { lock_wait: Duration::from_millis(30), ..Config::default() }
1215    }
1216
1217    fn urls(store: &Store) -> Vec<String> {
1218        let mut v = Vec::new();
1219        store.snapshot().for_each_live(|n| v.push(n.url.to_owned()));
1220        v.sort();
1221        v
1222    }
1223
1224    /// A second handle's commits land in the log; this handle's next commit
1225    /// catches up first — reads just those frames, takes the next sequence
1226    /// number after them, and serves both.
1227    #[test]
1228    fn a_commit_catches_up_with_a_grown_log_first() {
1229        let dir = tempfile::tempdir().unwrap();
1230        let a = Store::create(dir.path(), Config::default()).unwrap();
1231        let b = Store::open(dir.path()).unwrap();
1232        write(&a, "https://x.dev/a", 1);
1233        write(&b, "https://x.dev/b", 2);
1234        write(&a, "https://x.dev/c", 3);
1235        assert_eq!(urls(&a), ["https://x.dev/a", "https://x.dev/b", "https://x.dev/c"]);
1236        let raw = fs::read(wal_path(dir.path())).unwrap();
1237        let seqs: Vec<u64> = wal::replay(&raw).unwrap().ops.iter().map(|o| o.0).collect();
1238        assert_eq!(seqs, [1, 2, 3], "one sequence across both writers");
1239        assert!(b.reload_if_stale().unwrap(), "a writable handle reloads too");
1240        assert_eq!(urls(&b), urls(&a));
1241    }
1242
1243    /// Another handle compacts: this handle's next commit installs the new
1244    /// base before appending to the new log, never to the replaced one.
1245    #[test]
1246    fn a_commit_catches_up_after_another_handle_compacted() {
1247        let dir = tempfile::tempdir().unwrap();
1248        let a = Store::create(dir.path(), Config::default()).unwrap();
1249        let b = Store::open(dir.path()).unwrap();
1250        write(&a, "https://x.dev/a", 1); // a's log handle is now open
1251        write(&b, "https://x.dev/b", 2);
1252        b.compact().unwrap();
1253        let generation = b.generation();
1254        write(&a, "https://x.dev/c", 3);
1255        assert_eq!(a.generation(), generation, "the compacted base was installed");
1256        drop((a, b));
1257        let fresh = Store::open_read_only(dir.path()).unwrap();
1258        assert_eq!(urls(&fresh), ["https://x.dev/a", "https://x.dev/b", "https://x.dev/c"]);
1259    }
1260
1261    /// Past `lock_wait` a commit is a typed `Locked`, with its ops kept.
1262    #[test]
1263    fn a_commit_past_lock_wait_is_locked_and_keeps_its_ops() {
1264        let dir = tempfile::tempdir().unwrap();
1265        let a = Store::create(dir.path(), Config::default()).unwrap();
1266        let b = Store::open_cfg(dir.path(), quick()).unwrap();
1267        let held = a.commit_lock().unwrap();
1268        let mut w = b.writer().unwrap();
1269        w.upsert_node(&doc("https://x.dev/a", 1)).unwrap();
1270        let started = Instant::now();
1271        assert!(matches!(w.commit(), Err(Error::Locked)));
1272        assert!(started.elapsed() >= Duration::from_millis(30), "it waited lock_wait");
1273        let third = Store::open_cfg(dir.path(), quick()).unwrap();
1274        assert!(matches!(third.compact(), Err(Error::Locked)), "compaction takes the same lock");
1275        // The non-waiting form refuses at once and never runs the closure.
1276        let patient = Store::open(dir.path()).unwrap(); // a 10 s lock_wait
1277        let started = Instant::now();
1278        let ran = std::cell::Cell::new(false);
1279        let refused = patient.try_transact(|_| {
1280            ran.set(true);
1281            Ok(())
1282        });
1283        assert!(matches!(refused, Err(Error::Locked)) && !ran.get());
1284        assert!(started.elapsed() < Duration::from_secs(5), "try_transact waited");
1285        drop(held);
1286        w.commit().unwrap();
1287        drop(w);
1288        assert_eq!(b.len(), 1);
1289    }
1290
1291    /// `Err` from the closure aborts: nothing appended, nothing visible, and
1292    /// the handle's next transaction starts clean.
1293    #[test]
1294    fn an_aborted_transaction_writes_nothing() {
1295        let dir = tempfile::tempdir().unwrap();
1296        let store = Store::create(dir.path(), Config::default()).unwrap();
1297        let before = fs::read(wal_path(dir.path())).unwrap();
1298        let r: Result<()> = store.transact(|tx| {
1299            tx.upsert_node(&doc("https://x.dev/a", 1))?;
1300            Err(Error::format("caller changed its mind"))
1301        });
1302        assert!(r.is_err());
1303        assert_eq!(fs::read(wal_path(dir.path())).unwrap(), before, "nothing appended");
1304        assert!(store.is_empty());
1305        store
1306            .transact(|tx| tx.upsert_node(&doc("https://x.dev/b", 2)))
1307            .expect("the next transaction commits");
1308        assert_eq!(urls(&store), ["https://x.dev/b"], "the aborted op never resurfaces");
1309    }
1310
1311    /// The read-modify-write a transaction exists for: allocate the next id
1312    /// from what is committed. Interleaved over two handles, no id repeats.
1313    #[test]
1314    fn transactions_allocate_ids_without_collisions() {
1315        let dir = tempfile::tempdir().unwrap();
1316        let a = Store::create(dir.path(), Config::default()).unwrap();
1317        let b = Store::open(dir.path()).unwrap();
1318        let next = |store: &Store| {
1319            store
1320                .transact(|tx| {
1321                    let id = tx.snapshot().len();
1322                    let url = format!("https://x.dev/id/{id}");
1323                    tx.upsert_node(&doc(&url, id as u64))?;
1324                    Ok(id)
1325                })
1326                .unwrap()
1327        };
1328        let ids: Vec<usize> = (0..10).map(|i| next(if i % 2 == 0 { &a } else { &b })).collect();
1329        assert_eq!(ids, (0..10).collect::<Vec<_>>());
1330    }
1331
1332    /// Counted, not clocked: an open takes no lock at all, and a transaction
1333    /// of many ops is one lock and one fsync.
1334    #[test]
1335    fn open_takes_no_lock_and_a_transaction_is_one_lock_one_fsync() {
1336        let dir = tempfile::tempdir().unwrap();
1337        drop(Store::create(dir.path(), Config::default()).unwrap());
1338        probe::take(&probe::LOCKS);
1339        let store = Store::open(dir.path()).unwrap();
1340        let other = Store::open(dir.path()).unwrap();
1341        assert_eq!(probe::take(&probe::LOCKS), 0, "open made a lock call");
1342        // And nothing is held: the lock is free for anyone.
1343        drop(lock_dir(dir.path(), Duration::ZERO).expect("the lock is free after open"));
1344        probe::take(&probe::LOCKS);
1345        probe::take(&probe::FSYNCS);
1346        store
1347            .transact(|tx| {
1348                for i in 0..100 {
1349                    tx.upsert_node(&doc(&format!("https://x.dev/{i}"), i))?;
1350                }
1351                Ok(())
1352            })
1353            .unwrap();
1354        assert_eq!(probe::take(&probe::LOCKS), 1, "one lock per transaction");
1355        assert_eq!(probe::take(&probe::FSYNCS), 1, "one fsync per transaction");
1356        assert_eq!(other.len(), 0);
1357        assert!(other.reload_if_stale().unwrap());
1358        assert_eq!(other.len(), 100);
1359    }
1360
1361    /// Counted: a catch-up reads exactly the bytes appended since the
1362    /// handle last looked, not the log.
1363    #[test]
1364    fn a_catch_up_reads_only_the_appended_bytes() {
1365        let dir = tempfile::tempdir().unwrap();
1366        let a = Store::create(dir.path(), Config::default()).unwrap();
1367        let b = Store::open(dir.path()).unwrap();
1368        for i in 0..50 {
1369            write(&a, &format!("https://x.dev/{i}"), i);
1370        }
1371        assert!(b.reload_if_stale().unwrap());
1372        let len = || fs::metadata(wal_path(dir.path())).unwrap().len();
1373        let before = len();
1374        write(&a, "https://x.dev/new", 99);
1375        let appended = len() - before;
1376        probe::take(&probe::TAIL_BYTES);
1377        write(&b, "https://x.dev/mine", 100);
1378        assert_eq!(probe::take(&probe::TAIL_BYTES), appended, "read only a's append");
1379        assert_eq!(b.len(), 52);
1380    }
1381
1382    /// A writer that dies mid-append leaves a torn tail. Whoever commits next
1383    /// cuts it off under the lock before appending — its frames are never
1384    /// written after garbage, where replay would never reach them.
1385    #[test]
1386    fn the_next_committer_repairs_a_torn_tail() {
1387        use std::io::Write as _;
1388        let dir = tempfile::tempdir().unwrap();
1389        let a = Store::create(dir.path(), Config::default()).unwrap();
1390        let b = Store::open(dir.path()).unwrap();
1391        write(&a, "https://x.dev/a", 1);
1392        let intact = fs::metadata(wal_path(dir.path())).unwrap().len();
1393        // The crashed writer's half frame.
1394        let mut f = fs::OpenOptions::new().append(true).open(wal_path(dir.path())).unwrap();
1395        f.write_all(&[0x40, 0, 0, 0, 1, 2, 3]).unwrap();
1396        drop(f);
1397        write(&b, "https://x.dev/b", 2);
1398        let raw = fs::read(wal_path(dir.path())).unwrap();
1399        assert_eq!(wal::committed_len(&raw), raw.len(), "no garbage left in the log");
1400        assert!(raw.len() as u64 > intact);
1401        drop((a, b));
1402        let fresh = Store::open_read_only(dir.path()).unwrap();
1403        assert_eq!(urls(&fresh), ["https://x.dev/a", "https://x.dev/b"]);
1404    }
1405}