Skip to main content

gunnar_core/
txlog.rs

1//! **The transaction log** — every mutation of durable state on the primary,
2//! in one strictly increasing sequence, appended before the state changes,
3//! fsynced per commit, torn tail detected and cut on open.
4//!
5//! This is what the twin holds. The owner's ruling (2026-09-14): *"the twin
6//! must hold each transaction sent to the master; if it has missed something
7//! it must ask the master for the missing part before continuing to append."*
8//! The design note is `.nornir/twin-transaction-log.md`; this file is the
9//! storage half of it and knows nothing about the wire.
10//!
11//! # What a transaction is
12//!
13//! One frame: a [`Kind`], a small JSON **meta** (which repository, which ref,
14//! which document — [`Meta`]), and a **blob** that is the content itself —
15//! the pack bytes a client sent, the bytes of a control-store document, an
16//! LFS object. The blob is written from the caller's buffer with one
17//! `write_vectored`, never copied into a frame first. Nothing in the log is a
18//! reference to something the twin would have to fetch elsewhere: a twin
19//! holding entries `1..=n` holds everything the primary was told in that
20//! window, and a fresh primary is rebuilt from the twin alone.
21//!
22//! # On disk
23//!
24//! `<data-root>/txlog/` holds segments named by their first sequence number,
25//! `seg-<first>.log`, each a plain run of frames:
26//!
27//! ```text
28//! magic   "GTX1"      4     header crc32c covers 0..28
29//! seq     u64 LE      8
30//! kind    u16 LE      2
31//! flags   u16 LE      2     reserved, 0
32//! meta_len u32 LE     4
33//! blob_len u64 LE     8
34//! hcrc    u32 LE      4
35//! meta    meta_len
36//! blob    blob_len
37//! pcrc    u32 LE      4     crc32c over meta ‖ blob
38//! ```
39//!
40//! Beside each segment, `seg-<first>.idx` is a run of `u64 LE` byte offsets,
41//! one per frame, so `seq → (segment, offset)` is one seek. The index is a
42//! cache: it is appended **after** the frame's fsync and is not fsynced
43//! itself. On open the last index entry is checked against the data and the
44//! index is rebuilt from the data if it disagrees. The data is the truth.
45//!
46//! # Durability and the torn tail
47//!
48//! `append` = write the frame, `fdatasync` the segment, then return the
49//! sequence number. **Group commit** (perf twin-apply-fsync, 2026-10-06): the
50//! frame is staged under the writer lock and the fdatasync is
51//! [`Log::sync_through`]'s — one leader syncs every frame staged so far, outside
52//! the writer lock, so concurrent appenders share it; a twin writes a whole
53//! fetched group with [`Pending::commit_unsynced`] and makes it durable with ONE
54//! [`Log::sync`]. Either way a frame is visible to readers, indexed and counted in
55//! the head only once it is durable — the head is what a twin acknowledges.
56//! A crash before the fsync returns can leave a partial
57//! frame at the end of the last segment and nowhere else; `open` walks from
58//! the last indexed frame, verifies every frame it finds (header crc, both
59//! lengths against the file, payload crc, expected seq), and truncates the
60//! file at the first bad byte. Anything after a torn frame is unreachable
61//! anyway, and an append after garbage would never be read back.
62//!
63//! # Why not `arrow_log` / `history`
64//!
65//! `arrow_log` has the durability machinery (group commit, torn-tail cut) but
66//! is retired, per-repository, keeps no persisted sequence and no streaming
67//! reader, and its own manifest says it goes when the legacy secret log is
68//! deleted. `history` derives a `push_seq` at read time per repository. The
69//! bus in `gunnar-grpc` has a global `seq` but *"there is no durable log and
70//! this is not one"*. A twin that must hold every transaction needs one
71//! persisted, global, gap-free number, and that is this module's whole job.
72//!
73//! # Sequential by design
74//!
75//! Every write here is an append and every read is a forward scan or one
76//! seek — the twin sits on an HDD-tier volume and the log is laid out for
77//! it. No B-tree, no random rewrite, no compaction that copies. Retention is
78//! whole-segment deletion below a floor ([`Log::truncate_before`]).
79
80/// The tee: the decorators that make every store write a frame first.
81#[path = "txlog_tee.rs"]
82pub mod tee;
83
84use std::fs::{self, File, OpenOptions};
85use std::io::{self, Read, Seek, SeekFrom, Write};
86use std::path::{Path, PathBuf};
87use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
88use std::sync::{Arc, Mutex, Weak};
89
90use serde::{Deserialize, Serialize};
91
92const MAGIC: [u8; 4] = *b"GTX1";
93const HEADER_LEN: usize = 32;
94const TRAILER_LEN: usize = 4;
95const SEG_PREFIX: &str = "seg-";
96const SEG_EXT: &str = "log";
97const IDX_EXT: &str = "idx";
98
99/// Frames are appended to the current segment until it passes this size;
100/// the next append starts a new one. Retention deletes whole segments, so
101/// this is the granularity of "what may be truncated".
102pub const DEFAULT_SEGMENT_BYTES: u64 = 1 << 30;
103
104/// The kinds of transaction, one per mutation seam. The number is the wire
105/// and disk value; **never renumber, only add**.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
107#[repr(u16)]
108pub enum Kind {
109    /// A pack landed in a repository: blob = the pack bytes as the client
110    /// sent them. Meta: [`Meta::Pack`].
111    Pack = 1,
112    /// One or more reference edits in a repository, every ref kind — heads,
113    /// tags, notes, remotes, salvage, superseded, and `HEAD`. No blob.
114    /// Meta: [`Meta::Refs`].
115    Refs = 2,
116    /// A content-addressed blob of the control store (a document shard or a
117    /// document root). Blob = the bytes; meta carries the sha-256 id.
118    ControlBlob = 3,
119    /// A control-store document pointer moved (`commit_doc`): the catalog
120    /// (repositories, visibility, tombstones), the identity table
121    /// (principals, keys, grants, tokens, memberships, gates), PKI, mirrors,
122    /// flow, pulls, policy, entitlements. No blob; the bytes arrived as
123    /// [`Kind::ControlBlob`] entries before it.
124    ControlDoc = 4,
125    /// A sealed secret was written or tombstoned. Blob = the sealed bytes
126    /// exactly as the secret store holds them (sealed with the box's KEK).
127    Secret = 5,
128    /// An LFS object landed. Blob = the object bytes. Meta: [`Meta::Lfs`].
129    LfsPut = 6,
130    /// An LFS object was removed. No blob.
131    LfsDelete = 7,
132    /// A repository's object store was created or purged on disk (the
133    /// catalog row is a `ControlDoc`; this is the store beside it).
134    RepoStore = 8,
135    /// **The box's key-encrypting key** — the first frame of a primary's log,
136    /// so a restore into a fresh box can read every `Secret` frame after it.
137    /// Blob = the 32 KEK bytes exactly as `secrets.kek` holds them. The twin
138    /// holds it: a twin that held every sealed secret and not the key would be
139    /// a backup of nothing.
140    Kek = 9,
141    /// One line appended to an append-only signed log under the data root
142    /// (attestations, transparency heads, forge events, build records,
143    /// membership). Blob = the line without its newline. Meta: [`Meta::Append`].
144    Append = 10,
145    /// A whole document under the data root replaced or removed
146    /// (`replication.json`, `webhooks.json`). Blob = the bytes. Meta: [`Meta::File`].
147    File = 11,
148    /// **Never in a log**: the terminator of a twin SNAPSHOT stream
149    /// (`Mirror.SnapshotFetch`, `Bootstrap.Restore`). Every item before it is an
150    /// entry of the kinds above with `seq` 0; this one says at which sequence the
151    /// state they carry was taken. Meta: [`Meta::SnapshotAt`].
152    SnapshotAt = 12,
153    /// **A frame whose content was ERASED in place** ([`Log::erase`]): it was a
154    /// `Pack`, `Refs`, `LfsPut` or `LfsDelete` of a repository whose account was
155    /// deleted. The meta is the original one (it still names the repository);
156    /// the blob reads as empty and its bytes are gone from the file. Never
157    /// appended — only ever written over a frame that is already there, so the
158    /// sequence, the offsets and the twin's gap rule stay exactly as they were.
159    /// Applying one does nothing: the data it carried is what was deleted.
160    Redacted = 13,
161    /// **An account's data was deleted** (owner ruling g-nonpay, 2026-09-29):
162    /// every frame BEFORE this one of the repositories it names is erased
163    /// ([`Log::erase`]) — on the primary when it appends it, on the twin when it
164    /// stores it, on a box being restored when it replays it. No blob.
165    /// Meta: [`Meta::Erase`].
166    Erase = 14,
167    /// **Last-seen stamps** (owner, 2026-10-21): the principals whose last
168    /// sign-in or last operation moved since the previous flush, ONE frame per
169    /// flush ([`crate::activity`]). Blob = JSON, principal id → stamps. Applied
170    /// on the twin into the same control-database table; a snapshot carries
171    /// the whole table as one item of this kind. Meta: [`Meta::Activity`].
172    Activity = 15,
173}
174
175impl Kind {
176    /// The disk value back to a kind. `None` for a value this build does
177    /// not know — a newer primary's frame; the twin still stores it.
178    pub fn from_u16(v: u16) -> Option<Self> {
179        Some(match v {
180            1 => Kind::Pack,
181            2 => Kind::Refs,
182            3 => Kind::ControlBlob,
183            4 => Kind::ControlDoc,
184            5 => Kind::Secret,
185            6 => Kind::LfsPut,
186            7 => Kind::LfsDelete,
187            8 => Kind::RepoStore,
188            9 => Kind::Kek,
189            10 => Kind::Append,
190            11 => Kind::File,
191            12 => Kind::SnapshotAt,
192            13 => Kind::Redacted,
193            14 => Kind::Erase,
194            15 => Kind::Activity,
195            _ => return None,
196        })
197    }
198}
199
200/// One reference edit as the log records it: name, the value expected before
201/// (`None` = must not exist, `Some("")` = no claim), the value after
202/// (`None` = delete). Oids are hex; a symbolic target is `ref:<name>`.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct RefEdit {
205    /// Full ref name, or `HEAD`.
206    pub name: String,
207    /// `None` = no claim about the previous value (a plain `put_refs`);
208    /// `Some(None)` = must not have existed; `Some(Some(oid))` = must have
209    /// been `oid`.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub old: Option<Option<String>>,
212    /// `None` deletes.
213    pub new: Option<String>,
214    /// Peeled target of an annotated tag, hex.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub peeled: Option<String>,
217    /// Symbolic target, full ref name, when `new` is a symref.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub symref: Option<String>,
220}
221
222/// The typed meta of a frame. Serialized as JSON; every variant is additive.
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(tag = "k", rename_all = "snake_case")]
225pub enum Meta {
226    /// [`Kind::Pack`].
227    Pack {
228        /// Repository key, `namespace/name`.
229        repo: String,
230        /// Hash kind of the repository, `sha1` or `sha256`.
231        hash: String,
232    },
233    /// [`Kind::Refs`].
234    Refs {
235        /// Repository key.
236        repo: String,
237        /// Whether the batch is all-or-nothing (`put_refs_cas`).
238        atomic: bool,
239        /// The edits, in order.
240        edits: Vec<RefEdit>,
241    },
242    /// [`Kind::ControlBlob`].
243    ControlBlob {
244        /// sha-256 of the blob, hex.
245        id: String,
246    },
247    /// [`Kind::ControlDoc`].
248    ControlDoc {
249        /// Document name, e.g. `refs/gunnar/auth`.
250        name: String,
251        /// Blob id the pointer held before, hex, `None` = absent.
252        expected: Option<String>,
253        /// Blob id it holds now, hex.
254        new: String,
255    },
256    /// [`Kind::Secret`].
257    Secret {
258        /// Secret key.
259        key: String,
260        /// `true` = tombstone (the blob is empty).
261        deleted: bool,
262    },
263    /// [`Kind::LfsPut`] and [`Kind::LfsDelete`].
264    Lfs {
265        /// Repository key.
266        repo: String,
267        /// Object id, hex.
268        oid: String,
269        /// Size in bytes.
270        size: u64,
271    },
272    /// [`Kind::RepoStore`].
273    RepoStore {
274        /// Repository key.
275        repo: String,
276        /// `created` or `purged`.
277        op: String,
278        /// Hash kind.
279        hash: String,
280    },
281    /// [`Kind::Kek`]. No fields: the blob is the key.
282    Kek {},
283    /// [`Kind::Append`].
284    Append {
285        /// The log's path relative to the data root, e.g. `attestations.jsonl`.
286        log: String,
287    },
288    /// [`Kind::File`].
289    File {
290        /// The document's path relative to the data root.
291        path: String,
292        /// `true` = removed (the blob is empty).
293        deleted: bool,
294    },
295    /// [`Kind::Activity`]. No fields: the blob is the batch.
296    Activity {},
297    /// [`Kind::SnapshotAt`].
298    SnapshotAt {
299        /// The state the snapshot's items carry is the log's state through this sequence.
300        at: u64,
301    },
302    /// [`Kind::Erase`].
303    Erase {
304        /// The account whose data went.
305        account: String,
306        /// The repository keys (`namespace/name`) whose frames before this one are erased —
307        /// exactly the stores the purge removed, never a prefix of them.
308        repos: Vec<String>,
309        /// Why, for the audit trail.
310        reason: String,
311    },
312}
313
314impl Meta {
315    /// The repository a data frame's meta names — `Pack`, `Refs`, `Lfs`, `RepoStore`.
316    pub fn repo(&self) -> Option<&str> {
317        match self {
318            Meta::Pack { repo, .. }
319            | Meta::Refs { repo, .. }
320            | Meta::Lfs { repo, .. }
321            | Meta::RepoStore { repo, .. } => Some(repo),
322            _ => None,
323        }
324    }
325}
326
327/// What [`Log::erase`] did.
328#[derive(Debug, Clone, Default, PartialEq, Eq)]
329pub struct Erased {
330    /// Frames rewritten as [`Kind::Redacted`] by this call.
331    pub frames: u64,
332    /// Blob bytes gone from the files.
333    pub bytes: u64,
334}
335
336impl Meta {
337    /// Serialize to the bytes the frame carries.
338    pub fn to_bytes(&self) -> Vec<u8> {
339        serde_json::to_vec(self).expect("Meta serializes")
340    }
341
342    /// Parse a frame's meta.
343    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
344        serde_json::from_slice(bytes).map_err(|e| Error::BadMeta(e.to_string()))
345    }
346}
347
348/// A frame read back.
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct Entry {
351    /// Its sequence number.
352    pub seq: u64,
353    /// Kind as stored (a twin keeps kinds it does not know).
354    pub kind: u16,
355    /// Meta bytes (JSON).
356    pub meta: Vec<u8>,
357    /// Blob bytes.
358    pub blob: Vec<u8>,
359}
360
361impl Entry {
362    /// The typed kind, if this build knows it.
363    pub fn kind(&self) -> Option<Kind> {
364        Kind::from_u16(self.kind)
365    }
366    /// The typed meta.
367    pub fn meta(&self) -> Result<Meta> {
368        Meta::from_bytes(&self.meta)
369    }
370}
371
372/// Where a frame's blob lies, for a reader that streams it in chunks rather
373/// than loading it.
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub struct Located {
376    /// Sequence number.
377    pub seq: u64,
378    /// Kind as stored.
379    pub kind: u16,
380    /// Meta bytes.
381    pub meta: Vec<u8>,
382    /// The segment file the blob is in.
383    pub path: PathBuf,
384    /// Byte offset of the blob inside that file.
385    pub blob_offset: u64,
386    /// Blob length.
387    pub blob_len: u64,
388    /// **The frame's payload crc32c (meta ‖ blob)** — `Some` only on the LAST piece (restore-large-
389    /// entry, 2026-10-07): what a sender stamps on the entry's last chunk so a box that put the
390    /// entry together across several streams applies it only if it IS the entry. From
391    /// [`Log::read_pieces`] it is CHECKED: the crc over the bytes read matched the trailer. From
392    /// [`Log::read_pieces_from`] at an offset it is the trailer AS STORED, unchecked here — the
393    /// bytes below the offset were not read — and the receiver, which holds the whole entry,
394    /// checks it. `None` from [`Log::locate_blob`] and on an erased frame (its crc was over bytes
395    /// now gone).
396    pub payload_crc: Option<u32>,
397}
398
399/// Errors of the log.
400#[derive(Debug, thiserror::Error)]
401pub enum Error {
402    /// I/O.
403    #[error("txlog: {0}")]
404    Io(#[from] io::Error),
405    /// A frame's meta did not parse.
406    #[error("txlog: bad meta: {0}")]
407    BadMeta(String),
408    /// The requested sequence is below what the log retains.
409    #[error("txlog: seq {seq} not retained (first retained is {first})")]
410    NotRetained {
411        /// What was asked for.
412        seq: u64,
413        /// The oldest sequence still on disk.
414        first: u64,
415    },
416    /// The requested sequence is above the head.
417    #[error("txlog: seq {seq} is beyond head {head}")]
418    BeyondHead {
419        /// What was asked for.
420        seq: u64,
421        /// The newest committed sequence.
422        head: u64,
423    },
424    /// A frame in the middle of a segment (not the tail) is corrupt.
425    #[error("txlog: corrupt frame at {path}:{offset}: {what}")]
426    Corrupt {
427        /// Segment.
428        path: PathBuf,
429        /// Offset of the frame.
430        offset: u64,
431        /// What was wrong.
432        what: &'static str,
433    },
434    /// The log's files are RELEASED ([`Log::release`]): the data set under it is being grown
435    /// and unmounted, so nothing may write until [`Log::reacquire`].
436    #[error("txlog: released — the files under {0} are given up while the data set is grown; writes resume after the remount")]
437    Released(PathBuf),
438    /// An append was asked to start at a sequence that is not `head + 1`.
439    #[error("txlog: append out of order: got {got}, expected {expected}")]
440    OutOfOrder {
441        /// The sequence offered.
442        got: u64,
443        /// The one the log would accept.
444        expected: u64,
445    },
446    /// This log belongs to a TWIN: it holds the primary's numbers and
447    /// originates none. A local mutation on a twin is refused here, loudly,
448    /// rather than stealing a sequence number from the primary.
449    #[error("txlog: this box is a twin (or restoring); it originates no transactions")]
450    Follower,
451    /// The segment directory holds a file the layout does not describe.
452    #[error("txlog: unexpected file {0}")]
453    Unexpected(PathBuf),
454    /// **The log is POISONED** (stinky rows 3 and 6, 2026-10-10): a frame's `fdatasync` failed,
455    /// or the cut of a half-written frame did. After a failed fsync the kernel may have dropped
456    /// the dirty pages and the NEXT fsync succeeds without them (fsyncgate) — a frame its writer
457    /// was told had failed would then be published by the next leader and applied by the twin.
458    /// After a failed cut the next frame would land behind garbage. So nothing more is written:
459    /// every append refuses with this until the log is reopened, which recovers from the last
460    /// durable frame.
461    #[error("txlog: poisoned under {dir} — {cause}; every write refuses until the log is reopened from its last durable frame")]
462    Poisoned {
463        /// The log's directory.
464        dir: PathBuf,
465        /// What failed.
466        cause: String,
467    },
468}
469
470/// Result alias.
471pub type Result<T> = std::result::Result<T, Error>;
472
473// ── crc32c (Castagnoli), table-driven, no dependency ─────────────────────────
474
475const CRC32C_POLY: u32 = 0x82F6_3B78;
476
477const fn crc32c_table() -> [u32; 256] {
478    let mut table = [0u32; 256];
479    let mut i = 0;
480    while i < 256 {
481        let mut c = i as u32;
482        let mut k = 0;
483        while k < 8 {
484            c = if c & 1 != 0 {
485                CRC32C_POLY ^ (c >> 1)
486            } else {
487                c >> 1
488            };
489            k += 1;
490        }
491        table[i] = c;
492        i += 1;
493    }
494    table
495}
496
497static CRC32C_TABLE: [u32; 256] = crc32c_table();
498
499/// Streaming crc32c.
500#[derive(Debug, Clone, Copy)]
501pub struct Crc32c(u32);
502
503impl Default for Crc32c {
504    fn default() -> Self {
505        Self::new()
506    }
507}
508
509impl Crc32c {
510    /// Fresh.
511    pub const fn new() -> Self {
512        Crc32c(0xFFFF_FFFF)
513    }
514    /// Feed bytes.
515    pub fn update(&mut self, bytes: &[u8]) {
516        let mut c = self.0;
517        for &b in bytes {
518            c = CRC32C_TABLE[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8);
519        }
520        self.0 = c;
521    }
522    /// The checksum.
523    pub fn finish(self) -> u32 {
524        self.0 ^ 0xFFFF_FFFF
525    }
526    /// The running state, to be kept with the bytes it covers and [`Crc32c::resume`]d — a
527    /// checksum carried across a restart (restore-after-snapshot: a snapshot item staged over
528    /// several streams).
529    pub fn state(&self) -> u32 {
530        self.0
531    }
532    /// Go on from a kept [`Crc32c::state`].
533    pub const fn resume(state: u32) -> Self {
534        Crc32c(state)
535    }
536}
537
538/// crc32c of one buffer.
539pub fn crc32c(bytes: &[u8]) -> u32 {
540    let mut c = Crc32c::new();
541    c.update(bytes);
542    c.finish()
543}
544
545// ── frame header ─────────────────────────────────────────────────────────────
546
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548struct Header {
549    seq: u64,
550    kind: u16,
551    meta_len: u32,
552    blob_len: u64,
553}
554
555impl Header {
556    fn encode(&self) -> [u8; HEADER_LEN] {
557        let mut h = [0u8; HEADER_LEN];
558        h[0..4].copy_from_slice(&MAGIC);
559        h[4..12].copy_from_slice(&self.seq.to_le_bytes());
560        h[12..14].copy_from_slice(&self.kind.to_le_bytes());
561        h[14..16].copy_from_slice(&0u16.to_le_bytes());
562        h[16..20].copy_from_slice(&self.meta_len.to_le_bytes());
563        h[20..28].copy_from_slice(&self.blob_len.to_le_bytes());
564        let crc = crc32c(&h[0..28]);
565        h[28..32].copy_from_slice(&crc.to_le_bytes());
566        h
567    }
568
569    fn decode(h: &[u8]) -> Option<Self> {
570        if h.len() < HEADER_LEN || h[0..4] != MAGIC {
571            return None;
572        }
573        let crc = u32::from_le_bytes(h[28..32].try_into().ok()?);
574        if crc32c(&h[0..28]) != crc {
575            return None;
576        }
577        Some(Header {
578            seq: u64::from_le_bytes(h[4..12].try_into().ok()?),
579            kind: u16::from_le_bytes(h[12..14].try_into().ok()?),
580            meta_len: u32::from_le_bytes(h[16..20].try_into().ok()?),
581            blob_len: u64::from_le_bytes(h[20..28].try_into().ok()?),
582        })
583    }
584
585    fn frame_len(&self) -> u64 {
586        HEADER_LEN as u64 + self.meta_len as u64 + self.blob_len + TRAILER_LEN as u64
587    }
588}
589
590// ── segments ─────────────────────────────────────────────────────────────────
591
592fn seg_path(dir: &Path, first: u64) -> PathBuf {
593    dir.join(format!("{SEG_PREFIX}{first:020}.{SEG_EXT}"))
594}
595
596fn idx_path(dir: &Path, first: u64) -> PathBuf {
597    dir.join(format!("{SEG_PREFIX}{first:020}.{IDX_EXT}"))
598}
599
600fn parse_seg_name(name: &str) -> Option<(u64, bool)> {
601    let rest = name.strip_prefix(SEG_PREFIX)?;
602    let (num, ext) = rest.split_once('.')?;
603    let first: u64 = num.parse().ok()?;
604    match ext {
605        SEG_EXT => Some((first, true)),
606        IDX_EXT => Some((first, false)),
607        _ => None,
608    }
609}
610
611fn sync_dir(dir: &Path) -> io::Result<()> {
612    File::open(dir)?.sync_all()
613}
614
615/// **Make `len` bytes at `at` gone**: a punched hole where the filesystem can (the
616/// space is freed and the range reads as zeros), zeros written over it where it cannot.
617fn zero_range(file: &mut File, at: u64, len: u64) -> io::Result<()> {
618    if len == 0 {
619        return Ok(());
620    }
621    #[cfg(target_os = "linux")]
622    {
623        use rustix::fs::{fallocate, FallocateFlags};
624        if fallocate(
625            &*file,
626            FallocateFlags::PUNCH_HOLE | FallocateFlags::KEEP_SIZE,
627            at,
628            len,
629        )
630        .is_ok()
631        {
632            return Ok(());
633        }
634    }
635    let zeros = vec![0u8; (1usize << 20).min(len as usize)];
636    file.seek(SeekFrom::Start(at))?;
637    let mut left = len;
638    while left > 0 {
639        let n = left.min(zeros.len() as u64) as usize;
640        file.write_all(&zeros[..n])?;
641        left -= n as u64;
642    }
643    Ok(())
644}
645
646/// One segment as the log knows it: its first sequence, its frame offsets.
647#[derive(Debug)]
648struct Segment {
649    first: u64,
650    /// Byte offset of every frame, in order. `offsets.len()` frames.
651    offsets: Vec<u64>,
652    /// Byte length of the valid prefix.
653    len: u64,
654}
655
656impl Segment {
657    fn last_seq(&self) -> Option<u64> {
658        if self.offsets.is_empty() {
659            None
660        } else {
661            Some(self.first + self.offsets.len() as u64 - 1)
662        }
663    }
664}
665
666/// Verify frames from `from` to the end of `file`, appending offsets.
667/// Returns the length of the valid prefix and whether the tail was torn.
668fn scan_frames(
669    path: &Path,
670    file: &mut File,
671    first: u64,
672    offsets: &mut Vec<u64>,
673    from: u64,
674) -> Result<(u64, bool)> {
675    let total = file.metadata()?.len();
676    let mut pos = from;
677    let mut hdr = [0u8; HEADER_LEN];
678    let mut buf = Vec::new();
679    while pos < total {
680        if total - pos < HEADER_LEN as u64 {
681            return Ok((pos, true));
682        }
683        file.seek(SeekFrom::Start(pos))?;
684        file.read_exact(&mut hdr)?;
685        let Some(h) = Header::decode(&hdr) else {
686            return Ok((pos, true));
687        };
688        let expect = first + offsets.len() as u64;
689        if h.seq != expect {
690            return Ok((pos, true));
691        }
692        let payload = h.meta_len as u64 + h.blob_len;
693        if total - pos < h.frame_len() {
694            return Ok((pos, true));
695        }
696        // An ERASED frame's payload no longer matches its checksum — by design,
697        // its bytes are gone ([`Log::erase`]). Its header crc vouched for it.
698        if h.kind == Kind::Redacted as u16 {
699            offsets.push(pos);
700            pos += h.frame_len();
701            continue;
702        }
703        // Verify the payload crc with a bounded buffer: the blob may be large.
704        let mut crc = Crc32c::new();
705        let mut remaining = payload;
706        buf.resize(1 << 20, 0);
707        while remaining > 0 {
708            let n = remaining.min(buf.len() as u64) as usize;
709            file.read_exact(&mut buf[..n])?;
710            crc.update(&buf[..n]);
711            remaining -= n as u64;
712        }
713        let mut tr = [0u8; TRAILER_LEN];
714        file.read_exact(&mut tr)?;
715        if u32::from_le_bytes(tr) != crc.finish() {
716            return Ok((pos, true));
717        }
718        let _ = path;
719        offsets.push(pos);
720        pos += h.frame_len();
721    }
722    Ok((pos, false))
723}
724
725fn open_segment(dir: &Path, first: u64, repair: bool) -> Result<Segment> {
726    let path = seg_path(dir, first);
727    let mut file = OpenOptions::new().read(true).write(repair).open(&path)?;
728    let total = file.metadata()?.len();
729
730    // Trust the index up to the last entry that verifies against the data.
731    let mut offsets: Vec<u64> = Vec::new();
732    let ipath = idx_path(dir, first);
733    if let Ok(bytes) = fs::read(&ipath) {
734        let n = bytes.len() / 8;
735        offsets.reserve(n);
736        for i in 0..n {
737            let off = u64::from_le_bytes(bytes[i * 8..i * 8 + 8].try_into().expect("8 bytes"));
738            offsets.push(off);
739        }
740        // Offsets must be strictly increasing and inside the file.
741        let monotone = offsets.windows(2).all(|w| w[0] < w[1]);
742        if !monotone || offsets.first().is_some_and(|&o| o != 0) {
743            offsets.clear();
744        }
745        while let Some(&last) = offsets.last() {
746            if last + HEADER_LEN as u64 > total {
747                offsets.pop();
748                continue;
749            }
750            file.seek(SeekFrom::Start(last))?;
751            let mut hdr = [0u8; HEADER_LEN];
752            file.read_exact(&mut hdr)?;
753            match Header::decode(&hdr) {
754                Some(h)
755                    if h.seq == first + offsets.len() as u64 - 1
756                        && last + h.frame_len() <= total =>
757                {
758                    break;
759                }
760                _ => {
761                    offsets.pop();
762                }
763            }
764        }
765    }
766
767    // Resume AT the last indexed frame, not after it: the index vouched for
768    // its header only, and the torn tail is exactly that frame — a crash
769    // between the payload write and the fsync leaves a header that decodes
770    // over a payload that does not. `scan_frames` re-verifies it in full and
771    // puts it back when it is whole.
772    let trusted = offsets.len();
773    let resume = offsets.pop().unwrap_or(0);
774    let (valid_len, torn) = scan_frames(&path, &mut file, first, &mut offsets, resume)?;
775
776    if torn && repair {
777        tracing::warn!(
778            segment = %path.display(),
779            kept = valid_len,
780            discarded = total - valid_len,
781            "txlog: torn tail cut"
782        );
783        file.set_len(valid_len)?;
784        file.sync_all()?;
785    }
786    if (torn && repair) || offsets.len() != trusted {
787        // Rewrite the index to exactly the verified frames.
788        if repair {
789            let mut bytes = Vec::with_capacity(offsets.len() * 8);
790            for off in &offsets {
791                bytes.extend_from_slice(&off.to_le_bytes());
792            }
793            fs::write(&ipath, bytes)?;
794        }
795    }
796    Ok(Segment {
797        first,
798        offsets,
799        len: valid_len,
800    })
801}
802
803// ── the log ──────────────────────────────────────────────────────────────────
804
805struct Writer {
806    /// The open segment's data file, positioned at its end.
807    file: File,
808    /// Its index file.
809    idx: File,
810    /// The open segment as the WRITER knows it: every frame committed into it, including the
811    /// ones of a group commit not yet made durable ([`Pending::commit_unsynced`]). Readers go
812    /// by [`Log::segments`] and [`Log::head_seq`], which hold only what is durable.
813    seg: Segment,
814    /// Bytes of the open segment already handed to the kernel; `buf` follows them.
815    disk_len: u64,
816    /// **Frame bytes staged in memory** (perf twin-apply-fsync, 2026-10-06): a header, its meta,
817    /// small pieces and the trailer, of one frame or of a whole group, go to the kernel as ONE
818    /// write — the twin used to spend four writes on every frame. Flushed before every fsync,
819    /// roll, release and cut, and whenever it passes [`STAGE_BYTES`].
820    buf: Vec<u8>,
821    /// How many of `seg.offsets` are durable and published (in [`Log::segments`], indexed, at or
822    /// below the head). The rest are a group commit in progress.
823    published: usize,
824    /// Test-only: injected write and truncate failures.
825    #[cfg(test)]
826    faults: WriterFaults,
827}
828
829/// Test-only failures a [`Writer`] can be told to meet.
830#[cfg(test)]
831#[derive(Default)]
832struct WriterFaults {
833    /// Fail the put after this many more succeed.
834    fail_put_after: Option<usize>,
835    /// Fail the next [`Writer::cut_to_committed`] before it touches anything.
836    fail_cut: bool,
837}
838
839/// The most frame bytes [`Writer::buf`] stages before it writes them out.
840const STAGE_BYTES: usize = 1 << 20;
841/// A piece at least this long is written straight from the caller's buffer, never copied into
842/// the stage (a 1 MiB piece of a pack has nothing to gain from being coalesced).
843const DIRECT_BYTES: usize = 64 << 10;
844
845impl Writer {
846    /// Hand every staged byte to the kernel.
847    fn flush_buf(&mut self) -> io::Result<()> {
848        if !self.buf.is_empty() {
849            self.file.write_all(&self.buf)?;
850            self.disk_len += self.buf.len() as u64;
851            self.buf.clear();
852        }
853        Ok(())
854    }
855
856    /// Stage `bytes` at the end of the segment (written straight out when large).
857    fn put(&mut self, bytes: &[u8]) -> io::Result<()> {
858        #[cfg(test)]
859        if let Some(n) = self.faults.fail_put_after {
860            if n == 0 {
861                self.faults.fail_put_after = None;
862                return Err(io::Error::other("txlog test: injected write failure"));
863            }
864            self.faults.fail_put_after = Some(n - 1);
865        }
866        if bytes.len() >= DIRECT_BYTES {
867            self.flush_buf()?;
868            self.file.write_all(bytes)?;
869            self.disk_len += bytes.len() as u64;
870            return Ok(());
871        }
872        self.buf.extend_from_slice(bytes);
873        if self.buf.len() >= STAGE_BYTES {
874            self.flush_buf()?;
875        }
876        Ok(())
877    }
878
879    /// Frames committed into the open segment and not yet durable.
880    fn unpublished(&self) -> usize {
881        self.seg.offsets.len() - self.published
882    }
883
884    /// Drop a partial frame: everything after the last committed frame goes, staged or on disk.
885    fn cut_to_committed(&mut self) -> io::Result<()> {
886        #[cfg(test)]
887        if std::mem::take(&mut self.faults.fail_cut) {
888            return Err(io::Error::other("txlog test: injected truncate failure"));
889        }
890        let keep = self.seg.len;
891        if keep >= self.disk_len {
892            // Everything past `disk_len` is staged: keep the committed part of the stage.
893            self.buf.truncate((keep - self.disk_len) as usize);
894            return Ok(());
895        }
896        // The partial frame reached the file (a large piece goes straight out); nothing is
897        // staged after it, so the stage is all partial frame.
898        self.buf.clear();
899        self.file.set_len(keep)?;
900        self.file.seek(SeekFrom::Start(keep))?;
901        self.disk_len = keep;
902        self.file.sync_data()
903    }
904}
905
906/// **The test hold of a group commit, by environment** (round 5.2, 2026-10-07): set to `n`, a
907/// leader of [`Log::sync_through`] waits until `n` frames are staged before its fsync (at most
908/// [`TEST_GROUP_WAIT`]). A test that counts the fsyncs of `n` concurrent writers (gunnar-server
909/// `tests/audit_read_io.rs`, whose writers run in a child under `strace`) sets it, so the count is
910/// of what the group commit shares, not of scheduling luck. Unset on every box: no wait at all.
911pub const TEST_GROUP_ENV: &str = "GUNNAR_TXLOG_TEST_GROUP";
912
913/// **The test hold, read only by a build with `test-hooks`** (wiring tracer B 2026-10-08): a
914/// shipped box never reads [`TEST_GROUP_ENV`], so no stray variable holds its writes.
915#[cfg(feature = "test-hooks")]
916fn test_group_from_env() -> Option<usize> {
917    std::env::var(TEST_GROUP_ENV)
918        .ok()
919        .and_then(|v| v.trim().parse::<usize>().ok())
920        .filter(|n| *n > 1)
921}
922
923#[cfg(not(feature = "test-hooks"))]
924fn test_group_from_env() -> Option<usize> {
925    None
926}
927
928/// The longest a [`TEST_GROUP_ENV`] hold waits for its group: the last writers of a test finish
929/// one by one, and a group that will never fill goes on without them.
930pub const TEST_GROUP_WAIT: std::time::Duration = std::time::Duration::from_millis(100);
931
932/// The transaction log: one writer, any number of readers, one directory.
933///
934/// `append` is O(1) in the size of the log: a positioned write at the known
935/// end of the open segment, one fsync, one 8-byte index append.
936pub struct Log {
937    dir: PathBuf,
938    /// Closed segments, oldest first, plus the open one at the end.
939    segments: Mutex<Vec<Segment>>,
940    writer: Mutex<Writer>,
941    head: AtomicU64,
942    first: AtomicU64,
943    segment_bytes: u64,
944    /// Only `append_at` / `begin(Some(_))` may write (a twin, a restore).
945    follower: AtomicBool,
946    /// The mutation gate ([`Log::mutation`] / [`Log::freeze`]).
947    gate: Arc<Gate>,
948    /// [`Log::release`] gave up the writer's files; every write refuses until [`Log::reacquire`].
949    released: AtomicBool,
950    /// [`Error::Poisoned`]: what failed. Set once, never cleared — only a reopen recovers.
951    poisoned: Mutex<Option<String>>,
952    /// **Is a group-commit leader in its fsync?** ([`Log::sync_through`]). A flag under a mutex
953    /// that is NEVER held across I/O, and a condvar the leader wakes everybody on when its fsync
954    /// is published (r5 stall review, 2026-10-07). It used to be a `Mutex<()>` HELD ACROSS the
955    /// `fdatasync`: a waiter whose frame the leader's fsync had already made durable still had to
956    /// ACQUIRE that mutex before it could see so — and an unfair mutex hands it to the next
957    /// leader first, so the waiter sat behind an unrelated fsync, then the next, for as long as
958    /// writes kept arriving, every request thread in `futex_wait`.
959    sync_state: Mutex<bool>,
960    /// Notified (all) whenever a leader ends.
961    synced: std::sync::Condvar,
962    /// Test-only: each leader takes the next gate, if any, and waits on it before its fsync.
963    #[cfg(test)]
964    fsync_gates: Mutex<std::collections::VecDeque<std::sync::mpsc::Receiver<()>>>,
965    /// Test-only: this many of the next frame fsyncs fail.
966    #[cfg(test)]
967    fail_fsyncs: std::sync::atomic::AtomicU32,
968    /// **The test hold of a group commit** ([`TEST_GROUP_ENV`]): a leader waits, at most
969    /// [`TEST_GROUP_WAIT`], until this many frames are staged before its fsync. `None` — every
970    /// box, every ordinary test — does not wait at all.
971    test_group: Option<usize>,
972    /// `fdatasync`s that made frames durable ([`Log::frame_syncs`]).
973    frame_syncs: AtomicU64,
974    /// fsyncs an erase made ([`Log::erase_syncs`]).
975    erase_syncs: AtomicU64,
976    /// One erase at a time ([`Log::erase`]); the writer lock is NOT held for it.
977    erase_lock: Mutex<()>,
978    /// Test-only: an erase takes the next gate, if any, and waits on it after its first header.
979    #[cfg(test)]
980    erase_gates: Mutex<std::collections::VecDeque<std::sync::mpsc::Receiver<()>>>,
981}
982
983/// **The mutation gate**: every framed mutation holds a [`MutationGuard`] from before its frame
984/// is appended until the store has taken it; [`Log::freeze`] stops new ones and waits for the
985/// ones in flight. While frozen, the log's head IS the stores' state — what a consistent
986/// snapshot needs (the log is written BEFORE the store, so without the wait a frame at the head
987/// could still be on its way into the store).
988#[derive(Default)]
989struct Gate {
990    state: Mutex<GateState>,
991    cv: std::sync::Condvar,
992}
993
994#[derive(Default)]
995struct GateState {
996    frozen_by: Option<std::thread::ThreadId>,
997    inflight: u64,
998}
999
1000thread_local! {
1001    /// Nested guards on one thread count once (a tee write inside another framed write).
1002    static MUTATION_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
1003}
1004
1005/// Held by a framed mutation, frame through store write. See [`Log::mutation`].
1006#[must_use = "a mutation guard must be held until the store has taken the write"]
1007pub struct MutationGuard {
1008    gate: Option<Arc<Gate>>,
1009}
1010
1011impl Drop for MutationGuard {
1012    fn drop(&mut self) {
1013        MUTATION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
1014        if let Some(gate) = self.gate.take() {
1015            let mut st = gate.state.lock().unwrap_or_else(|p| p.into_inner());
1016            st.inflight = st.inflight.saturating_sub(1);
1017            gate.cv.notify_all();
1018        }
1019    }
1020}
1021
1022/// Held while the log is frozen. See [`Log::freeze`].
1023pub struct FreezeGuard {
1024    gate: Arc<Gate>,
1025}
1026
1027impl Drop for FreezeGuard {
1028    fn drop(&mut self) {
1029        let mut st = self.gate.state.lock().unwrap_or_else(|p| p.into_inner());
1030        st.frozen_by = None;
1031        self.gate.cv.notify_all();
1032    }
1033}
1034
1035impl std::fmt::Debug for Log {
1036    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1037        f.debug_struct("Log")
1038            .field("dir", &self.dir)
1039            .field("first", &self.first_seq())
1040            .field("head", &self.head_seq())
1041            .finish()
1042    }
1043}
1044
1045/// **The one entry in the log's directory that is not a segment.**
1046///
1047/// `gunnar-server`'s `txlog_lfs` spools an arriving LFS object beside the log
1048/// — `<txlog>/spool/<oid>.part` — because the object has to be on disk before
1049/// the frame that names it is appended. It removes the FILE when the object
1050/// lands; the DIRECTORY stays, because `create_dir_all` made it and nothing
1051/// unmakes it.
1052///
1053/// It is named HERE and not there for the reason the name is load-bearing:
1054/// this module describes what may live in that directory, and [`Log::open_with`]
1055/// refuses everything else. A writer choosing a name the reader has to guess is
1056/// how one LFS push plus one restart became
1057/// `Kernel panic - not syncing: Attempted to kill init!` on the live appliance,
1058/// MEASURED 2026-09-17.
1059pub const SPOOL_DIR: &str = "spool";
1060
1061impl Log {
1062    /// Open or create the log at `dir`, cutting a torn tail if there is one.
1063    pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
1064        Self::open_with(dir, DEFAULT_SEGMENT_BYTES)
1065    }
1066
1067    /// [`Log::open`] with a segment size (tests roll segments small).
1068    pub fn open_with(dir: impl AsRef<Path>, segment_bytes: u64) -> Result<Self> {
1069        let dir = dir.as_ref().to_path_buf();
1070        fs::create_dir_all(&dir)?;
1071        let mut firsts: Vec<u64> = Vec::new();
1072        for entry in fs::read_dir(&dir)? {
1073            let entry = entry?;
1074            let name = entry.file_name();
1075            let name = name.to_string_lossy();
1076            match parse_seg_name(&name) {
1077                Some((first, true)) => firsts.push(first),
1078                Some((_, false)) => {}
1079                // The LFS spool is part of this directory's layout — see
1080                // [`SPOOL_DIR`]. Only that exact name, and only as a directory:
1081                // a stray FILE called `spool` is still something nobody wrote
1082                // on purpose, and a reader that shrugs at one shrugs at
1083                // corruption.
1084                None if name == SPOOL_DIR && entry.file_type().is_ok_and(|t| t.is_dir()) => {}
1085                None => return Err(Error::Unexpected(entry.path())),
1086            }
1087        }
1088        firsts.sort_unstable();
1089        let mut segments = Vec::with_capacity(firsts.len().max(1));
1090        let n = firsts.len();
1091        for (i, first) in firsts.iter().enumerate() {
1092            let seg = open_segment(&dir, *first, true)?;
1093            // A closed segment must be intact end to end; only the last may
1094            // have been torn.
1095            if i + 1 < n && seg.offsets.is_empty() {
1096                return Err(Error::Corrupt {
1097                    path: seg_path(&dir, *first),
1098                    offset: 0,
1099                    what: "closed segment holds no frame",
1100                });
1101            }
1102            // Adjacent segments must be contiguous.
1103            if let Some(next) = firsts.get(i + 1) {
1104                match seg.last_seq() {
1105                    Some(last) if last + 1 == *next => {}
1106                    _ => {
1107                        return Err(Error::Corrupt {
1108                            path: seg_path(&dir, *first),
1109                            offset: seg.len,
1110                            what: "segment does not run up to the next one",
1111                        })
1112                    }
1113                }
1114            }
1115            segments.push(seg);
1116        }
1117        if segments.is_empty() {
1118            let first = 1;
1119            File::create(seg_path(&dir, first))?;
1120            File::create(idx_path(&dir, first))?;
1121            sync_dir(&dir)?;
1122            segments.push(Segment {
1123                first,
1124                offsets: Vec::new(),
1125                len: 0,
1126            });
1127        }
1128        let open = segments.last().expect("one segment");
1129        let head = open.last_seq().unwrap_or(open.first - 1);
1130        let first = segments[0].first;
1131        let mut file = OpenOptions::new()
1132            .append(true)
1133            .open(seg_path(&dir, open.first))?;
1134        file.seek(SeekFrom::Start(open.len))?;
1135        let idx = OpenOptions::new()
1136            .append(true)
1137            .open(idx_path(&dir, open.first))?;
1138        let seg = Segment {
1139            first: open.first,
1140            offsets: open.offsets.clone(),
1141            len: open.len,
1142        };
1143        let published = seg.offsets.len();
1144        Ok(Log {
1145            dir,
1146            segments: Mutex::new(segments),
1147            writer: Mutex::new(Writer {
1148                file,
1149                idx,
1150                disk_len: seg.len,
1151                seg,
1152                buf: Vec::new(),
1153                published,
1154                #[cfg(test)]
1155                faults: WriterFaults::default(),
1156            }),
1157            head: AtomicU64::new(head),
1158            first: AtomicU64::new(first),
1159            segment_bytes,
1160            follower: AtomicBool::new(false),
1161            gate: Arc::new(Gate::default()),
1162            released: AtomicBool::new(false),
1163            poisoned: Mutex::new(None),
1164            sync_state: Mutex::new(false),
1165            synced: std::sync::Condvar::new(),
1166            #[cfg(test)]
1167            fsync_gates: Mutex::new(Default::default()),
1168            #[cfg(test)]
1169            fail_fsyncs: std::sync::atomic::AtomicU32::new(0),
1170            test_group: test_group_from_env(),
1171            frame_syncs: AtomicU64::new(0),
1172            erase_syncs: AtomicU64::new(0),
1173            erase_lock: Mutex::new(()),
1174            #[cfg(test)]
1175            erase_gates: Mutex::new(Default::default()),
1176        })
1177    }
1178
1179    /// **Enter a framed mutation.** Hold the guard from before the frame is appended until the
1180    /// store has the write. Waits while another thread holds [`Log::freeze`]; the freezing
1181    /// thread itself passes (a mutation it causes lands after the snapshot's sequence and is
1182    /// replayed, which every apply tolerates). Nested guards on one thread count once.
1183    pub fn mutation(&self) -> MutationGuard {
1184        let depth = MUTATION_DEPTH.with(|d| {
1185            let v = d.get();
1186            d.set(v + 1);
1187            v
1188        });
1189        if depth > 0 {
1190            return MutationGuard { gate: None };
1191        }
1192        let me = std::thread::current().id();
1193        let mut st = self.gate.state.lock().unwrap_or_else(|p| p.into_inner());
1194        while matches!(st.frozen_by, Some(t) if t != me) {
1195            st = self.gate.cv.wait(st).unwrap_or_else(|p| p.into_inner());
1196        }
1197        st.inflight += 1;
1198        MutationGuard {
1199            gate: Some(Arc::clone(&self.gate)),
1200        }
1201    }
1202
1203    /// **Freeze**: no new framed mutation starts on another thread, and this returns once every
1204    /// one in flight has reached its store. Reads are untouched. One freezer at a time. Must not
1205    /// be called from inside a mutation (it would wait for itself).
1206    pub fn freeze(&self) -> FreezeGuard {
1207        debug_assert_eq!(
1208            MUTATION_DEPTH.with(|d| d.get()),
1209            0,
1210            "freeze from inside a mutation waits for itself"
1211        );
1212        let me = std::thread::current().id();
1213        let mut st = self.gate.state.lock().unwrap_or_else(|p| p.into_inner());
1214        while st.frozen_by.is_some_and(|t| t != me) {
1215            st = self.gate.cv.wait(st).unwrap_or_else(|p| p.into_inner());
1216        }
1217        st.frozen_by = Some(me);
1218        while st.inflight > 0 {
1219            st = self.gate.cv.wait(st).unwrap_or_else(|p| p.into_inner());
1220        }
1221        FreezeGuard {
1222            gate: Arc::clone(&self.gate),
1223        }
1224    }
1225
1226    /// The directory.
1227    pub fn dir(&self) -> &Path {
1228        &self.dir
1229    }
1230
1231    /// Make this log follower-only (a twin; a box being restored): `append`
1232    /// and `begin(None)` refuse with [`Error::Follower`], `append_at` works.
1233    pub fn set_follower(&self, on: bool) {
1234        self.follower.store(on, Ordering::Release);
1235    }
1236
1237    /// See [`Log::set_follower`].
1238    pub fn is_follower(&self) -> bool {
1239        self.follower.load(Ordering::Acquire)
1240    }
1241
1242    /// The test hold of a group commit this log honours ([`TEST_GROUP_ENV`]); `None` on every
1243    /// box.
1244    pub fn group_hold(&self) -> Option<usize> {
1245        self.test_group
1246    }
1247
1248    /// The newest committed sequence; 0 when nothing has been committed.
1249    pub fn head_seq(&self) -> u64 {
1250        self.head.load(Ordering::Acquire)
1251    }
1252
1253    /// The oldest sequence still retained. `head + 1` when the log is empty.
1254    pub fn first_seq(&self) -> u64 {
1255        self.first.load(Ordering::Acquire)
1256    }
1257
1258    /// Append one transaction with the next sequence number. Durable when
1259    /// this returns.
1260    pub fn append(&self, kind: Kind, meta: &Meta, blob: &[u8]) -> Result<u64> {
1261        if self.is_follower() {
1262            return Err(Error::Follower);
1263        }
1264        let meta = meta.to_bytes();
1265        self.append_raw(kind as u16, &meta, blob)
1266    }
1267
1268    /// [`Log::append`], but the caller says which sequence number the entry
1269    /// must get — the twin's path: it holds the primary's numbers, not its
1270    /// own. Refuses anything but `head + 1`.
1271    pub fn append_at(&self, seq: u64, kind: u16, meta: &[u8], blob: &[u8]) -> Result<u64> {
1272        {
1273            let mut w = self.lock_writer()?;
1274            let expected = w.seg.first + w.seg.offsets.len() as u64;
1275            if seq != expected {
1276                return Err(Error::OutOfOrder { got: seq, expected });
1277            }
1278            self.stage_locked(&mut w, kind, meta, blob)?;
1279        }
1280        self.sync_through(seq)?;
1281        Ok(seq)
1282    }
1283
1284    /// Append with raw kind and meta bytes. Durable when this returns.
1285    ///
1286    /// **Group commit for concurrent appenders** (perf twin-apply-fsync, 2026-10-06): the frame
1287    /// is staged under the writer lock, and its fsync is [`Log::sync_through`]'s — taken OUTSIDE
1288    /// the writer lock by one leader for every frame staged so far. Eight request threads that
1289    /// append together pay about one `fdatasync` between them instead of eight; one appender alone
1290    /// pays exactly what it paid before (one write, one fdatasync, one index write).
1291    pub fn append_raw(&self, kind: u16, meta: &[u8], blob: &[u8]) -> Result<u64> {
1292        let seq = {
1293            let mut w = self.lock_writer()?;
1294            self.stage_locked(&mut w, kind, meta, blob)?
1295        };
1296        self.sync_through(seq)?;
1297        Ok(seq)
1298    }
1299
1300    /// **[`Log::append`] without waiting for durability** — for a frame whose mutation is
1301    /// invisible until a LATER frame publishes it (a control document's shard blob, published by
1302    /// the `ControlDoc` frame that moves the pointer): that frame's `fdatasync` covers this one,
1303    /// which is earlier in the same file. Refused on a follower, as `append` is.
1304    pub fn stage(&self, kind: Kind, meta: &Meta, blob: &[u8]) -> Result<u64> {
1305        if self.is_follower() {
1306            return Err(Error::Follower);
1307        }
1308        self.stage_raw(kind as u16, &meta.to_bytes(), blob)
1309    }
1310
1311    /// [`Log::append_raw`] without waiting for durability: the frame is whole in the segment and
1312    /// holds its number; nobody reads it and it is not acknowledged until [`Log::sync_through`]
1313    /// (or any later sync) makes it durable. The caller MUST NOT change any state the frame
1314    /// describes before then — the log is written before the state, always.
1315    pub fn stage_raw(&self, kind: u16, meta: &[u8], blob: &[u8]) -> Result<u64> {
1316        let mut w = self.lock_writer()?;
1317        self.stage_locked(&mut w, kind, meta, blob)
1318    }
1319
1320    fn stage_locked(&self, w: &mut Writer, kind: u16, meta: &[u8], blob: &[u8]) -> Result<u64> {
1321        self.roll_if_full(w)?;
1322        let seq = w.seg.first + w.seg.offsets.len() as u64;
1323        let header = Header {
1324            seq,
1325            kind,
1326            meta_len: meta.len() as u32,
1327            blob_len: blob.len() as u64,
1328        };
1329        let h = header.encode();
1330        let mut crc = Crc32c::new();
1331        crc.update(meta);
1332        crc.update(blob);
1333        let tr = crc.finish().to_le_bytes();
1334        let put = (|| {
1335            w.put(&h)?;
1336            w.put(meta)?;
1337            w.put(blob)?;
1338            w.put(&tr)
1339        })();
1340        if let Err(e) = put {
1341            // Half a frame is never left behind for the next one to land after — and when it
1342            // cannot be cut, nothing more is written behind it.
1343            if let Err(cut) = w.cut_to_committed() {
1344                self.poison(format!("a half-written frame could not be cut off the segment ({cut}) after its write failed ({e})"));
1345            }
1346            return Err(e.into());
1347        }
1348        let off = w.seg.len;
1349        w.seg.offsets.push(off);
1350        w.seg.len += header.frame_len();
1351        Ok(seq)
1352    }
1353
1354    /// **Return once frame `seq` is durable** — making it so if nobody is: a waiter becomes the
1355    /// leader when no leader is in its fsync, writes out the stage, `fdatasync`s a duplicate of
1356    /// the segment's descriptor WITHOUT holding the writer lock (appenders keep staging behind it),
1357    /// then indexes and publishes everything that fsync covered and wakes every waiter. A waiter
1358    /// re-checks the head on EVERY wake-up, so the moment a leader's fsync covers its frame it
1359    /// returns — it never waits for a later leader's fsync, and no lock is held across I/O.
1360    pub fn sync_through(&self, seq: u64) -> Result<()> {
1361        {
1362            let mut leading = self.sync_state.lock().unwrap_or_else(|p| p.into_inner());
1363            loop {
1364                if self.head_seq() >= seq {
1365                    return Ok(());
1366                }
1367                if !*leading {
1368                    *leading = true;
1369                    break;
1370                }
1371                leading = self.synced.wait(leading).unwrap_or_else(|p| p.into_inner());
1372            }
1373        }
1374        // This thread leads until `_lead` drops — on every path out, an error and a panic
1375        // included — and then every waiter is woken to look at the head again.
1376        struct Lead<'a>(&'a Log);
1377        impl Drop for Lead<'_> {
1378            fn drop(&mut self) {
1379                *self.0.sync_state.lock().unwrap_or_else(|p| p.into_inner()) = false;
1380                self.0.synced.notify_all();
1381            }
1382        }
1383        let _lead = Lead(self);
1384        if let Some(n) = self.test_group {
1385            self.hold_for_the_group(n);
1386        }
1387        let snap = (|| -> Result<(File, u64, usize)> {
1388            let mut w = self.lock_writer()?;
1389            w.flush_buf()?;
1390            Ok((w.file.try_clone()?, w.seg.first, w.seg.offsets.len()))
1391        })();
1392        let (file, first, upto) = match snap {
1393            Ok(s) => s,
1394            // A release (the data set being grown) publishes everything first.
1395            Err(_) if self.head_seq() >= seq => return Ok(()),
1396            Err(e) => return Err(e),
1397        };
1398        #[cfg(test)]
1399        {
1400            let gate = self
1401                .fsync_gates
1402                .lock()
1403                .unwrap_or_else(|p| p.into_inner())
1404                .pop_front();
1405            if let Some(gate) = gate {
1406                let _ = gate.recv();
1407            }
1408        }
1409        if let Err(e) = self.sync_frames(&file) {
1410            let mut w = self.writer.lock().expect("txlog writer");
1411            if w.seg.first == first {
1412                return Err(self.poison_and_cut(&mut w, e));
1413            }
1414            // A roll since then published (and synced) that segment whole — the frames this
1415            // fsync was for are durable.
1416            drop(w);
1417            if self.head_seq() < seq {
1418                return Err(self.poison_and_cut(&mut self.writer.lock().expect("txlog writer"), e));
1419            }
1420            return Ok(());
1421        }
1422        self.frame_syncs.fetch_add(1, Ordering::Relaxed);
1423        let mut w = match self.lock_writer() {
1424            Ok(w) => w,
1425            Err(_) if self.head_seq() >= seq => return Ok(()),
1426            Err(e) => return Err(e),
1427        };
1428        // A roll since then published (and synced) that segment whole.
1429        if w.seg.first == first && upto > w.published {
1430            self.publish_upto(&mut w, upto)?;
1431        }
1432        Ok(())
1433    }
1434
1435    /// **The test hold** ([`TEST_GROUP_ENV`]): the leader waits until `n` frames are staged and
1436    /// not yet durable — or [`TEST_GROUP_WAIT`] passes — so a count of fsyncs measures what the
1437    /// group commit shares when the writers ARE there, not how the scheduler happened to
1438    /// interleave them. The writer lock is taken only to read the count.
1439    fn hold_for_the_group(&self, n: usize) {
1440        let until = std::time::Instant::now() + TEST_GROUP_WAIT;
1441        loop {
1442            let staged = self.writer.lock().map(|w| w.unpublished()).unwrap_or(n);
1443            if staged >= n || std::time::Instant::now() >= until {
1444                return;
1445            }
1446            std::thread::sleep(std::time::Duration::from_micros(200));
1447        }
1448    }
1449
1450    /// The `fdatasync` that makes frames durable (and, in a test, the one an injected failure
1451    /// replaces).
1452    fn sync_frames(&self, file: &File) -> io::Result<()> {
1453        #[cfg(test)]
1454        loop {
1455            let n = self.fail_fsyncs.load(Ordering::SeqCst);
1456            if n == 0 {
1457                break;
1458            }
1459            if self
1460                .fail_fsyncs
1461                .compare_exchange(n, n - 1, Ordering::SeqCst, Ordering::SeqCst)
1462                .is_ok()
1463            {
1464                return Err(io::Error::other("txlog test: injected fsync failure (EIO)"));
1465            }
1466        }
1467        file.sync_data()
1468    }
1469
1470    /// Test-only: the next `n` frame fsyncs fail.
1471    #[cfg(test)]
1472    fn fail_next_fsyncs(&self, n: u32) {
1473        self.fail_fsyncs.store(n, Ordering::SeqCst);
1474    }
1475
1476    /// Test-only: the next leader waits on `gate` before its fsync.
1477    #[cfg(test)]
1478    fn hold_next_fsync(&self, gate: std::sync::mpsc::Receiver<()>) {
1479        self.fsync_gates.lock().unwrap().push_back(gate);
1480    }
1481
1482    /// **Make a group commit durable and visible** (perf twin-apply-fsync, 2026-10-06): every
1483    /// frame committed with [`Pending::commit_unsynced`] since the last call is written out,
1484    /// `fdatasync`ed ONCE, indexed with ONE write, and published — readers see it and the head
1485    /// moves to the last of them only now. A twin acknowledges its head to the primary, so what it
1486    /// acknowledges is what this made durable. Answers the head. Nothing pending = no I/O.
1487    pub fn sync(&self) -> Result<u64> {
1488        let mut w = self.lock_writer()?;
1489        self.publish(&mut w)?;
1490        Ok(self.head_seq())
1491    }
1492
1493    /// Frames committed and not yet durable ([`Log::sync`] makes them so).
1494    pub fn unsynced(&self) -> u64 {
1495        self.writer.lock().expect("txlog writer").unpublished() as u64
1496    }
1497
1498    /// The body of [`Log::sync`], under the writer lock.
1499    fn publish(&self, w: &mut Writer) -> Result<()> {
1500        w.flush_buf()?;
1501        let n = w.unpublished();
1502        if n == 0 {
1503            return Ok(());
1504        }
1505        if let Some(cause) = self.poisoned_by() {
1506            return Err(Error::Poisoned {
1507                dir: self.dir.clone(),
1508                cause,
1509            });
1510        }
1511        if let Err(e) = self.sync_frames(&w.file) {
1512            return Err(self.poison_and_cut(w, e));
1513        }
1514        self.frame_syncs.fetch_add(1, Ordering::Relaxed);
1515        self.publish_upto(w, w.seg.offsets.len())
1516    }
1517
1518    /// What poisoned the log, if anything did ([`Error::Poisoned`]).
1519    pub fn poisoned_by(&self) -> Option<String> {
1520        self.poisoned
1521            .lock()
1522            .unwrap_or_else(|p| p.into_inner())
1523            .clone()
1524    }
1525
1526    /// **Poison the log** ([`Error::Poisoned`]): the first cause is kept, said once at ERROR.
1527    fn poison(&self, cause: String) {
1528        let mut p = self.poisoned.lock().unwrap_or_else(|p| p.into_inner());
1529        if p.is_none() {
1530            tracing::error!(
1531                target: crate::observe::TARGET_STORE,
1532                event = "txlog.poisoned",
1533                dir = %self.dir.display(),
1534                cause = %cause,
1535                "the transaction log is poisoned: every write refuses until it is reopened"
1536            );
1537            *p = Some(cause);
1538        }
1539    }
1540
1541    /// **A frame fsync failed**: poison the log, then cut every frame it did not make durable off
1542    /// the segment — staged, written, or in the page cache — so neither a later fsync nor a
1543    /// reopen can publish what its writer was told had failed. Answers the error to return.
1544    fn poison_and_cut(&self, w: &mut Writer, e: io::Error) -> Error {
1545        let mut cause = format!("a frame fsync failed ({e})");
1546        let keep = w.seg.offsets.get(w.published).copied().unwrap_or(w.seg.len);
1547        w.seg.offsets.truncate(w.published);
1548        w.seg.len = keep;
1549        if keep >= w.disk_len {
1550            w.buf.truncate((keep - w.disk_len) as usize);
1551        } else {
1552            w.buf.clear();
1553            let cut = (|| {
1554                w.file.set_len(keep)?;
1555                w.file.seek(SeekFrom::Start(keep))?;
1556                w.disk_len = keep;
1557                w.file.sync_data()
1558            })();
1559            if let Err(c) = cut {
1560                cause.push_str(&format!(
1561                    ", and the frames it did not make durable could not be cut ({c})"
1562                ));
1563            }
1564        }
1565        self.poison(cause.clone());
1566        Error::Poisoned {
1567            dir: self.dir.clone(),
1568            cause,
1569        }
1570    }
1571
1572    /// **How many `fdatasync`s made frames durable** since this log was opened — the leader's in
1573    /// [`Log::sync_through`] and a group's in [`Log::sync`] (a roll, a release and a cut sync too,
1574    /// rarely, and are not counted). What the I/O tests hold to a bound per entry.
1575    pub fn frame_syncs(&self) -> u64 {
1576        self.frame_syncs.load(Ordering::Relaxed)
1577    }
1578
1579    /// Index and publish the open segment's frames up to `upto` — already durable.
1580    fn publish_upto(&self, w: &mut Writer, upto: usize) -> Result<()> {
1581        if upto <= w.published {
1582            return Ok(());
1583        }
1584        let fresh: Vec<u64> = w.seg.offsets[w.published..upto].to_vec();
1585        let mut bytes = Vec::with_capacity(fresh.len() * 8);
1586        for off in &fresh {
1587            bytes.extend_from_slice(&off.to_le_bytes());
1588        }
1589        // The index is a cache appended AFTER the data's fsync, never fsynced itself (the
1590        // module's rule): one write for the whole group.
1591        w.idx.write_all(&bytes)?;
1592        w.published = upto;
1593        let end = match w.seg.offsets.get(upto) {
1594            Some(&next) => next,
1595            None => w.seg.len,
1596        };
1597        {
1598            let mut segs = self.segments.lock().expect("txlog segments");
1599            let open = segs.last_mut().expect("open segment");
1600            debug_assert_eq!(
1601                open.first, w.seg.first,
1602                "the published open segment is the writer's"
1603            );
1604            open.offsets.extend_from_slice(&fresh);
1605            open.len = end;
1606        }
1607        let last = w.seg.first + upto as u64 - 1;
1608        self.head.store(last, Ordering::Release);
1609        Ok(())
1610    }
1611
1612    /// Begin a streaming append for a blob whose bytes arrive in chunks —
1613    /// the twin receiving a large pack over the wire without holding it in
1614    /// memory. The writer lock is held until [`Pending::commit`] or
1615    /// [`Pending::abort`]; the frame does not exist until `commit`.
1616    pub fn begin(
1617        &self,
1618        seq: Option<u64>,
1619        kind: u16,
1620        meta: &[u8],
1621        blob_len: u64,
1622    ) -> Result<Pending<'_>> {
1623        if seq.is_none() && self.is_follower() {
1624            return Err(Error::Follower);
1625        }
1626        let mut w = self.lock_writer()?;
1627        self.roll_if_full(&mut w)?;
1628        let next = w.seg.first + w.seg.offsets.len() as u64;
1629        if let Some(seq) = seq {
1630            if seq != next {
1631                return Err(Error::OutOfOrder {
1632                    got: seq,
1633                    expected: next,
1634                });
1635            }
1636        }
1637        let header = Header {
1638            seq: next,
1639            kind,
1640            meta_len: meta.len() as u32,
1641            blob_len,
1642        };
1643        let h = header.encode();
1644        w.put(&h)?;
1645        w.put(meta)?;
1646        let mut crc = Crc32c::new();
1647        crc.update(meta);
1648        Ok(Pending {
1649            log: self,
1650            w,
1651            header,
1652            crc,
1653            written: 0,
1654            finished: false,
1655        })
1656    }
1657
1658    /// The writer, or [`Error::Released`] while the files are given up.
1659    fn lock_writer(&self) -> Result<std::sync::MutexGuard<'_, Writer>> {
1660        let w = self.writer.lock().expect("txlog writer");
1661        if let Some(cause) = self.poisoned_by() {
1662            return Err(Error::Poisoned {
1663                dir: self.dir.clone(),
1664                cause,
1665            });
1666        }
1667        if self.released.load(Ordering::Acquire) {
1668            return Err(Error::Released(self.dir.clone()));
1669        }
1670        Ok(w)
1671    }
1672
1673    /// **Give up the writer's files** so the filesystem under the log can be unmounted — the
1674    /// runtime data-set growth (gunnar-server `grow`), where PID 1 unmounts `/data` itself.
1675    ///
1676    /// MEASURED 2026-09-15 under KVM (growbench R5): the open segment's `.log` and `.idx` were
1677    /// the only descriptors still under `/data` after the stores closed, and `umount` answered
1678    /// EBUSY — so every runtime growth on a box carrying the log aborted.
1679    ///
1680    /// Waits for an in-flight [`Pending`] (it holds the writer lock), syncs both files, and
1681    /// parks the handles on `/dev/null`; readers open per call and are unaffected until the
1682    /// unmount itself. Every write refuses with [`Error::Released`] until [`Log::reacquire`].
1683    /// Idempotent.
1684    pub fn release(&self) -> Result<()> {
1685        let mut w = self.writer.lock().expect("txlog writer");
1686        if self.released.load(Ordering::Acquire) {
1687            return Ok(());
1688        }
1689        self.publish(&mut w)?;
1690        w.file.sync_all()?;
1691        w.idx.sync_all()?;
1692        w.file = OpenOptions::new().append(true).open("/dev/null")?;
1693        w.idx = OpenOptions::new().append(true).open("/dev/null")?;
1694        self.released.store(true, Ordering::Release);
1695        Ok(())
1696    }
1697
1698    /// **Take the files back** after the remount. The open segment must be exactly as long as
1699    /// the writer left it — anything else is a different disk, and is refused by name rather
1700    /// than appended to. Idempotent.
1701    pub fn reacquire(&self) -> Result<()> {
1702        let mut w = self.writer.lock().expect("txlog writer");
1703        if !self.released.load(Ordering::Acquire) {
1704            return Ok(());
1705        }
1706        let path = seg_path(&self.dir, w.seg.first);
1707        let mut file = OpenOptions::new().append(true).open(&path)?;
1708        let on_disk = file.metadata()?.len();
1709        if on_disk != w.seg.len {
1710            return Err(Error::Corrupt {
1711                path,
1712                offset: on_disk,
1713                what: "the open segment changed length while the log was released",
1714            });
1715        }
1716        file.seek(SeekFrom::Start(w.seg.len))?;
1717        let idx = OpenOptions::new()
1718            .append(true)
1719            .open(idx_path(&self.dir, w.seg.first))?;
1720        w.file = file;
1721        w.idx = idx;
1722        w.disk_len = w.seg.len;
1723        self.released.store(false, Ordering::Release);
1724        Ok(())
1725    }
1726
1727    /// The files are given up ([`Log::release`]).
1728    pub fn is_released(&self) -> bool {
1729        self.released.load(Ordering::Acquire)
1730    }
1731
1732    fn roll_if_full(&self, w: &mut Writer) -> Result<()> {
1733        if w.seg.len < self.segment_bytes || w.seg.offsets.is_empty() {
1734            return Ok(());
1735        }
1736        let next_first = w.seg.first + w.seg.offsets.len() as u64;
1737        // A group in progress ends with the segment it was written into.
1738        self.publish(w)?;
1739        w.file.sync_all()?;
1740        w.idx.sync_all()?;
1741        let file = File::create(seg_path(&self.dir, next_first))?;
1742        let idx = File::create(idx_path(&self.dir, next_first))?;
1743        sync_dir(&self.dir)?;
1744        w.file = file;
1745        w.idx = idx;
1746        w.disk_len = 0;
1747        w.published = 0;
1748        w.seg = Segment {
1749            first: next_first,
1750            offsets: Vec::new(),
1751            len: 0,
1752        };
1753        self.segments.lock().expect("txlog segments").push(Segment {
1754            first: next_first,
1755            offsets: Vec::new(),
1756            len: 0,
1757        });
1758        Ok(())
1759    }
1760
1761    /// Locate a frame: which segment, which offset, how long.
1762    fn locate(&self, seq: u64) -> Result<(PathBuf, u64)> {
1763        let head = self.head_seq();
1764        if seq > head {
1765            return Err(Error::BeyondHead { seq, head });
1766        }
1767        let first = self.first_seq();
1768        if seq < first {
1769            return Err(Error::NotRetained { seq, first });
1770        }
1771        let segs = self.segments.lock().expect("txlog segments");
1772        let i = match segs.binary_search_by(|s| s.first.cmp(&seq)) {
1773            Ok(i) => i,
1774            Err(0) => return Err(Error::NotRetained { seq, first }),
1775            Err(i) => i - 1,
1776        };
1777        let seg = &segs[i];
1778        let k = (seq - seg.first) as usize;
1779        let off = *seg.offsets.get(k).ok_or(Error::BeyondHead { seq, head })?;
1780        Ok((seg_path(&self.dir, seg.first), off))
1781    }
1782
1783    /// Read one frame whole.
1784    pub fn read(&self, seq: u64) -> Result<Entry> {
1785        let (path, off) = self.locate(seq)?;
1786        let mut file = File::open(&path)?;
1787        file.seek(SeekFrom::Start(off))?;
1788        let mut hdr = [0u8; HEADER_LEN];
1789        file.read_exact(&mut hdr)?;
1790        let h = Header::decode(&hdr).ok_or(Error::Corrupt {
1791            path: path.clone(),
1792            offset: off,
1793            what: "header",
1794        })?;
1795        let mut meta = vec![0u8; h.meta_len as usize];
1796        file.read_exact(&mut meta)?;
1797        if h.kind == Kind::Redacted as u16 {
1798            // Erased: the meta is whole, the blob is gone. Nothing to check it against.
1799            return Ok(Entry {
1800                seq: h.seq,
1801                kind: h.kind,
1802                meta,
1803                blob: Vec::new(),
1804            });
1805        }
1806        let mut blob = vec![0u8; h.blob_len as usize];
1807        file.read_exact(&mut blob)?;
1808        let mut tr = [0u8; TRAILER_LEN];
1809        file.read_exact(&mut tr)?;
1810        let mut crc = Crc32c::new();
1811        crc.update(&meta);
1812        crc.update(&blob);
1813        if crc.finish() != u32::from_le_bytes(tr) {
1814            return Err(Error::Corrupt {
1815                path,
1816                offset: off,
1817                what: "payload crc",
1818            });
1819        }
1820        Ok(Entry {
1821            seq: h.seq,
1822            kind: h.kind,
1823            meta,
1824            blob,
1825        })
1826    }
1827
1828    /// Read a frame's header and meta, and say where its blob is, so a
1829    /// caller can stream the blob in chunks.
1830    pub fn locate_blob(&self, seq: u64) -> Result<Located> {
1831        let (path, off) = self.locate(seq)?;
1832        let mut file = File::open(&path)?;
1833        file.seek(SeekFrom::Start(off))?;
1834        let mut hdr = [0u8; HEADER_LEN];
1835        file.read_exact(&mut hdr)?;
1836        let h = Header::decode(&hdr).ok_or(Error::Corrupt {
1837            path: path.clone(),
1838            offset: off,
1839            what: "header",
1840        })?;
1841        let mut meta = vec![0u8; h.meta_len as usize];
1842        file.read_exact(&mut meta)?;
1843        Ok(Located {
1844            seq: h.seq,
1845            kind: h.kind,
1846            meta,
1847            path,
1848            blob_offset: off + HEADER_LEN as u64 + h.meta_len as u64,
1849            // An erased frame streams nothing: its bytes are gone.
1850            blob_len: if h.kind == Kind::Redacted as u16 {
1851                0
1852            } else {
1853                h.blob_len
1854            },
1855            payload_crc: None,
1856        })
1857    }
1858
1859    /// **One frame's blob in pieces of at most `piece` bytes, never whole** — the primary's half
1860    /// of a twin's fetch (twin-streaming, 2026-10-06). `each` gets the frame's header and meta
1861    /// ([`Located`]), one piece, and whether it is the LAST.
1862    ///
1863    /// **The last piece is handed over only after the payload crc checked out**, the same check
1864    /// [`Log::read`] makes before it answers. A reader that makes the entry visible on `last` (a
1865    /// twin's commit) therefore never makes a corrupt one visible: a frame that fails its crc
1866    /// ends in [`Error::Corrupt`] with every piece but the last delivered, and the reader drops
1867    /// what it was given. Memory is one piece, whatever the blob's size.
1868    ///
1869    /// An erased frame ([`Kind::Redacted`]) is one empty last piece and is not checked: its bytes
1870    /// are gone and its crc was over them. A frame with an empty blob checks the crc over its
1871    /// meta and is one empty last piece.
1872    pub fn read_pieces<E: From<Error>>(
1873        &self,
1874        seq: u64,
1875        piece: usize,
1876        mut each: impl FnMut(&Located, &[u8], bool) -> std::result::Result<(), E>,
1877    ) -> std::result::Result<Located, E> {
1878        let mut at = self.locate_blob(seq)?;
1879        let frame_at = at.blob_offset - HEADER_LEN as u64 - at.meta.len() as u64;
1880        let corrupt = |path: &Path| Error::Corrupt {
1881            path: path.to_path_buf(),
1882            offset: frame_at,
1883            what: "payload crc",
1884        };
1885        if at.kind == Kind::Redacted as u16 {
1886            each(&at, &[], true)?;
1887            return Ok(at);
1888        }
1889        let mut file = File::open(&at.path).map_err(Error::from)?;
1890        file.seek(SeekFrom::Start(at.blob_offset))
1891            .map_err(Error::from)?;
1892        let mut crc = Crc32c::new();
1893        crc.update(&at.meta);
1894        let mut buf = vec![0u8; (piece.max(1) as u64).min(at.blob_len) as usize];
1895        let mut left = at.blob_len;
1896        loop {
1897            let n = (buf.len() as u64).min(left) as usize;
1898            file.read_exact(&mut buf[..n]).map_err(Error::from)?;
1899            crc.update(&buf[..n]);
1900            left -= n as u64;
1901            if left > 0 {
1902                each(&at, &buf[..n], false)?;
1903                continue;
1904            }
1905            let mut tr = [0u8; TRAILER_LEN];
1906            file.read_exact(&mut tr).map_err(Error::from)?;
1907            if crc.finish() != u32::from_le_bytes(tr) {
1908                return Err(corrupt(&at.path).into());
1909            }
1910            at.payload_crc = Some(u32::from_le_bytes(tr));
1911            each(&at, &buf[..n], true)?;
1912            return Ok(at);
1913        }
1914    }
1915
1916    /// **[`Log::read_pieces`] from byte `from` of the blob on** (restore-large-entry, 2026-10-07):
1917    /// the twin's half of a restore resuming an entry the box being filled holds part of. 0 is
1918    /// [`Log::read_pieces`] exactly. Otherwise only the blob from `from` is read — a resumed entry
1919    /// of gigabytes costs the twin its REST on every call, never a re-read of what was sent, which
1920    /// on a slow volume would eat the call's budget and stall the restore again near the end —
1921    /// and the crc cannot be checked here (it is over all of it): the last piece carries the
1922    /// frame's stored crc as [`Located::payload_crc`], unchecked, and the receiver checks it over
1923    /// the entry it put together. `from` at the blob's end is one empty last piece; `from` past
1924    /// it, or on an erased frame, is refused: an `InvalidInput` I/O error naming both numbers.
1925    pub fn read_pieces_from<E: From<Error>>(
1926        &self,
1927        seq: u64,
1928        from: u64,
1929        piece: usize,
1930        mut each: impl FnMut(&Located, &[u8], bool) -> std::result::Result<(), E>,
1931    ) -> std::result::Result<Located, E> {
1932        if from == 0 {
1933            return self.read_pieces(seq, piece, each);
1934        }
1935        let mut at = self.locate_blob(seq)?;
1936        if at.kind == Kind::Redacted as u16 || from > at.blob_len {
1937            return Err(Error::Io(io::Error::new(
1938                io::ErrorKind::InvalidInput,
1939                format!(
1940                    "txlog: seq {seq} read from byte {from} of a {}-byte blob",
1941                    at.blob_len
1942                ),
1943            ))
1944            .into());
1945        }
1946        let mut file = File::open(&at.path).map_err(Error::from)?;
1947        file.seek(SeekFrom::Start(at.blob_offset + from))
1948            .map_err(Error::from)?;
1949        let mut left = at.blob_len - from;
1950        let mut buf = vec![0u8; (piece.max(1) as u64).min(left.max(1)) as usize];
1951        loop {
1952            let n = (buf.len() as u64).min(left) as usize;
1953            file.read_exact(&mut buf[..n]).map_err(Error::from)?;
1954            left -= n as u64;
1955            if left > 0 {
1956                each(&at, &buf[..n], false)?;
1957                continue;
1958            }
1959            let mut tr = [0u8; TRAILER_LEN];
1960            file.read_exact(&mut tr).map_err(Error::from)?;
1961            at.payload_crc = Some(u32::from_le_bytes(tr));
1962            each(&at, &buf[..n], true)?;
1963            return Ok(at);
1964        }
1965    }
1966
1967    /// **Erase every frame before `before` that carries data of one of `repos`** — the
1968    /// `Pack`, `Refs`, `LfsPut` and `LfsDelete` frames whose meta names one of them
1969    /// exactly — in place (owner ruling g-nonpay: an account deleted for non-payment
1970    /// is deleted from the log too, on main and on the twin).
1971    ///
1972    /// Per segment, every matching frame is rewritten as [`Kind::Redacted`]: the HEADERS
1973    /// first, made durable with ONE `fdatasync` (from then on every reader reads those frames
1974    /// as erased, and a crash leaves erased frames, never a frame whose checksum fails), then
1975    /// the blobs' ranges are punched out of the file (zeros where the filesystem cannot punch),
1976    /// then ONE `fsync` — two per segment, where it used to be one per frame (R5.1-B). The sequence, the offsets and the index are untouched: a twin's gap
1977    /// rule, a reader's seek and the retention floor mean exactly what they meant.
1978    ///
1979    /// `RepoStore` frames stay: a replay needs the store's create and purge to land
1980    /// in order. Frames AT or after `before` are never touched — a repository created
1981    /// again under the same name after the erase is somebody's new data. Idempotent:
1982    /// an erased frame is punched again (cheap), so an erase a crash cut between its
1983    /// headers and its punches is finished by the next. **Appends do not wait for it**
1984    /// (R5.1-B, 2026-10-07): the writer lock is taken only to publish what is staged and
1985    /// to read the plan, and an erase only touches frames below `before`, all of them
1986    /// published; one erase runs at a time.
1987    pub fn erase(&self, before: u64, repos: &[String]) -> Result<Erased> {
1988        let _one = self.erase_lock.lock().unwrap_or_else(|p| p.into_inner());
1989        // **The writer lock only to publish and to read the plan** (R5.1-B, 2026-10-07). Every
1990        // frame below `before` is published (durable, indexed) once this block ends, and the erase
1991        // touches nothing else: appends go on at the tail beside it.
1992        let plan: Vec<(u64, Vec<u64>)> = {
1993            let mut w = self.lock_writer()?;
1994            self.publish(&mut w)?;
1995            self.segments
1996                .lock()
1997                .expect("txlog segments")
1998                .iter()
1999                .filter(|s| !s.offsets.is_empty() && s.first < before)
2000                .map(|s| (s.first, s.offsets.clone()))
2001                .collect()
2002        };
2003        let data = [
2004            Kind::Pack as u16,
2005            Kind::Refs as u16,
2006            Kind::LfsPut as u16,
2007            Kind::LfsDelete as u16,
2008        ];
2009        let mut out = Erased::default();
2010        let mut hdr = [0u8; HEADER_LEN];
2011        for (first, offsets) in plan {
2012            let path = seg_path(&self.dir, first);
2013            let mut file = match OpenOptions::new().read(true).write(true).open(&path) {
2014                Ok(f) => f,
2015                // Retention dropped the whole segment meanwhile: nothing of it is left to erase.
2016                Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
2017                Err(e) => return Err(e.into()),
2018            };
2019            // Pass 1: every matching frame's header becomes Redacted; ONE fdatasync makes them all
2020            // durable before a single byte goes — from then on each frame IS erased, and a crash
2021            // leaves erased frames, never a frame whose checksum fails.
2022            let mut punch: Vec<(u64, u64)> = Vec::new();
2023            for (i, &off) in offsets.iter().enumerate() {
2024                let seq = first + i as u64;
2025                if seq >= before {
2026                    break;
2027                }
2028                file.seek(SeekFrom::Start(off))?;
2029                file.read_exact(&mut hdr)?;
2030                let h = Header::decode(&hdr).ok_or(Error::Corrupt {
2031                    path: path.clone(),
2032                    offset: off,
2033                    what: "header",
2034                })?;
2035                let redacted_already = h.kind == Kind::Redacted as u16;
2036                if !redacted_already && !data.contains(&h.kind) {
2037                    continue;
2038                }
2039                let mut meta = vec![0u8; h.meta_len as usize];
2040                file.read_exact(&mut meta)?;
2041                let named = Meta::from_bytes(&meta)
2042                    .ok()
2043                    .and_then(|m| m.repo().map(str::to_owned));
2044                if !named.is_some_and(|r| repos.contains(&r)) {
2045                    continue;
2046                }
2047                let blob_at = off + HEADER_LEN as u64 + h.meta_len as u64;
2048                if redacted_already {
2049                    // Erased before — punched again (idempotent, cheap), so an erase a crash cut
2050                    // between its headers and its punches is finished by the next one.
2051                    punch.push((blob_at, h.blob_len));
2052                    continue;
2053                }
2054                let redacted = Header {
2055                    kind: Kind::Redacted as u16,
2056                    ..h
2057                };
2058                file.seek(SeekFrom::Start(off))?;
2059                file.write_all(&redacted.encode())?;
2060                punch.push((blob_at, h.blob_len));
2061                out.frames += 1;
2062                out.bytes += h.blob_len;
2063            }
2064            if punch.is_empty() {
2065                continue;
2066            }
2067            file.sync_data()?;
2068            self.erase_syncs.fetch_add(1, Ordering::Relaxed);
2069            #[cfg(test)]
2070            self.erase_gate();
2071            // Pass 2: the bytes, then ONE fsync.
2072            for (at, len) in punch {
2073                zero_range(&mut file, at, len)?;
2074            }
2075            file.sync_all()?;
2076            self.erase_syncs.fetch_add(1, Ordering::Relaxed);
2077        }
2078        Ok(out)
2079    }
2080
2081    /// **How many fsyncs [`Log::erase`] made** since this log was opened.
2082    pub fn erase_syncs(&self) -> u64 {
2083        self.erase_syncs.load(Ordering::Relaxed)
2084    }
2085
2086    /// Test-only: the next erase waits on `gate` once its first header is down.
2087    #[cfg(test)]
2088    fn hold_next_erase(&self, gate: std::sync::mpsc::Receiver<()>) {
2089        self.erase_gates.lock().unwrap().push_back(gate);
2090    }
2091
2092    #[cfg(test)]
2093    fn erase_gate(&self) {
2094        let gate = self
2095            .erase_gates
2096            .lock()
2097            .unwrap_or_else(|p| p.into_inner())
2098            .pop_front();
2099        if let Some(gate) = gate {
2100            let _ = gate.recv();
2101        }
2102    }
2103
2104    /// **Walk every retained frame's header and meta, oldest first**, without its blob: one
2105    /// open and one sequential buffered read per SEGMENT, each blob skipped by a relative seek
2106    /// inside the buffer, one header array and one meta buffer reused for every frame (T17).
2107    ///
2108    /// [`Log::locate_blob`] opens the segment file once per frame; a scan that runs on every
2109    /// start (`gunnar_server::twin::rebuild_audit_trail`) cannot pay an `open(2)` per frame of a
2110    /// long log. Bounded by the head at call time; a frame whose header does not decode, or
2111    /// whose sequence is not the one its position says, is [`Error::Corrupt`].
2112    pub fn scan_meta(&self, mut each: impl FnMut(u64, u16, &[u8])) -> Result<()> {
2113        let head = self.head_seq();
2114        let plan: Vec<(u64, u64, u64)> = self
2115            .segments
2116            .lock()
2117            .expect("txlog segments")
2118            .iter()
2119            .filter(|s| !s.offsets.is_empty())
2120            .map(|s| (s.first, s.offsets[0], s.offsets.len() as u64))
2121            .collect();
2122        let mut hdr = [0u8; HEADER_LEN];
2123        let mut meta: Vec<u8> = Vec::with_capacity(256);
2124        for (first, start, n) in plan {
2125            let path = seg_path(&self.dir, first);
2126            let mut r = io::BufReader::with_capacity(1 << 16, File::open(&path)?);
2127            r.seek(SeekFrom::Start(start))?;
2128            let mut offset = start;
2129            for seq in first..first + n {
2130                if seq > head {
2131                    return Ok(());
2132                }
2133                r.read_exact(&mut hdr)?;
2134                let h = match Header::decode(&hdr) {
2135                    Some(h) if h.seq == seq => h,
2136                    Some(_) => {
2137                        return Err(Error::Corrupt {
2138                            path,
2139                            offset,
2140                            what: "sequence out of place",
2141                        })
2142                    }
2143                    None => {
2144                        return Err(Error::Corrupt {
2145                            path,
2146                            offset,
2147                            what: "header",
2148                        })
2149                    }
2150                };
2151                meta.resize(h.meta_len as usize, 0);
2152                r.read_exact(&mut meta)?;
2153                each(seq, h.kind, &meta);
2154                r.seek_relative((h.blob_len + TRAILER_LEN as u64) as i64)?;
2155                offset += h.frame_len();
2156            }
2157        }
2158        Ok(())
2159    }
2160
2161    /// Read `from..=to` whole (bounded by the head).
2162    pub fn range(&self, from: u64, to: u64) -> Result<Vec<Entry>> {
2163        let to = to.min(self.head_seq());
2164        let mut out = Vec::new();
2165        let mut seq = from;
2166        while seq <= to {
2167            out.push(self.read(seq)?);
2168            seq += 1;
2169        }
2170        Ok(out)
2171    }
2172
2173    /// Drop every whole segment whose last sequence is below `seq`. The
2174    /// open segment is never dropped. Returns the new first sequence.
2175    pub fn truncate_before(&self, seq: u64) -> Result<u64> {
2176        let _w = self.lock_writer()?;
2177        let mut segs = self.segments.lock().expect("txlog segments");
2178        while segs.len() > 1 {
2179            let drop = match segs[0].last_seq() {
2180                Some(last) => last < seq,
2181                None => true,
2182            };
2183            if !drop {
2184                break;
2185            }
2186            let first = segs[0].first;
2187            fs::remove_file(seg_path(&self.dir, first))?;
2188            let _ = fs::remove_file(idx_path(&self.dir, first));
2189            segs.remove(0);
2190        }
2191        sync_dir(&self.dir)?;
2192        let first = segs[0].first;
2193        self.first.store(first, Ordering::Release);
2194        Ok(first)
2195    }
2196
2197    /// **Retain from `seq`**: close the open segment (when it holds anything) so every frame
2198    /// up to the head sits in a closed segment, then drop every whole segment below `seq`.
2199    /// Returns the new first sequence. `seq` past the head retains nothing old: the log keeps
2200    /// its numbers and the next append takes `head + 1`. The operator's retention door — and
2201    /// the drill's way to open a retention hole without writing a gigabyte first.
2202    pub fn retain_from(&self, seq: u64) -> Result<u64> {
2203        {
2204            let mut w = self.writer.lock().expect("txlog writer");
2205            self.publish(&mut w)?;
2206            if !w.seg.offsets.is_empty() {
2207                let next_first = w.seg.first + w.seg.offsets.len() as u64;
2208                w.file.sync_all()?;
2209                w.idx.sync_all()?;
2210                let file = File::create(seg_path(&self.dir, next_first))?;
2211                let idx = File::create(idx_path(&self.dir, next_first))?;
2212                sync_dir(&self.dir)?;
2213                w.file = file;
2214                w.idx = idx;
2215                w.disk_len = 0;
2216                w.published = 0;
2217                w.seg = Segment {
2218                    first: next_first,
2219                    offsets: Vec::new(),
2220                    len: 0,
2221                };
2222                self.segments.lock().expect("txlog segments").push(Segment {
2223                    first: next_first,
2224                    offsets: Vec::new(),
2225                    len: 0,
2226                });
2227            }
2228        }
2229        self.truncate_before(seq)
2230    }
2231
2232    /// **Start over**: drop every segment and begin again at sequence 1 with
2233    /// nothing in the log. The restore door on a fresh box: its own first
2234    /// frames (its minted KEK) make way for the twin's numbers. Refused when
2235    /// the log holds more than `keep_at_most` frames — a box with history is
2236    /// not fresh, and a reset would be data loss with a verb on it.
2237    pub fn reset(&self, keep_at_most: u64) -> Result<()> {
2238        let head = self.head_seq();
2239        if head > keep_at_most {
2240            return Err(Error::OutOfOrder {
2241                got: head,
2242                expected: keep_at_most,
2243            });
2244        }
2245        self.restart_after(0)
2246    }
2247
2248    /// **Start over after `at`**: drop every segment; the log is empty with its head at `at`
2249    /// and the next append taking `at + 1`. What a twin does once it has installed a snapshot
2250    /// taken at `at` — it holds that state, and the log resumes from the next number.
2251    pub fn restart_after(&self, at: u64) -> Result<()> {
2252        let mut w = self.lock_writer()?;
2253        let mut segs = self.segments.lock().expect("txlog segments");
2254        for s in segs.iter() {
2255            fs::remove_file(seg_path(&self.dir, s.first))?;
2256            let _ = fs::remove_file(idx_path(&self.dir, s.first));
2257        }
2258        segs.clear();
2259        let first = at + 1;
2260        File::create(seg_path(&self.dir, first))?;
2261        File::create(idx_path(&self.dir, first))?;
2262        sync_dir(&self.dir)?;
2263        segs.push(Segment {
2264            first,
2265            offsets: Vec::new(),
2266            len: 0,
2267        });
2268        w.file = OpenOptions::new()
2269            .append(true)
2270            .open(seg_path(&self.dir, first))?;
2271        w.idx = OpenOptions::new()
2272            .append(true)
2273            .open(idx_path(&self.dir, first))?;
2274        w.seg = Segment {
2275            first,
2276            offsets: Vec::new(),
2277            len: 0,
2278        };
2279        w.disk_len = 0;
2280        w.buf.clear();
2281        w.published = 0;
2282        self.head.store(at, Ordering::Release);
2283        self.first.store(first, Ordering::Release);
2284        Ok(())
2285    }
2286
2287    /// **The segments as retention sees them**, oldest first, the open one last:
2288    /// `(first, last, bytes)` — `last` is `first - 1` for a segment that holds no
2289    /// frame yet. What a byte-bounded retention decides from (2026-10-06, cov-13):
2290    /// only whole segments are ever dropped, so the bytes a `from` keeps are the
2291    /// sum of the segments at and after the one holding it.
2292    pub fn layout(&self) -> Vec<(u64, u64, u64)> {
2293        self.segments
2294            .lock()
2295            .expect("txlog segments")
2296            .iter()
2297            .map(|s| (s.first, s.first + s.offsets.len() as u64 - 1, s.len))
2298            .collect()
2299    }
2300
2301    /// Bytes on disk across all segments.
2302    pub fn bytes(&self) -> u64 {
2303        self.segments
2304            .lock()
2305            .expect("txlog segments")
2306            .iter()
2307            .map(|s| s.len)
2308            .sum()
2309    }
2310}
2311
2312/// A streaming append in progress. See [`Log::begin`].
2313///
2314/// **Dropped without [`Pending::commit`] or [`Pending::abort`], it aborts** (twin-streaming,
2315/// 2026-10-06). The partial frame is already in the segment file, and the writer appends with
2316/// `O_APPEND`: before this, a `?` between `begin` and `commit` (a write that failed, a wire that
2317/// broke under a twin's fetch) left the half frame where it was, the next append landed AFTER
2318/// it while the index recorded the offset BEFORE it, and every read of that next frame was a
2319/// corrupt header — a log broken by one failed transfer. Now the half frame is cut off the
2320/// segment on every path out, and a partial transfer leaves exactly nothing.
2321pub struct Pending<'a> {
2322    log: &'a Log,
2323    w: std::sync::MutexGuard<'a, Writer>,
2324    header: Header,
2325    crc: Crc32c,
2326    written: u64,
2327    /// Committed, or the partial frame already cut: nothing for `Drop` to do.
2328    finished: bool,
2329}
2330
2331impl Pending<'_> {
2332    /// The sequence number this frame will commit as.
2333    pub fn seq(&self) -> u64 {
2334        self.header.seq
2335    }
2336
2337    /// Bytes of the blob still owed.
2338    pub fn remaining(&self) -> u64 {
2339        self.header.blob_len - self.written
2340    }
2341
2342    /// Write a chunk of the blob.
2343    pub fn write(&mut self, chunk: &[u8]) -> Result<()> {
2344        if chunk.len() as u64 > self.remaining() {
2345            return Err(Error::Io(io::Error::new(
2346                io::ErrorKind::InvalidInput,
2347                "txlog: chunk exceeds the declared blob length",
2348            )));
2349        }
2350        self.w.put(chunk)?;
2351        self.crc.update(chunk);
2352        self.written += chunk.len() as u64;
2353        Ok(())
2354    }
2355
2356    /// Finish: trailer, fsync, index. Durable when this returns — and so is every frame of a
2357    /// group committed before it ([`Pending::commit_unsynced`]).
2358    pub fn commit(mut self) -> Result<u64> {
2359        let seq = self.close()?;
2360        self.log.publish(&mut self.w)?;
2361        Ok(seq)
2362    }
2363
2364    /// **Finish WITHOUT making it durable — one frame of a group commit** (perf twin-apply-fsync,
2365    /// 2026-10-06). The frame is whole in the segment (staged or written, trailer and all), it
2366    /// holds its sequence number — the next `begin` takes the one after — but it is not fsynced,
2367    /// not indexed, not visible to readers and not the head until [`Log::sync`] (or any synced
2368    /// append, roll or release) makes the whole group durable with ONE `fdatasync`.
2369    ///
2370    /// A crash before that loses the group's tail, never anything acknowledged: `open` keeps
2371    /// every whole frame that reached the disk (each checks its own crc) and cuts the rest, and a
2372    /// twin re-fetches from its durable head.
2373    pub fn commit_unsynced(mut self) -> Result<u64> {
2374        self.close()
2375    }
2376
2377    /// The trailer and the writer's own bookkeeping: the frame is committed, not yet durable.
2378    fn close(&mut self) -> Result<u64> {
2379        if self.remaining() != 0 {
2380            let owed = self.remaining();
2381            self.abort_inner()?;
2382            return Err(Error::Io(io::Error::new(
2383                io::ErrorKind::UnexpectedEof,
2384                format!("txlog: blob short by {owed} bytes at commit"),
2385            )));
2386        }
2387        let tr = self.crc.finish().to_le_bytes();
2388        self.w.put(&tr)?;
2389        // From here the frame is whole: it is the writer's, never cut by a drop.
2390        self.finished = true;
2391        let off = self.w.seg.len;
2392        self.w.seg.offsets.push(off);
2393        self.w.seg.len += self.header.frame_len();
2394        Ok(self.header.seq)
2395    }
2396
2397    /// Give up: the partial frame is cut off the segment.
2398    pub fn abort(mut self) -> Result<()> {
2399        self.abort_inner()
2400    }
2401
2402    fn abort_inner(&mut self) -> Result<()> {
2403        self.finished = true;
2404        if let Err(e) = self.w.cut_to_committed() {
2405            // The half frame stays where the next append would land after it: nothing more is.
2406            self.log.poison(format!(
2407                "a partial frame could not be cut off the segment ({e})"
2408            ));
2409            return Err(e.into());
2410        }
2411        Ok(())
2412    }
2413}
2414
2415impl Drop for Pending<'_> {
2416    fn drop(&mut self) {
2417        if self.finished {
2418            return;
2419        }
2420        // A cut that fails here poisons the log; the half frame is the torn tail `open` cuts at
2421        // the next start (it was never indexed and its trailer never written).
2422        let _ = self.abort_inner();
2423    }
2424}
2425
2426/// **Is this path (relative to a data root) a CACHE** — something rebuilt from packs,
2427/// derived from another store, or scratch, that may be lost and must never exist on the
2428/// twin? The exploded read cache, the start-up disk probe, repack and quarantine scratch,
2429/// the metadata mirror (a derived copy of the identity table), the grow journal, and every
2430/// temporary. The drill scans the twin's disk for these.
2431pub fn is_cache_path(rel: &str) -> bool {
2432    let rel = rel.trim_start_matches("./");
2433    // A box with no /serialdat partition has that tree at `<data-root>/.serialdat`, with
2434    // `crash`, `log`, `gc`, `quarantine` linked to it: judge a path by its place in the tree.
2435    let rel = rel
2436        .strip_prefix(".serialdat/")
2437        .or_else(|| rel.strip_prefix("serialdat/"))
2438        .unwrap_or(rel);
2439    let first = rel.split('/').next().unwrap_or("");
2440    let name = rel.rsplit('/').next().unwrap_or("");
2441    matches!(first, ".korp-disktest" | "gc" | "quarantine" | "meta" | "serialdat")
2442        || rel.split('/').any(|c| c.starts_with("objects.exploded"))
2443        // The commit-graph memo the engine folds from the packs (C2): derived, and off on a twin.
2444        || name == "objects.graph"
2445        || name.ends_with(".tmp")
2446        || name.starts_with("incoming-")
2447        || name == "grow-journal"
2448}
2449
2450/// **The control plane's audit trail, as the LOG names it** — one canonical relative path,
2451/// whatever the layout config put the `log` directory on.
2452///
2453/// It is a frame like `attestations.jsonl` is a frame, and it is spelled here rather than
2454/// derived from a physical path because the two boxes in a restore need not agree on where
2455/// `log/` physically lives: the frame carries the name, the applying box resolves the name.
2456pub const AUDIT_REL: &str = "log/audit-chain.jsonl";
2457
2458/// **A twin's — or a restoring box's — OWN audit trail.** Per box, never framed, never sent.
2459///
2460/// A twin serves a control plane of its own (`Mirror.*`, browse), and every decision it makes
2461/// is ITS decision, not the primary's. Writing those into [`AUDIT_REL`] would interleave a
2462/// second writer into a hash chain whose whole value is that it has exactly one — the `prev`
2463/// links would not hold and the restored copy would be refused at open. So the twin's own
2464/// decisions go here, this file stays [`is_per_box_path`], and the twin **originates nothing**
2465/// into the trail it is holding for the primary.
2466///
2467/// The consequence is the one worth having: a restored box opens [`AUDIT_REL`], finds the dead
2468/// primary's tip, and appends its own first decision with `prev` = that tip. **The trail is one
2469/// unbroken chain across the disaster.**
2470pub const AUDIT_LOCAL_REL: &str = "log/audit-chain.local.jsonl";
2471
2472/// **The site's WireGuard hub declaration** — `<data root>/wg/hub.conf`, laid there by
2473/// `korp-installer` through the ESP data stage on a fresh install
2474/// (`korp_installer::esp::DATA_STAGE_DIR`), then never written again.
2475///
2476/// It is **public site data**: the hub's public key, endpoint, allowed IPs and the
2477/// per-box `[Interface] Address` gunnar requires (`gunnar_server::appliance`). It is the
2478/// answer to "how is this box reached", and a restored main that does not have it is a box
2479/// nobody can reach — the worst thing to discover on the day the twin is used.
2480///
2481/// Framed since 2026-09-21 (lane T11): the twin holds it and a restore brings it back.
2482pub const HUB_CONF_REL: &str = "wg/hub.conf";
2483
2484/// **The digest of the hub declaration this box last framed**, so a boot that changes
2485/// nothing frames nothing. Per box, never framed — it is a record of what THIS log already
2486/// carries, and it is meaningless on any other box.
2487pub const HUB_CONF_FRAMED_REL: &str = "wg/hub.conf.framed";
2488
2489/// **The machine's own WireGuard identity** — the private key minted on the machine and
2490/// wrapped to its own TPM, and its public half.
2491///
2492/// **Never replicated, and now refused by name.** Two reasons, either sufficient: it is a
2493/// PRIVATE KEY, and a twin that held it would be a second place to steal the site's network
2494/// identity from; and it names THIS box to the hub, so a restored main carrying it would come
2495/// up claiming the dead machine's identity on a tunnel the hub has registered to a key that no
2496/// longer exists anywhere. The same ruling holger makes about its admin token and its role
2497/// record. Before this they were merely un-framed by accident — nothing wrote them through the
2498/// sink — which is not the same as being refused.
2499pub const WG_IDENTITY_KEY_REL: &str = "wg/identity.key";
2500/// See [`WG_IDENTITY_KEY_REL`]. The public half is equally per box: it names this machine.
2501pub const WG_IDENTITY_PUB_REL: &str = "wg/identity.pub";
2502
2503/// **Is this path a record of THIS box** — not cache and not a transaction: its own crash
2504/// records and console log (`crash/`, `log/`), a twin's own `twin.json` and
2505/// `twin.key`, and the restore markers. A twin keeps its own; the primary's are never sent.
2506///
2507/// **[`AUDIT_REL`] is the one exception, since 2026-09-21 (lane T11).** It used to be swept up
2508/// by `log/` and was therefore the one class of state a restore silently destroyed: the record
2509/// of who did what, gone on exactly the day somebody needs it. It is a signed-shaped
2510/// append-only log like the other nine in [`FRAMED_FILES`] and it is replicated like them.
2511/// [`AUDIT_LOCAL_REL`] stays per box — see its docs for why the two must not be one file.
2512pub fn is_per_box_path(rel: &str) -> bool {
2513    let rel = rel.trim_start_matches("./");
2514    // A box with no /serialdat partition has that tree at `<data-root>/.serialdat`, with
2515    // `crash`, `log`, `gc`, `quarantine` linked to it: judge a path by its place in the tree.
2516    let rel = rel
2517        .strip_prefix(".serialdat/")
2518        .or_else(|| rel.strip_prefix("serialdat/"))
2519        .unwrap_or(rel);
2520    if rel == AUDIT_REL || rel == HUB_CONF_REL {
2521        return false;
2522    }
2523    // The machine's own WireGuard identity and the digest of what this log already carries:
2524    // see [`WG_IDENTITY_KEY_REL`] and [`HUB_CONF_FRAMED_REL`].
2525    if matches!(
2526        rel,
2527        WG_IDENTITY_KEY_REL | WG_IDENTITY_PUB_REL | HUB_CONF_FRAMED_REL
2528    ) {
2529        return true;
2530    }
2531    let first = rel.split('/').next().unwrap_or("");
2532    // `txlog-snapshot-base` is the snapshot a store-only twin KEEPS as bytes (ruling 2026-09-22);
2533    // `-dropped` / `-too-small` are its resync's room markers (twin-resync-space, 2026-10-06).
2534    matches!(
2535        first,
2536        "crash" | "log" | "twin.json" | "twin.key" | "txlog-restoring" | "txlog-fresh-docs.json" | "txlog-reseal" | "txlog-snapshot-installing" | "txlog-snapshot-base" | "txlog-snapshot-base.old"
2537            | "txlog-snapshot-dropped" | "txlog-snapshot-too-small"
2538            // The entry a restoring box holds part of (restore-large-entry, 2026-10-07).
2539            | "txlog-restore-partial"
2540            // The twin's base a restoring box holds part of (restore-after-snapshot, 2026-10-07).
2541            | "txlog-restore-snapshot"
2542    )
2543}
2544
2545/// **The documents under a data root that are frames** (`Append` and `File` kinds): the signed
2546/// append-only logs and their tree heads, and the whole documents. A snapshot carries each that
2547/// exists; installing one removes each that the snapshot does not carry. A new sink writer adds
2548/// its file here (the twin test fixture checks every sink-framed path is listed).
2549pub const FRAMED_FILES: &[&str] = &[
2550    "attestations.jsonl",
2551    "transparency-heads.jsonl",
2552    "builds.jsonl",
2553    "build-heads.jsonl",
2554    "forge.jsonl",
2555    "forge-heads.jsonl",
2556    "membership.jsonl",
2557    "membership-heads.jsonl",
2558    "replication.json",
2559    "webhooks.json",
2560    // The control plane's audit trail (lane T11, 2026-09-21). Listed LAST so the ordering of
2561    // the nine above — which several fixtures index positionally — does not move.
2562    AUDIT_REL,
2563    // The site's WireGuard hub declaration (lane T11): a restored main that does not have it
2564    // is a box nobody can reach.
2565    HUB_CONF_REL,
2566    // The identity core's records (sessions and invites as hashes, the people, the floors;
2567    // IDENTITY-HOST): a twin and a snapshot carry them, so a main rebuilt from its twin has the
2568    // main's identity state (RA5-10).
2569    "identity.json",
2570];
2571
2572/// **Never a transaction**: cache ([`is_cache_path`]) or per box ([`is_per_box_path`]).
2573/// The sink refuses to frame one and the twin refuses to apply one.
2574pub fn never_framed(rel: &str) -> bool {
2575    is_cache_path(rel) || is_per_box_path(rel)
2576}
2577
2578/// **The sink**: how a writer that holds only a PATH (an append-only signed
2579/// log under the data root, an atomically replaced document) frames its write
2580/// in the log of the data root that path is under — without threading an
2581/// `Arc<Log>` through every constructor that names a file.
2582///
2583/// `ServerStores::open` registers `(data root, log)` on a primary. A path
2584/// under no registered root (a test's scratch file, a box with no log, a
2585/// twin — whose writes are applies, never originations) frames nothing. The
2586/// registry holds `Weak`s, so a dropped store unregisters itself.
2587pub mod sink {
2588    use super::*;
2589
2590    static ROOTS: Mutex<Vec<(PathBuf, Weak<Log>)>> = Mutex::new(Vec::new());
2591
2592    fn refuse_cache(rel: &str) -> Result<()> {
2593        if never_framed(rel) {
2594            return Err(Error::BadMeta(format!(
2595                "{rel} is a cache or per-box path; it is never a transaction"
2596            )));
2597        }
2598        Ok(())
2599    }
2600
2601    /// Frame writes under `root` into `log`.
2602    pub fn register(root: &Path, log: &Arc<Log>) {
2603        let mut roots = ROOTS.lock().expect("txlog sink");
2604        roots.retain(|(r, w)| w.strong_count() > 0 && r != root);
2605        roots.push((root.to_path_buf(), Arc::downgrade(log)));
2606    }
2607
2608    /// The log `path` is framed into and `path` relative to its root.
2609    pub fn log_for(path: &Path) -> Option<(Arc<Log>, String)> {
2610        let roots = ROOTS.lock().expect("txlog sink");
2611        roots
2612            .iter()
2613            .filter(|(r, _)| path.starts_with(r))
2614            .max_by_key(|(r, _)| r.as_os_str().len())
2615            .and_then(|(r, w)| {
2616                let log = w.upgrade()?;
2617                let rel = path.strip_prefix(r).ok()?.to_string_lossy().into_owned();
2618                Some((log, rel))
2619            })
2620    }
2621
2622    /// A framed write in progress: hold it until the file has the write (the mutation gate,
2623    /// [`Log::mutation`]). Empty when the path is under no registered root.
2624    #[must_use = "hold the frame until the file has the write"]
2625    pub struct Framed(#[allow(dead_code)] Option<MutationGuard>);
2626
2627    /// Before `line` (no newline) is appended to the file at `path`.
2628    pub fn append_line(path: &Path, line: &str) -> Result<Framed> {
2629        if let Some((log, rel)) = log_for(path) {
2630            refuse_cache(&rel)?;
2631            let guard = log.mutation();
2632            log.append(Kind::Append, &Meta::Append { log: rel }, line.as_bytes())?;
2633            return Ok(Framed(Some(guard)));
2634        }
2635        Ok(Framed(None))
2636    }
2637
2638    /// **A line framed and NOT yet durable** ([`stage_line`]): hold it — it is the mutation
2639    /// guard — until the file has the line, and write the line only after [`StagedLine::durable`].
2640    #[must_use = "wait for the frame to be durable, then write the line, then drop this"]
2641    pub struct StagedLine {
2642        _guard: MutationGuard,
2643        log: Arc<Log>,
2644        seq: u64,
2645    }
2646
2647    impl StagedLine {
2648        /// The frame's sequence number.
2649        pub fn seq(&self) -> u64 {
2650            self.seq
2651        }
2652        /// Return once the frame is durable — group-committed with every frame staged with it
2653        /// ([`Log::sync_through`]).
2654        pub fn durable(&self) -> Result<()> {
2655            self.log.sync_through(self.seq)
2656        }
2657        /// The log the frame is in.
2658        pub fn log(&self) -> &Arc<Log> {
2659            &self.log
2660        }
2661    }
2662
2663    /// **[`append_line`], group-committed** (perf, 2026-10-06): the frame is staged and its
2664    /// fsync is shared with every other frame staged before the leader's ([`Log::sync_through`]).
2665    /// `None` = the path is under no registered root (nothing is framed).
2666    pub fn stage_line(path: &Path, line: &str) -> Result<Option<StagedLine>> {
2667        if let Some((log, rel)) = log_for(path) {
2668            refuse_cache(&rel)?;
2669            if log.is_follower() {
2670                return Err(Error::Follower);
2671            }
2672            let guard = log.mutation();
2673            let seq = log.stage_raw(
2674                Kind::Append as u16,
2675                &Meta::Append { log: rel }.to_bytes(),
2676                line.as_bytes(),
2677            )?;
2678            return Ok(Some(StagedLine {
2679                _guard: guard,
2680                log,
2681                seq,
2682            }));
2683        }
2684        Ok(None)
2685    }
2686
2687    /// Before the document at `path` is replaced by `bytes`, or removed.
2688    pub fn replace_file(path: &Path, bytes: &[u8], deleted: bool) -> Result<Framed> {
2689        if let Some((log, rel)) = log_for(path) {
2690            refuse_cache(&rel)?;
2691            let guard = log.mutation();
2692            log.append(Kind::File, &Meta::File { path: rel, deleted }, bytes)?;
2693            return Ok(Framed(Some(guard)));
2694        }
2695        Ok(Framed(None))
2696    }
2697}
2698
2699#[cfg(test)]
2700mod tests {
2701    use super::*;
2702
2703    fn meta(repo: &str) -> Meta {
2704        Meta::Pack {
2705            repo: repo.into(),
2706            hash: "sha256".into(),
2707        }
2708    }
2709
2710    /// ★ **A failed fsync poisons the log** (stinky row 3, fsyncgate). The caller of the append
2711    /// whose `fdatasync` failed was told so, but its frame stayed staged; the NEXT leader's
2712    /// fsync — which Linux answers with success once the error has been reported — published
2713    /// it, and a twin applied a transaction main had reported as failed. After a failed fsync
2714    /// nothing more is written or published until the log is reopened from its committed point.
2715    #[test]
2716    fn a_failed_fsync_poisons_the_log_until_it_is_reopened() {
2717        let dir = tempfile::tempdir().unwrap();
2718        let m = meta("g/r").to_bytes();
2719        {
2720            let log = Log::open(dir.path()).unwrap();
2721            assert_eq!(log.append_raw(Kind::Pack as u16, &m, b"one").unwrap(), 1);
2722            log.fail_next_fsyncs(1);
2723            log.append_raw(Kind::Pack as u16, &m, b"two")
2724                .expect_err("its fsync failed");
2725            let next = log.append_raw(Kind::Pack as u16, &m, b"three");
2726            assert!(next.is_err(), "an append after a failed fsync was taken (and published the failed frame with it): {next:?}");
2727            assert!(
2728                log.sync().is_err() || log.head_seq() == 1,
2729                "a sync published past the failed fsync"
2730            );
2731            assert_eq!(
2732                log.head_seq(),
2733                1,
2734                "the frame whose fsync failed was published"
2735            );
2736            assert!(
2737                log.read(2).is_err(),
2738                "the frame whose fsync failed is readable"
2739            );
2740        }
2741        let log = Log::open(dir.path()).unwrap();
2742        assert_eq!(
2743            log.head_seq(),
2744            1,
2745            "a reopen resurrected the frame whose fsync failed"
2746        );
2747        assert_eq!(log.append_raw(Kind::Pack as u16, &m, b"again").unwrap(), 2);
2748        assert_eq!(log.read(2).unwrap().blob, b"again");
2749    }
2750
2751    /// ★ **A failed cut poisons the log** (stinky row 6). A frame whose write failed half way is
2752    /// cut back off the segment; when that truncate ALSO failed it was ignored (`let _ =`), the
2753    /// half frame stayed on disk and the next append landed after it while the index recorded
2754    /// the offset before it — every later read a corrupt header, replication broken from there.
2755    #[test]
2756    fn a_failed_cut_of_a_half_frame_poisons_the_log() {
2757        let dir = tempfile::tempdir().unwrap();
2758        let m = meta("g/r").to_bytes();
2759        {
2760            let log = Log::open(dir.path()).unwrap();
2761            assert_eq!(log.append_raw(Kind::Pack as u16, &m, b"one").unwrap(), 1);
2762            {
2763                let mut w = log.writer.lock().unwrap();
2764                // header, meta and the (directly written) blob go out; the trailer fails.
2765                w.faults.fail_put_after = Some(3);
2766                w.faults.fail_cut = true;
2767            }
2768            let big = vec![7u8; DIRECT_BYTES * 2];
2769            log.append_raw(Kind::Pack as u16, &m, &big)
2770                .expect_err("its trailer failed");
2771            let next = log.append_raw(Kind::Pack as u16, &m, b"three");
2772            assert!(
2773                next.is_err(),
2774                "an append after a failed cut landed after the half frame: {next:?}"
2775            );
2776            assert_eq!(log.head_seq(), 1);
2777        }
2778        let log = Log::open(dir.path()).unwrap();
2779        assert_eq!(log.head_seq(), 1);
2780        assert_eq!(log.append_raw(Kind::Pack as u16, &m, b"three").unwrap(), 2);
2781        assert_eq!(log.read(2).unwrap().blob, b"three");
2782    }
2783
2784    #[test]
2785    fn crc32c_known_answer() {
2786        // RFC 3720 / standard check value for "123456789".
2787        assert_eq!(crc32c(b"123456789"), 0xE306_9283);
2788        // Carried across a restart: the state kept after "1234" goes on to the same answer.
2789        let mut a = Crc32c::new();
2790        a.update(b"1234");
2791        let mut b = Crc32c::resume(a.state());
2792        b.update(b"56789");
2793        assert_eq!(b.finish(), 0xE306_9283);
2794    }
2795
2796    #[test]
2797    fn appends_are_dense_and_read_back() {
2798        let dir = tempfile::tempdir().unwrap();
2799        let log = Log::open(dir.path()).unwrap();
2800        assert_eq!(log.head_seq(), 0);
2801        assert_eq!(log.first_seq(), 1);
2802        for i in 1..=10u64 {
2803            let s = log
2804                .append(Kind::Pack, &meta("a/b"), &[i as u8; 100])
2805                .unwrap();
2806            assert_eq!(s, i);
2807        }
2808        assert_eq!(log.head_seq(), 10);
2809        let e = log.read(7).unwrap();
2810        assert_eq!(e.seq, 7);
2811        assert_eq!(e.kind(), Some(Kind::Pack));
2812        assert_eq!(e.blob, vec![7u8; 100]);
2813        assert_eq!(e.meta().unwrap(), meta("a/b"));
2814        assert!(matches!(log.read(11), Err(Error::BeyondHead { .. })));
2815        assert!(matches!(log.read(0), Err(Error::NotRetained { .. })));
2816    }
2817
2818    #[test]
2819    fn reopen_keeps_head_and_trusts_index() {
2820        let dir = tempfile::tempdir().unwrap();
2821        {
2822            let log = Log::open(dir.path()).unwrap();
2823            for i in 0..5u64 {
2824                log.append(Kind::Refs, &meta("x/y"), &[i as u8]).unwrap();
2825            }
2826        }
2827        let log = Log::open(dir.path()).unwrap();
2828        assert_eq!(log.head_seq(), 5);
2829        assert_eq!(log.read(5).unwrap().blob, vec![4u8]);
2830        assert_eq!(log.append(Kind::Refs, &meta("x/y"), b"6").unwrap(), 6);
2831    }
2832
2833    #[test]
2834    fn torn_tail_is_cut_and_the_log_goes_on() {
2835        let dir = tempfile::tempdir().unwrap();
2836        {
2837            let log = Log::open(dir.path()).unwrap();
2838            log.append(Kind::Pack, &meta("a/b"), &[1u8; 1000]).unwrap();
2839            log.append(Kind::Pack, &meta("a/b"), &[2u8; 1000]).unwrap();
2840        }
2841        let path = seg_path(dir.path(), 1);
2842        let len = fs::metadata(&path).unwrap().len();
2843        // Tear the last frame: cut 100 bytes off it.
2844        let f = OpenOptions::new().write(true).open(&path).unwrap();
2845        f.set_len(len - 100).unwrap();
2846        let log = Log::open(dir.path()).unwrap();
2847        assert_eq!(log.head_seq(), 1, "the torn second frame is gone");
2848        assert_eq!(
2849            fs::metadata(&path).unwrap().len(),
2850            len - 1000 - 32 - 4 - meta("a/b").to_bytes().len() as u64
2851        );
2852        assert_eq!(log.append(Kind::Pack, &meta("a/b"), &[3u8; 10]).unwrap(), 2);
2853        drop(log);
2854        let log = Log::open(dir.path()).unwrap();
2855        assert_eq!(log.read(2).unwrap().blob, vec![3u8; 10]);
2856    }
2857
2858    /// One frame through `begin` → `write` → `commit_unsynced`, at `seq`.
2859    fn unsynced(log: &Log, seq: u64, blob: &[u8]) -> u64 {
2860        let m = meta("g/r").to_bytes();
2861        let mut p = log
2862            .begin(Some(seq), Kind::Pack as u16, &m, blob.len() as u64)
2863            .unwrap();
2864        p.write(blob).unwrap();
2865        p.commit_unsynced().unwrap()
2866    }
2867
2868    /// **A group commit is invisible until `sync`, then durable as one** (perf twin-apply-fsync):
2869    /// the frames hold their numbers (the next `begin` takes the one after), nobody reads them and
2870    /// the head does not move until `sync`, which writes the index once and moves the head to the
2871    /// group's last; a reopen finds them all.
2872    #[test]
2873    fn a_group_is_invisible_until_synced_and_then_durable_as_one() {
2874        let dir = tempfile::tempdir().unwrap();
2875        let log = Log::open(dir.path()).unwrap();
2876        log.set_follower(true);
2877        for seq in 1..=5u64 {
2878            assert_eq!(unsynced(&log, seq, &[seq as u8; 100]), seq);
2879        }
2880        assert_eq!(log.head_seq(), 0, "nothing acknowledged before the sync");
2881        assert_eq!(log.unsynced(), 5);
2882        assert!(
2883            matches!(log.read(1), Err(Error::BeyondHead { .. })),
2884            "a reader does not see a frame that is not durable"
2885        );
2886        assert!(
2887            matches!(
2888                log.begin(Some(5), Kind::Pack as u16, b"{}", 0),
2889                Err(Error::OutOfOrder {
2890                    got: 5,
2891                    expected: 6
2892                })
2893            ),
2894            "the gap rule counts the group"
2895        );
2896        assert_eq!(
2897            fs::metadata(idx_path(dir.path(), 1)).unwrap().len(),
2898            0,
2899            "the index is written after the data's fsync"
2900        );
2901        assert_eq!(log.sync().unwrap(), 5);
2902        assert_eq!((log.head_seq(), log.unsynced()), (5, 0));
2903        assert_eq!(log.read(3).unwrap().blob, vec![3u8; 100]);
2904        assert_eq!(
2905            fs::metadata(idx_path(dir.path(), 1)).unwrap().len(),
2906            5 * 8,
2907            "one index entry per frame"
2908        );
2909        assert_eq!(log.sync().unwrap(), 5, "nothing pending: nothing done");
2910        drop(log);
2911        let log = Log::open(dir.path()).unwrap();
2912        assert_eq!(log.head_seq(), 5);
2913        assert_eq!(log.read(5).unwrap().blob, vec![5u8; 100]);
2914    }
2915
2916    /// **A crash before the sync loses only what was never acknowledged.** A synced group stays;
2917    /// a group still staged in memory is simply gone (it never reached the file); a group whose
2918    /// bytes reached the file but were never fsynced is kept frame by frame where whole (each frame
2919    /// checks its own crc), and a half frame is cut. The log takes the next number either way.
2920    #[test]
2921    fn a_crash_before_the_sync_loses_only_what_was_never_acknowledged() {
2922        let dir = tempfile::tempdir().unwrap();
2923        let log = Log::open(dir.path()).unwrap();
2924        for seq in 1..=3u64 {
2925            unsynced(&log, seq, b"acked");
2926        }
2927        assert_eq!(log.sync().unwrap(), 3);
2928        // Staged, never written: a crash (here: the process forgets the log) loses them.
2929        unsynced(&log, 4, b"staged");
2930        unsynced(&log, 5, b"staged");
2931        drop(log);
2932        let log = Log::open(dir.path()).unwrap();
2933        assert_eq!(
2934            log.head_seq(),
2935            3,
2936            "the acknowledged group is whole; the staged one never reached the file"
2937        );
2938        // Written (a large piece goes straight to the file), never fsynced, then a half frame.
2939        let big = vec![7u8; DIRECT_BYTES * 2];
2940        unsynced(&log, 4, &big);
2941        let m = meta("g/r").to_bytes();
2942        let mut half = log
2943            .begin(Some(5), Kind::Pack as u16, &m, (DIRECT_BYTES * 4) as u64)
2944            .unwrap();
2945        half.write(&big).unwrap();
2946        std::mem::forget(half); // the process dies with the frame half-written: no cut, no sync
2947        let path = seg_path(dir.path(), 1);
2948        let on_disk = fs::metadata(&path).unwrap().len();
2949        drop(log);
2950        let log = Log::open(dir.path()).unwrap();
2951        assert_eq!(
2952            log.head_seq(),
2953            4,
2954            "the whole frame that reached the file is kept, the half one is cut"
2955        );
2956        assert_eq!(log.read(4).unwrap().blob, big);
2957        assert!(
2958            fs::metadata(&path).unwrap().len() < on_disk,
2959            "the half frame is gone from the segment"
2960        );
2961        assert_eq!(log.append(Kind::Pack, &meta("g/r"), b"next").unwrap(), 5);
2962    }
2963
2964    /// **A frame given up inside a group takes only itself** — the frames committed before it in
2965    /// the same group stay, staged or written, and the sync makes them durable.
2966    #[test]
2967    fn an_abort_inside_a_group_keeps_the_frames_committed_before_it() {
2968        let dir = tempfile::tempdir().unwrap();
2969        let log = Log::open(dir.path()).unwrap();
2970        unsynced(&log, 1, b"one");
2971        unsynced(&log, 2, &vec![2u8; DIRECT_BYTES]);
2972        let m = meta("g/r").to_bytes();
2973        {
2974            let mut p = log.begin(Some(3), Kind::Pack as u16, &m, 10).unwrap();
2975            p.write(b"abc").unwrap();
2976            // dropped without commit: cut
2977        }
2978        {
2979            let mut p = log
2980                .begin(Some(3), Kind::Pack as u16, &m, (DIRECT_BYTES * 2) as u64)
2981                .unwrap();
2982            p.write(&vec![9u8; DIRECT_BYTES]).unwrap();
2983            p.abort().unwrap();
2984        }
2985        assert_eq!(log.sync().unwrap(), 2);
2986        assert_eq!(log.read(2).unwrap().blob, vec![2u8; DIRECT_BYTES]);
2987        assert_eq!(unsynced(&log, 3, b"three"), 3);
2988        assert_eq!(log.sync().unwrap(), 3);
2989        drop(log);
2990        let log = Log::open(dir.path()).unwrap();
2991        assert_eq!(log.head_seq(), 3);
2992        assert_eq!(log.read(3).unwrap().blob, b"three".to_vec());
2993    }
2994
2995    /// **A waiter whose frame is already durable returns at once — it never waits for the NEXT
2996    /// leader's fsync** (r5 stall review, 2026-10-07: the UI rig's control writes took 10–36 s
2997    /// with every gunnar-server thread in `futex_wait`).
2998    ///
2999    /// The leader used to hold a `Mutex<()>` across its `fdatasync`, and a waiter could only see
3000    /// that its frame was durable after ACQUIRING that mutex. Here leader A's fsync covers frames
3001    /// 1 and 2; B (frame 3, staged after A took its snapshot) queues for the leader BEFORE X
3002    /// (frame 2). When A ends, the mutex goes to B, which leads frame 3's fsync — held here by a
3003    /// gate the test never opens until the end — and X, whose frame has been durable since A
3004    /// published, stays parked behind it. On a loaded disk that is one unrelated fsync after
3005    /// another for as long as writes keep coming.
3006    ///
3007    /// RED with the mutex (X does not return while B's fsync is held); GREEN with the flag and
3008    /// condvar, where every waiter re-reads the head on every wake-up.
3009    #[test]
3010    fn a_waiter_whose_frame_is_durable_returns_without_waiting_for_the_next_leaders_fsync() {
3011        use std::sync::mpsc::channel;
3012        use std::time::Duration;
3013        let dir = tempfile::tempdir().unwrap();
3014        let log = Arc::new(Log::open(dir.path()).unwrap());
3015        let m = meta("g/r").to_bytes();
3016        assert_eq!(log.stage_raw(Kind::Pack as u16, &m, b"one").unwrap(), 1);
3017        assert_eq!(log.stage_raw(Kind::Pack as u16, &m, b"x").unwrap(), 2);
3018        let (open_a, gate_a) = channel::<()>();
3019        let (open_b, gate_b) = channel::<()>();
3020        log.hold_next_fsync(gate_a);
3021        log.hold_next_fsync(gate_b);
3022        let gates_left = |log: &Log| log.fsync_gates.lock().unwrap().len();
3023
3024        // A leads frames 1..=2 and is held before its fsync.
3025        let a = {
3026            let log = Arc::clone(&log);
3027            std::thread::spawn(move || log.sync_through(1).unwrap())
3028        };
3029        while gates_left(&log) != 1 {
3030            std::thread::sleep(Duration::from_millis(5));
3031        }
3032        // Frame 3 is staged after A's snapshot; B waits for it first, then X for frame 2.
3033        assert_eq!(log.stage_raw(Kind::Pack as u16, &m, b"three").unwrap(), 3);
3034        let b = {
3035            let log = Arc::clone(&log);
3036            std::thread::spawn(move || log.sync_through(3).unwrap())
3037        };
3038        std::thread::sleep(Duration::from_millis(200));
3039        let (x_done, x_said) = channel::<()>();
3040        let x = {
3041            let log = Arc::clone(&log);
3042            std::thread::spawn(move || {
3043                log.sync_through(2).unwrap();
3044                let _ = x_done.send(());
3045            })
3046        };
3047        std::thread::sleep(Duration::from_millis(200));
3048
3049        // A's fsync goes through: frames 1 and 2 are durable and published.
3050        open_a.send(()).unwrap();
3051        a.join().unwrap();
3052        assert!(log.head_seq() >= 2, "A published frames 1 and 2");
3053        // B now leads frame 3 and is held before ITS fsync. X's frame is durable: X must be back.
3054        let t = std::time::Instant::now();
3055        while gates_left(&log) != 0 && t.elapsed() < Duration::from_secs(5) {
3056            std::thread::sleep(Duration::from_millis(5));
3057        }
3058        let held = gates_left(&log) == 0;
3059        let back = x_said.recv_timeout(Duration::from_secs(5));
3060        open_b.send(()).unwrap();
3061        b.join().unwrap();
3062        x.join().unwrap();
3063        assert!(held, "B led frame 3's fsync (its gate was taken)");
3064        assert!(
3065            back.is_ok(),
3066            "X's frame was durable since A published it, yet X waited behind B's fsync of frame 3"
3067        );
3068        assert_eq!(log.head_seq(), 3);
3069    }
3070
3071    /// **An account's erase costs about two fsyncs per segment, and appends do not wait for it**
3072    /// (R5.1-B, 2026-10-07). `Log::erase` held the writer lock for the whole erase and made an
3073    /// `fdatasync` per erased frame (the redacted header) plus one per segment: an account with
3074    /// 200 frames was ~200 fsyncs with every append on the box — every control write, every
3075    /// audited call — waiting behind them. Per segment, ONE fdatasync makes every redacted header
3076    /// durable before any byte is punched, and ONE fsync follows the punches; the writer lock is
3077    /// taken only to publish and to read the plan.
3078    #[test]
3079    fn an_erase_is_two_fsyncs_per_segment_and_appends_do_not_wait_for_it() {
3080        use std::sync::mpsc::channel;
3081        use std::time::Duration;
3082        let dir = tempfile::tempdir().unwrap();
3083        let log = Arc::new(Log::open_with(dir.path(), 64 << 10).unwrap());
3084        for i in 0..200u64 {
3085            let repo = if i % 2 == 0 { "gone/a" } else { "stays/b" };
3086            log.append(Kind::Pack, &meta(repo), &vec![i as u8; 1500])
3087                .unwrap();
3088            log.append(Kind::Pack, &meta("gone/a"), &vec![7u8; 600])
3089                .unwrap();
3090        }
3091        let before = log.append(Kind::Refs, &meta("stays/b"), b"marker").unwrap();
3092        let segments = log.segments.lock().unwrap().len() as u64;
3093        let syncs0 = log.erase_syncs();
3094
3095        // The erase is held once its first header is down; an append meanwhile must not wait.
3096        let (open, gate) = channel::<()>();
3097        log.hold_next_erase(gate);
3098        let eraser = {
3099            let log = Arc::clone(&log);
3100            std::thread::spawn(move || log.erase(before, &["gone/a".to_string()]).unwrap())
3101        };
3102        while !log.erase_gates.lock().unwrap().is_empty() {
3103            std::thread::sleep(Duration::from_millis(5));
3104        }
3105        let (done, said) = channel::<u64>();
3106        let appender = {
3107            let log = Arc::clone(&log);
3108            std::thread::spawn(move || {
3109                let s = log.append(Kind::Refs, &meta("stays/b"), b"during").unwrap();
3110                let _ = done.send(s);
3111            })
3112        };
3113        let appended = said.recv_timeout(Duration::from_secs(3));
3114        open.send(()).unwrap();
3115        let erased = eraser.join().unwrap();
3116        appender.join().unwrap();
3117        let syncs = log.erase_syncs() - syncs0;
3118        let line = format!(
3119            "erased {} frames in {segments} segments with {syncs} fsyncs",
3120            erased.frames
3121        );
3122        eprintln!("{line}");
3123        assert_eq!(erased.frames, 300, "{line}");
3124        assert!(appended.is_ok(), "an append waited for the erase: {line}");
3125        assert!(
3126            syncs <= 2 * segments,
3127            "about two fsyncs per segment: {line}"
3128        );
3129        // Erased frames read as erased, the others as they were.
3130        for seq in 1..before {
3131            let e = log.read(seq).unwrap();
3132            let gone = Meta::from_bytes(&e.meta).unwrap().repo() == Some("gone/a");
3133            assert_eq!(e.kind == Kind::Redacted as u16, gone, "seq {seq}");
3134            assert_eq!(e.blob.is_empty(), gone, "seq {seq}");
3135        }
3136    }
3137
3138    /// **A group that crosses a segment roll** is published at the roll (the old segment is synced
3139    /// whole before the new one starts) and the rest at the sync: every frame reads back, across
3140    /// segments, before and after a reopen. A synced `append` after a group makes the group durable
3141    /// first, in order.
3142    #[test]
3143    fn a_group_across_a_segment_roll_and_a_synced_append_after_it() {
3144        let dir = tempfile::tempdir().unwrap();
3145        let log = Log::open_with(dir.path(), 4096).unwrap();
3146        for seq in 1..=40u64 {
3147            unsynced(&log, seq, &vec![seq as u8; 300]);
3148        }
3149        assert!(
3150            log.head_seq() > 0,
3151            "the rolls published the groups in the closed segments"
3152        );
3153        assert_eq!(log.sync().unwrap(), 40);
3154        unsynced(&log, 41, b"group");
3155        assert_eq!(
3156            log.append(Kind::Pack, &meta("g/r"), b"synced").unwrap(),
3157            42,
3158            "the append comes after the group"
3159        );
3160        assert_eq!(log.head_seq(), 42);
3161        for seq in 1..=40u64 {
3162            assert_eq!(
3163                log.read(seq).unwrap().blob,
3164                vec![seq as u8; 300],
3165                "seq {seq}"
3166            );
3167        }
3168        drop(log);
3169        let log = Log::open_with(dir.path(), 4096).unwrap();
3170        assert_eq!(log.head_seq(), 42);
3171        assert_eq!(log.read(41).unwrap().blob, b"group".to_vec());
3172        assert_eq!(log.read(42).unwrap().blob, b"synced".to_vec());
3173    }
3174
3175    #[test]
3176    fn torn_header_is_cut_too() {
3177        let dir = tempfile::tempdir().unwrap();
3178        {
3179            let log = Log::open(dir.path()).unwrap();
3180            log.append(Kind::Pack, &meta("a/b"), &[1u8; 10]).unwrap();
3181        }
3182        let path = seg_path(dir.path(), 1);
3183        let mut f = OpenOptions::new().append(true).open(&path).unwrap();
3184        f.write_all(b"GTX1\x05\x00\x00").unwrap(); // seven bytes of a header
3185        let log = Log::open(dir.path()).unwrap();
3186        assert_eq!(log.head_seq(), 1);
3187        assert_eq!(log.append(Kind::Pack, &meta("a/b"), &[2u8; 10]).unwrap(), 2);
3188    }
3189
3190    /// `scan_meta` answers exactly what `locate_blob` answers, frame by frame, across segment
3191    /// boundaries and a retention hole, with blobs of every size skipped (T17).
3192    #[test]
3193    fn scan_meta_agrees_with_locate_blob_across_segments_and_retention() {
3194        let dir = tempfile::tempdir().unwrap();
3195        let log = Log::open_with(dir.path(), 4096).unwrap();
3196        for i in 0..200u64 {
3197            let blob = vec![i as u8; (i as usize * 37) % 9000];
3198            log.append(Kind::Pack, &meta(&format!("r/{i}")), &blob)
3199                .unwrap();
3200        }
3201        log.retain_from(50).unwrap();
3202        let mut seen = Vec::new();
3203        log.scan_meta(|seq, kind, m| seen.push((seq, kind, m.to_vec())))
3204            .unwrap();
3205        let want: Vec<_> = (log.first_seq()..=log.head_seq())
3206            .map(|s| {
3207                let at = log.locate_blob(s).unwrap();
3208                (at.seq, at.kind, at.meta)
3209            })
3210            .collect();
3211        assert!(log.first_seq() > 1 && !want.is_empty());
3212        assert_eq!(seen, want);
3213    }
3214
3215    #[test]
3216    fn corrupted_payload_in_tail_is_cut() {
3217        let dir = tempfile::tempdir().unwrap();
3218        let first_len;
3219        {
3220            let log = Log::open(dir.path()).unwrap();
3221            log.append(Kind::Pack, &meta("a/b"), &[1u8; 10]).unwrap();
3222            first_len = log.bytes();
3223            log.append(Kind::Pack, &meta("a/b"), &[2u8; 10]).unwrap();
3224        }
3225        let path = seg_path(dir.path(), 1);
3226        let mut f = OpenOptions::new().write(true).open(&path).unwrap();
3227        // Flip one byte of the second frame's blob.
3228        f.seek(SeekFrom::Start(
3229            first_len + 32 + meta("a/b").to_bytes().len() as u64 + 3,
3230        ))
3231        .unwrap();
3232        f.write_all(&[0xEE]).unwrap();
3233        let log = Log::open(dir.path()).unwrap();
3234        assert_eq!(log.head_seq(), 1);
3235    }
3236
3237    #[test]
3238    fn stale_index_is_rebuilt_from_data() {
3239        let dir = tempfile::tempdir().unwrap();
3240        {
3241            let log = Log::open(dir.path()).unwrap();
3242            for _ in 0..4 {
3243                log.append(Kind::Pack, &meta("a/b"), &[9u8; 10]).unwrap();
3244            }
3245        }
3246        // Lose the index entirely, and separately make it lie.
3247        fs::remove_file(idx_path(dir.path(), 1)).unwrap();
3248        fs::write(idx_path(dir.path(), 1), []).unwrap();
3249        let log = Log::open(dir.path()).unwrap();
3250        assert_eq!(log.head_seq(), 4);
3251        drop(log);
3252        fs::write(idx_path(dir.path(), 1), [0u8; 8 * 9]).unwrap();
3253        let log = Log::open(dir.path()).unwrap();
3254        assert_eq!(log.head_seq(), 4);
3255        assert_eq!(log.read(3).unwrap().seq, 3);
3256    }
3257
3258    #[test]
3259    fn segments_roll_and_retention_drops_whole_segments() {
3260        let dir = tempfile::tempdir().unwrap();
3261        let log = Log::open_with(dir.path(), 300).unwrap();
3262        for i in 1..=20u64 {
3263            log.append(Kind::Pack, &meta("a/b"), &[i as u8; 100])
3264                .unwrap();
3265        }
3266        assert!(log.segments.lock().unwrap().len() > 3);
3267        assert_eq!(log.read(1).unwrap().blob[0], 1);
3268        let first = log.truncate_before(10).unwrap();
3269        assert!(first <= 10 && first > 1, "first={first}");
3270        assert!(matches!(log.read(1), Err(Error::NotRetained { .. })));
3271        assert_eq!(log.read(10).unwrap().blob[0], 10);
3272        assert_eq!(log.read(20).unwrap().blob[0], 20);
3273        drop(log);
3274        let log = Log::open_with(dir.path(), 300).unwrap();
3275        assert_eq!(log.first_seq(), first);
3276        assert_eq!(log.head_seq(), 20);
3277        assert_eq!(
3278            log.append(Kind::Pack, &meta("a/b"), &[21u8; 10]).unwrap(),
3279            21
3280        );
3281    }
3282
3283    #[test]
3284    fn append_at_refuses_out_of_order() {
3285        let dir = tempfile::tempdir().unwrap();
3286        let log = Log::open(dir.path()).unwrap();
3287        let m = meta("a/b").to_bytes();
3288        assert_eq!(log.append_at(1, 1, &m, b"x").unwrap(), 1);
3289        assert!(matches!(
3290            log.append_at(3, 1, &m, b"x"),
3291            Err(Error::OutOfOrder {
3292                got: 3,
3293                expected: 2
3294            })
3295        ));
3296        assert!(matches!(
3297            log.append_at(1, 1, &m, b"x"),
3298            Err(Error::OutOfOrder { .. })
3299        ));
3300        assert_eq!(log.append_at(2, 1, &m, b"x").unwrap(), 2);
3301    }
3302
3303    #[test]
3304    fn streaming_append_commits_and_aborts() {
3305        let dir = tempfile::tempdir().unwrap();
3306        let log = Log::open(dir.path()).unwrap();
3307        let m = meta("a/b").to_bytes();
3308        {
3309            let mut p = log.begin(Some(1), 1, &m, 6).unwrap();
3310            p.write(b"abc").unwrap();
3311            p.write(b"def").unwrap();
3312            assert_eq!(p.commit().unwrap(), 1);
3313        }
3314        {
3315            let mut p = log.begin(None, 1, &m, 6).unwrap();
3316            p.write(b"abc").unwrap();
3317            p.abort().unwrap();
3318        }
3319        assert_eq!(log.head_seq(), 1);
3320        {
3321            let mut p = log.begin(None, 1, &m, 3).unwrap();
3322            p.write(b"xy").unwrap();
3323            assert!(p.commit().is_err(), "short blob is refused");
3324        }
3325        assert_eq!(log.head_seq(), 1);
3326        assert_eq!(log.append_at(2, 1, &m, b"ok").unwrap(), 2);
3327        drop(log);
3328        let log = Log::open(dir.path()).unwrap();
3329        assert_eq!(log.head_seq(), 2);
3330        assert_eq!(log.read(1).unwrap().blob, b"abcdef");
3331        assert_eq!(log.read(2).unwrap().blob, b"ok");
3332        let l = log.locate_blob(1).unwrap();
3333        assert_eq!(l.blob_len, 6);
3334        let mut f = File::open(&l.path).unwrap();
3335        f.seek(SeekFrom::Start(l.blob_offset)).unwrap();
3336        let mut b = vec![0u8; 6];
3337        f.read_exact(&mut b).unwrap();
3338        assert_eq!(b, b"abcdef");
3339    }
3340
3341    /// **A streaming append DROPPED half-way leaves nothing** (twin-streaming, 2026-10-06): the
3342    /// shape of a twin's fetch whose wire broke inside an entry, or any `?` between `begin` and
3343    /// `commit`. Without `Drop` cutting it, the half frame stayed in the segment, the next append
3344    /// went after it (`O_APPEND`) while the index recorded the offset before it, and that next
3345    /// frame read back as a corrupt header — in this process AND after a restart.
3346    #[test]
3347    fn a_streaming_append_dropped_half_way_leaves_nothing_and_the_next_append_reads_back() {
3348        let dir = tempfile::tempdir().unwrap();
3349        let log = Log::open(dir.path()).unwrap();
3350        let m = meta("a/b").to_bytes();
3351        assert_eq!(log.append_at(1, 1, &m, b"first").unwrap(), 1);
3352        let before = log.bytes();
3353        {
3354            let mut p = log.begin(Some(2), 1, &m, 1 << 20).unwrap();
3355            p.write(&[7u8; 4096]).unwrap();
3356            // dropped: no commit, no abort
3357        }
3358        assert_eq!(log.head_seq(), 1, "the dropped frame is not in the log");
3359        let seg = log.locate_blob(1).unwrap().path;
3360        assert_eq!(
3361            std::fs::metadata(&seg).unwrap().len(),
3362            before,
3363            "and not in the segment file either"
3364        );
3365        assert_eq!(log.append_at(2, 1, &m, b"second").unwrap(), 2);
3366        assert_eq!(
3367            log.read(2).unwrap().blob,
3368            b"second",
3369            "the next append reads back in this process"
3370        );
3371        drop(log);
3372        let log = Log::open(dir.path()).unwrap();
3373        assert_eq!(log.head_seq(), 2);
3374        assert_eq!(log.read(1).unwrap().blob, b"first");
3375        assert_eq!(log.read(2).unwrap().blob, b"second", "and after a restart");
3376    }
3377
3378    /// **`read_pieces` hands a frame over in pieces and the last one only after the crc** — the
3379    /// primary's half of a twin's streamed fetch: the pieces are the blob, byte for byte; an
3380    /// empty blob and an erased frame are one empty last piece; and a frame whose bytes rotted
3381    /// on disk delivers every piece BUT the last and then refuses, so a reader that commits on
3382    /// `last` never commits it.
3383    #[test]
3384    fn read_pieces_streams_the_blob_and_withholds_the_last_piece_of_a_corrupt_frame() {
3385        let dir = tempfile::tempdir().unwrap();
3386        let log = Log::open(dir.path()).unwrap();
3387        let blob: Vec<u8> = (0..10_000u32).map(|i| (i * 7 % 251) as u8).collect();
3388        let s = log.append(Kind::Pack, &meta("a/b"), &blob).unwrap();
3389        let empty = log.append(Kind::Refs, &meta("a/b"), b"").unwrap();
3390        let rot = log.append(Kind::Pack, &meta("a/c"), &blob).unwrap();
3391        let mut got = Vec::new();
3392        let mut lasts = 0;
3393        // The checked payload crc rides on the LAST piece only (restore-large-entry).
3394        let mut want = Crc32c::new();
3395        want.update(&meta("a/b").to_bytes());
3396        want.update(&blob);
3397        let want = want.finish();
3398        let at = log
3399            .read_pieces::<Error>(s, 3000, |l, p, last| {
3400                assert_eq!(l.seq, s);
3401                assert!(p.len() <= 3000);
3402                assert_eq!(
3403                    l.payload_crc,
3404                    last.then_some(want),
3405                    "the crc is handed over with the last piece and only then"
3406                );
3407                got.extend_from_slice(p);
3408                lasts += usize::from(last);
3409                Ok(())
3410            })
3411            .unwrap();
3412        assert_eq!(
3413            (got, lasts, at.blob_len),
3414            (blob.clone(), 1, blob.len() as u64)
3415        );
3416        assert_eq!(at.payload_crc, Some(want));
3417        // From an offset (a resumed restore): only the bytes from it, the stored crc on the last
3418        // piece; at the blob's end one empty last piece; past it, or on nothing, refused.
3419        let mut tail = Vec::new();
3420        let at = log
3421            .read_pieces_from::<Error>(s, 4321, 3000, |l, p, last| {
3422                assert!(p.len() <= 3000);
3423                assert_eq!(l.payload_crc, last.then_some(want));
3424                tail.extend_from_slice(p);
3425                Ok(())
3426            })
3427            .unwrap();
3428        assert_eq!(
3429            (tail.as_slice(), at.payload_crc),
3430            (&blob[4321..], Some(want))
3431        );
3432        let mut end = Vec::new();
3433        log.read_pieces_from::<Error>(s, blob.len() as u64, 3000, |l, p, last| {
3434            end.push((p.len(), last, l.payload_crc));
3435            Ok(())
3436        })
3437        .unwrap();
3438        assert_eq!(end, vec![(0, true, Some(want))]);
3439        assert!(log
3440            .read_pieces_from::<Error>(s, blob.len() as u64 + 1, 3000, |_, _, _| Ok(()))
3441            .is_err());
3442        let mut pieces = Vec::new();
3443        log.read_pieces::<Error>(empty, 3000, |_, p, last| {
3444            pieces.push((p.len(), last));
3445            Ok(())
3446        })
3447        .unwrap();
3448        assert_eq!(pieces, vec![(0, true)]);
3449        // One byte in the middle of the third frame's blob flips on disk.
3450        let l = log.locate_blob(rot).unwrap();
3451        let mut f = OpenOptions::new()
3452            .read(true)
3453            .write(true)
3454            .open(&l.path)
3455            .unwrap();
3456        f.seek(SeekFrom::Start(l.blob_offset + 5000)).unwrap();
3457        let mut b = [0u8; 1];
3458        f.read_exact(&mut b).unwrap();
3459        f.seek(SeekFrom::Start(l.blob_offset + 5000)).unwrap();
3460        f.write_all(&[b[0] ^ 0xFF]).unwrap();
3461        drop(f);
3462        let mut seen = 0usize;
3463        let mut last_seen = false;
3464        let r = log.read_pieces::<Error>(rot, 3000, |_, p, last| {
3465            seen += p.len();
3466            last_seen |= last;
3467            Ok(())
3468        });
3469        assert!(
3470            matches!(
3471                r,
3472                Err(Error::Corrupt {
3473                    what: "payload crc",
3474                    ..
3475                })
3476            ),
3477            "{r:?}"
3478        );
3479        assert!(
3480            !last_seen,
3481            "the last piece of a corrupt frame is never handed over"
3482        );
3483        assert!(
3484            seen < blob.len(),
3485            "{seen} of {} bytes went out before the refusal",
3486            blob.len()
3487        );
3488        assert!(
3489            matches!(log.read(rot), Err(Error::Corrupt { .. })),
3490            "read refuses the same frame"
3491        );
3492        // An erased frame: one empty last piece, unchecked.
3493        log.erase(rot + 1, &["a/b".to_string()]).unwrap();
3494        let mut erased = Vec::new();
3495        log.read_pieces::<Error>(s, 3000, |l, p, last| {
3496            erased.push((l.kind, p.len(), last));
3497            Ok(())
3498        })
3499        .unwrap();
3500        assert_eq!(erased, vec![(Kind::Redacted as u16, 0, true)]);
3501    }
3502
3503    #[test]
3504    fn unknown_kind_is_stored_and_read_back() {
3505        let dir = tempfile::tempdir().unwrap();
3506        let log = Log::open(dir.path()).unwrap();
3507        log.append_raw(999, b"{}", b"future").unwrap();
3508        let e = log.read(1).unwrap();
3509        assert_eq!(e.kind(), None);
3510        assert_eq!(e.blob, b"future");
3511    }
3512
3513    #[test]
3514    fn reset_starts_over_at_one_and_refuses_a_box_with_history() {
3515        let dir = tempfile::tempdir().unwrap();
3516        let log = Log::open(dir.path()).unwrap();
3517        log.append(Kind::Kek, &Meta::Kek {}, &[7u8; 32]).unwrap();
3518        // A box with more frames than the door allows is not fresh.
3519        assert!(log.reset(0).is_err());
3520        assert_eq!(log.head_seq(), 1);
3521        log.reset(1).unwrap();
3522        assert_eq!(log.head_seq(), 0);
3523        assert_eq!(log.first_seq(), 1);
3524        // The twin's numbers go in from 1, and survive a reopen.
3525        log.append_at(1, Kind::Kek as u16, &Meta::Kek {}.to_bytes(), &[9u8; 32])
3526            .unwrap();
3527        drop(log);
3528        let log = Log::open(dir.path()).unwrap();
3529        assert_eq!(log.head_seq(), 1);
3530        assert_eq!(log.read(1).unwrap().blob, vec![9u8; 32]);
3531    }
3532
3533    #[test]
3534    fn kek_append_and_file_kinds_round_trip() {
3535        let dir = tempfile::tempdir().unwrap();
3536        let log = Log::open(dir.path()).unwrap();
3537        let metas = [
3538            (Kind::Kek, Meta::Kek {}),
3539            (
3540                Kind::Append,
3541                Meta::Append {
3542                    log: "attestations.jsonl".into(),
3543                },
3544            ),
3545            (
3546                Kind::File,
3547                Meta::File {
3548                    path: "replication.json".into(),
3549                    deleted: false,
3550                },
3551            ),
3552        ];
3553        for (k, m) in &metas {
3554            log.append(*k, m, b"x").unwrap();
3555        }
3556        for (i, (k, m)) in metas.iter().enumerate() {
3557            let e = log.read(i as u64 + 1).unwrap();
3558            assert_eq!(e.kind(), Some(*k));
3559            assert_eq!(&e.meta().unwrap(), m);
3560        }
3561    }
3562
3563    #[test]
3564    fn a_follower_log_refuses_originations_and_takes_the_primarys_numbers() {
3565        let dir = tempfile::tempdir().unwrap();
3566        let log = Log::open(dir.path()).unwrap();
3567        log.set_follower(true);
3568        assert!(matches!(
3569            log.append(Kind::Pack, &meta("a/b"), b"x"),
3570            Err(Error::Follower)
3571        ));
3572        assert!(matches!(
3573            log.begin(None, Kind::Pack as u16, b"{}", 1),
3574            Err(Error::Follower)
3575        ));
3576        log.append_at(1, Kind::Pack as u16, &meta("a/b").to_bytes(), b"x")
3577            .unwrap();
3578        assert_eq!(log.head_seq(), 1);
3579        log.set_follower(false);
3580        assert_eq!(log.append(Kind::Pack, &meta("a/b"), b"y").unwrap(), 2);
3581    }
3582
3583    #[test]
3584    fn cache_and_per_box_paths_are_judged_by_their_place_in_the_serialdat_tree() {
3585        for p in [
3586            "gc/x",
3587            ".serialdat/gc/x",
3588            "serialdat/quarantine/p.pack",
3589            ".serialdat/meta/identity.json",
3590            "repos/a/b.git/objects.exploded/1",
3591            ".korp-disktest/probe",
3592            "webhooks.json.tmp",
3593        ] {
3594            assert!(is_cache_path(p) && never_framed(p), "{p} is cache");
3595        }
3596        for p in [
3597            "crash/panic-1",
3598            ".serialdat/log/console.txt",
3599            "log/audit-chain.local.jsonl",
3600            ".serialdat/log/audit-chain.local.jsonl",
3601            "twin.key",
3602        ] {
3603            assert!(
3604                !is_cache_path(p) && is_per_box_path(p) && never_framed(p),
3605                "{p} is per box"
3606            );
3607        }
3608        for p in [
3609            "attestations.jsonl",
3610            "replication.json",
3611            "repos/a/b.git/pack",
3612            "control.redb",
3613        ] {
3614            assert!(!never_framed(p), "{p} is a transaction's path");
3615        }
3616    }
3617
3618    /// **The audit trail is a transaction, not a per-box record** (lane T11, 2026-09-21) — the
3619    /// D6 finding: a restore used to bring a box back with no audit history and say nothing.
3620    ///
3621    /// Both spellings the layout can produce, and the twin's OWN trail beside it, which must
3622    /// stay per box or two writers would interleave into one hash chain.
3623    ///
3624    /// **Fails before the fix**: delete the `if rel == AUDIT_REL { return false; }` early
3625    /// return from [`is_per_box_path`] and every assertion in the first two blocks reverses.
3626    #[test]
3627    fn the_audit_trail_is_framed_and_the_twins_own_trail_is_not() {
3628        for p in [
3629            "log/audit-chain.jsonl",
3630            ".serialdat/log/audit-chain.jsonl",
3631            "serialdat/log/audit-chain.jsonl",
3632            "./log/audit-chain.jsonl",
3633        ] {
3634            assert!(
3635                !is_per_box_path(p),
3636                "{p} is the audit trail and travels with the log"
3637            );
3638            assert!(
3639                !never_framed(p),
3640                "{p} must be framable, or a restore loses the audit trail"
3641            );
3642        }
3643        assert!(
3644            FRAMED_FILES.contains(&AUDIT_REL),
3645            "a snapshot must carry the audit trail"
3646        );
3647        // The twin's own decisions are its own: per box, never framed, never sent.
3648        assert!(is_per_box_path(AUDIT_LOCAL_REL) && never_framed(AUDIT_LOCAL_REL));
3649        assert!(!FRAMED_FILES.contains(&AUDIT_LOCAL_REL));
3650    }
3651
3652    /// **The site's hub declaration travels; the machine's own identity never does**
3653    /// (lane T11, 2026-09-21 — the owner's "config must be saved in twin too").
3654    ///
3655    /// `wg/hub.conf` is public site data the installer lays into the data root and that
3656    /// nothing in gunnar writes, so it had no sink call site and the twin never held it. A
3657    /// restored main without it is a box nobody can reach. `wg/identity.key` is the opposite
3658    /// case in every respect: a private key, minted on the machine, naming THIS box to the
3659    /// hub — it must never reach the twin and must never come back on a restore.
3660    ///
3661    /// **Fails before the fix**: drop `|| rel == HUB_CONF_REL` from the early return in
3662    /// [`is_per_box_path`] and the first block reverses; drop the `matches!(rel,
3663    /// WG_IDENTITY_KEY_REL | …)` block and the second does.
3664    #[test]
3665    fn the_hub_declaration_travels_and_the_machines_own_identity_never_does() {
3666        for p in [HUB_CONF_REL, ".serialdat/wg/hub.conf", "./wg/hub.conf"] {
3667            assert!(
3668                !never_framed(p),
3669                "{p} must travel: a restored main without it is unreachable"
3670            );
3671        }
3672        assert!(
3673            FRAMED_FILES.contains(&HUB_CONF_REL),
3674            "a snapshot must carry the hub declaration"
3675        );
3676        for p in [
3677            WG_IDENTITY_KEY_REL,
3678            WG_IDENTITY_PUB_REL,
3679            HUB_CONF_FRAMED_REL,
3680        ] {
3681            assert!(
3682                is_per_box_path(p) && never_framed(p),
3683                "{p} is this machine's own and never travels"
3684            );
3685            assert!(!FRAMED_FILES.contains(&p), "{p} must not be in a snapshot");
3686        }
3687    }
3688
3689    /// **A released log holds no descriptor under its directory** (growbench R5, 2026-09-15:
3690    /// the open segment kept `/data` busy and every runtime growth aborted with EBUSY), refuses
3691    /// every write by name, and after `reacquire` appends where it left off — on the live
3692    /// handle and across a reopen.
3693    #[test]
3694    #[cfg(target_os = "linux")]
3695    fn a_released_log_holds_nothing_under_its_dir_refuses_writes_and_resumes_after_reacquire() {
3696        let root = tempfile::tempdir().unwrap();
3697        let dir = root.path().join("txlog");
3698        let log = Log::open(&dir).unwrap();
3699        let holds = |dir: &Path| -> Vec<PathBuf> {
3700            fs::read_dir("/proc/self/fd")
3701                .unwrap()
3702                .filter_map(|e| fs::read_link(e.ok()?.path()).ok())
3703                .filter(|p| p.starts_with(dir))
3704                .collect()
3705        };
3706        log.append(Kind::Kek, &Meta::Kek {}, b"one").unwrap();
3707        log.append(Kind::Kek, &Meta::Kek {}, b"two").unwrap();
3708        assert!(!holds(&dir).is_empty(), "the writer holds the open segment");
3709        log.release().unwrap();
3710        log.release().unwrap();
3711        assert!(log.is_released());
3712        assert_eq!(
3713            holds(&dir),
3714            Vec::<PathBuf>::new(),
3715            "released: nothing under the dir"
3716        );
3717        assert!(matches!(
3718            log.append(Kind::Kek, &Meta::Kek {}, b"no"),
3719            Err(Error::Released(_))
3720        ));
3721        assert!(matches!(
3722            log.begin(None, 1, b"", 1),
3723            Err(Error::Released(_))
3724        ));
3725        assert_eq!(log.read(2).unwrap().blob, b"two", "readers open per call");
3726        log.reacquire().unwrap();
3727        log.reacquire().unwrap();
3728        assert_eq!(log.append(Kind::Kek, &Meta::Kek {}, b"three").unwrap(), 3);
3729        drop(log);
3730        let back = Log::open(&dir).unwrap();
3731        assert_eq!(back.head_seq(), 3);
3732        assert_eq!(back.read(3).unwrap().blob, b"three");
3733    }
3734
3735    /// A segment that moved while released is a different disk: refused, not appended to.
3736    #[test]
3737    fn reacquire_refuses_a_segment_that_changed_length() {
3738        let root = tempfile::tempdir().unwrap();
3739        let dir = root.path().join("txlog");
3740        let log = Log::open(&dir).unwrap();
3741        log.append(Kind::Kek, &Meta::Kek {}, b"one").unwrap();
3742        log.release().unwrap();
3743        OpenOptions::new()
3744            .append(true)
3745            .open(seg_path(&dir, 1))
3746            .unwrap()
3747            .write_all(b"junk")
3748            .unwrap();
3749        assert!(matches!(log.reacquire(), Err(Error::Corrupt { .. })));
3750        assert!(log.is_released(), "still refusing writes");
3751    }
3752
3753    #[test]
3754    fn the_sink_frames_writes_under_a_registered_root_and_nothing_elsewhere() {
3755        let root = tempfile::tempdir().unwrap();
3756        let other = tempfile::tempdir().unwrap();
3757        let log = Arc::new(Log::open(root.path().join("txlog")).unwrap());
3758        sink::register(root.path(), &log);
3759        drop(sink::append_line(&root.path().join("attestations.jsonl"), "{\"a\":1}").unwrap());
3760        drop(sink::replace_file(&root.path().join("webhooks.json"), b"[]", false).unwrap());
3761        drop(sink::append_line(&other.path().join("attestations.jsonl"), "{}").unwrap());
3762        assert_eq!(log.head_seq(), 2);
3763        assert_eq!(
3764            log.read(1).unwrap().meta().unwrap(),
3765            Meta::Append {
3766                log: "attestations.jsonl".into()
3767            }
3768        );
3769        assert_eq!(
3770            log.read(2).unwrap().meta().unwrap(),
3771            Meta::File {
3772                path: "webhooks.json".into(),
3773                deleted: false
3774            }
3775        );
3776        drop(log);
3777        // A dropped log unregisters itself: nothing to frame into, no error.
3778        drop(sink::append_line(&root.path().join("attestations.jsonl"), "{}").unwrap());
3779    }
3780
3781    #[test]
3782    fn restart_after_leaves_an_empty_log_whose_next_number_follows_the_snapshot_and_survives_a_reopen(
3783    ) {
3784        let dir = tempfile::tempdir().unwrap();
3785        {
3786            let log = Log::open(dir.path()).unwrap();
3787            log.append(Kind::Pack, &meta("a/b"), b"x").unwrap();
3788            log.restart_after(41).unwrap();
3789            assert_eq!((log.first_seq(), log.head_seq()), (42, 41));
3790            assert!(matches!(
3791                log.append_at(43, Kind::Pack as u16, b"{}", b""),
3792                Err(Error::OutOfOrder { .. })
3793            ));
3794            assert_eq!(
3795                log.append_at(42, Kind::Pack as u16, &meta("a/b").to_bytes(), b"y")
3796                    .unwrap(),
3797                42
3798            );
3799        }
3800        let log = Log::open(dir.path()).unwrap();
3801        assert_eq!((log.first_seq(), log.head_seq()), (42, 42));
3802        assert_eq!(log.read(42).unwrap().blob, b"y");
3803        assert!(matches!(log.read(1), Err(Error::NotRetained { .. })));
3804    }
3805
3806    #[test]
3807    fn a_freeze_waits_for_a_mutation_in_flight_and_holds_new_ones_until_it_ends() {
3808        use std::sync::atomic::AtomicU32;
3809        let dir = tempfile::tempdir().unwrap();
3810        let log = Arc::new(Log::open(dir.path()).unwrap());
3811        let stage = Arc::new(AtomicU32::new(0));
3812        // A mutation in flight on another thread: frame appended, store write not yet done.
3813        let (l, s) = (Arc::clone(&log), Arc::clone(&stage));
3814        let (go_tx, go_rx) = std::sync::mpsc::channel::<()>();
3815        let (in_tx, in_rx) = std::sync::mpsc::channel::<()>();
3816        let writer = std::thread::spawn(move || {
3817            let g = l.mutation();
3818            l.append(Kind::Pack, &meta("a/b"), b"1").unwrap();
3819            in_tx.send(()).unwrap();
3820            go_rx.recv().unwrap();
3821            s.store(1, Ordering::SeqCst); // "the store has the write"
3822            drop(g);
3823        });
3824        in_rx.recv().unwrap();
3825        let (l, s) = (Arc::clone(&log), Arc::clone(&stage));
3826        let (frozen_tx, frozen_rx) = std::sync::mpsc::channel::<()>();
3827        let freezer = std::thread::spawn(move || {
3828            let f = l.freeze();
3829            frozen_tx.send(()).unwrap();
3830            // The freeze returned: the write in flight reached its store first.
3831            assert_eq!(
3832                s.load(Ordering::SeqCst),
3833                1,
3834                "freeze returned before the mutation in flight ended"
3835            );
3836            let head = l.head_seq();
3837            std::thread::sleep(std::time::Duration::from_millis(50));
3838            assert_eq!(l.head_seq(), head, "no mutation started while frozen");
3839            s.store(2, Ordering::SeqCst);
3840            drop(f);
3841        });
3842        std::thread::sleep(std::time::Duration::from_millis(30));
3843        go_tx.send(()).unwrap();
3844        writer.join().unwrap();
3845        // A new mutation on this thread, started while frozen, waits for the freeze to end.
3846        frozen_rx.recv().unwrap();
3847        let g = log.mutation();
3848        assert_eq!(
3849            stage.load(Ordering::SeqCst),
3850            2,
3851            "a mutation started while the log was frozen"
3852        );
3853        log.append(Kind::Pack, &meta("a/b"), b"2").unwrap();
3854        drop(g);
3855        freezer.join().unwrap();
3856        // Nested guards on one thread count once, and the freezing thread passes its own gate.
3857        let outer = log.mutation();
3858        let inner = log.mutation();
3859        drop(inner);
3860        drop(outer);
3861        let f = log.freeze();
3862        let own = log.mutation();
3863        drop(own);
3864        drop(f);
3865    }
3866}
3867
3868#[cfg(test)]
3869mod spool_tests {
3870    //! **The log's directory holds one thing besides segments, and the log has
3871    //! to know it.**
3872
3873    use super::*;
3874
3875    /// **MEASURED 2026-09-17 on the live appliance, as a kernel panic.**
3876    ///
3877    /// `gunnar-server`'s `txlog_lfs` spools an arriving LFS object beside the
3878    /// log — `<txlog>/spool/<oid>.part` — and removes the FILE when the object
3879    /// lands. Nothing ever removes the DIRECTORY, so `create_dir_all` leaves it
3880    /// there for ever after the first LFS upload the box ever takes.
3881    ///
3882    /// [`Log::open_with`] then refused it: every entry that is not a segment
3883    /// name was `Error::Unexpected`. So one LFS push plus one restart was a
3884    /// box that could never open its transaction log again, and since gunnar is
3885    /// PID 1 the exit came back as
3886    /// `Kernel panic - not syncing: Attempted to kill init!`.
3887    ///
3888    /// It hid for as long as it did because it needs BOTH halves: the appliance
3889    /// had taken LFS objects and simply had not restarted since.
3890    ///
3891    /// The spool is part of this directory's layout, so the layout is what
3892    /// names it — one constant, used by the writer and skipped by the reader,
3893    /// rather than the reader guessing at a name the writer chose.
3894    #[test]
3895    fn the_lfs_spool_directory_is_part_of_the_layout_and_does_not_refuse_the_log() {
3896        let dir = tempfile::tempdir().expect("temp dir");
3897        let log = Log::open(dir.path()).expect("a fresh log opens");
3898        drop(log);
3899        std::fs::create_dir_all(dir.path().join(SPOOL_DIR)).expect("the spool dir");
3900        Log::open(dir.path()).expect("a log whose spool directory exists still opens");
3901    }
3902
3903    /// The skip is for the ONE name the layout describes, and nothing else: a
3904    /// stray file in the log's directory is still a refusal, because the
3905    /// alternative is a reader that shrugs at corruption.
3906    #[test]
3907    fn a_stray_entry_is_still_refused_by_name() {
3908        let dir = tempfile::tempdir().expect("temp dir");
3909        Log::open(dir.path()).expect("a fresh log opens");
3910        std::fs::write(dir.path().join("stray"), b"x").expect("write");
3911        let err = Log::open(dir.path()).expect_err("a stray file is not part of the layout");
3912        assert!(matches!(err, Error::Unexpected(_)), "{err}");
3913    }
3914}