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