Skip to main content

planarian_txn/
journal.rs

1//! The write-ahead journal (§12, §12c).
2//!
3//! Frame (format 2): `len: u32 | crc32c: u32 | seq: u64 | prev: [u8; 32] |
4//! payload`, little-endian, where `len` counts `prev` and the payload. The
5//! CRC covers a format tag, `seq`, `prev` and the payload.
6//!
7//! **Hash chain (I18).** `prev` is the chain head before this record, and the
8//! head after it is SHA-256(`prev` ‖ `seq` ‖ payload); the first record after
9//! an empty journal chains from zero, and the checkpoint carries the head at
10//! its seq in its own `prev`. A scan accepts frames only while each is
11//! intact, its seq is exactly one more than the last, and its `prev` is the
12//! current head. An intact frame that does not chain is an integrity failure.
13//! A frame that fails its CRC is the torn tail only if no intact record with
14//! a later seq follows it; then its header is zeroed and it is never
15//! interpreted. Otherwise a record in the middle was damaged or edited, which
16//! is an integrity failure too, and nothing is truncated: the evidence stays.
17//!
18//! The chain has no key: it detects damage and naive edits, not an attacker
19//! with root who rewrites every later record and the checkpoint consistently
20//! (measured boot, Phase E, anchors the head outside the disk).
21//!
22//! Compaction is checkpoint-then-truncate. The ledger is written as the
23//! checkpoint (atomic replace), then the log restarts at offset 0. Bytes left
24//! over from before compaction all carry seq ≤ the checkpoint's seq, so they can
25//! never pass as the next record.
26//!
27//! Several handles (the daemon and the watchdog) may share a store. Every
28//! append takes the store lock and first catches up on records others wrote.
29
30use crate::chain::{self, Follows, Link, Verdict};
31use crate::ledger::{Entry, InterlockEntry, Ledger, LedgerError, Record};
32use crate::store::Store;
33use planarian_schema::{GenerationId, Hash, Timestamp, Transition, TxnId};
34use std::io;
35
36const HEADER: usize = 16;
37const PREV: usize = 32;
38/// Prefixed to the CRC input, so a frame of another format never passes.
39const FORMAT: &[u8] = b"planarian-journal-2";
40
41#[derive(Debug)]
42pub enum JournalError {
43    Io(io::Error),
44    /// The preallocation cannot hold the record (compact, or take the sentinel path).
45    Full,
46    Ledger(LedgerError),
47    Corrupt(String),
48    /// The hash chain is broken, or a record before the tail is damaged (I18).
49    /// An `Integrity` failure (§12d); the journal is left as found.
50    Integrity(String),
51    /// Written by an earlier Planarian (journal format 1, before the chain).
52    Legacy,
53}
54
55impl From<io::Error> for JournalError {
56    fn from(e: io::Error) -> Self {
57        if e.raw_os_error() == Some(28) { JournalError::Full } else { JournalError::Io(e) }
58    }
59}
60
61impl From<LedgerError> for JournalError {
62    fn from(e: LedgerError) -> Self {
63        JournalError::Ledger(e)
64    }
65}
66
67/// A journal handle with the store lock held (see `Journal::locked`).
68pub struct Locked<'a, S: Store>(&'a mut Journal<S>);
69
70impl<S: Store> Locked<'_, S> {
71    pub fn ledger(&self) -> &Ledger {
72        &self.0.ledger
73    }
74
75    pub fn store(&self) -> &S {
76        &self.0.store
77    }
78
79    pub fn append(&mut self, at: Timestamp, record: Record) -> Result<Entry, JournalError> {
80        self.0.append_locked(at, record)
81    }
82}
83
84pub struct Journal<S: Store> {
85    store: S,
86    ledger: Ledger,
87    /// Byte offset of the next record in the log.
88    offset: u64,
89    /// The checkpoint seq this handle's ledger incorporates.
90    checkpoint_seq: u64,
91    /// The hash chain's head after the last record this handle has read (I18).
92    head: Hash,
93    /// During a reload: the sentinel's transaction and bytes, not yet applied
94    /// (§12c). Its interlock is applied just before a `Resolved` that names it,
95    /// or after the scan.
96    pending_sentinel: Option<(TxnId, Vec<u8>)>,
97}
98
99/// The chain head after a record (I18).
100pub fn chain(prev: &Hash, seq: u64, payload: &[u8]) -> Hash {
101    let mut b = Vec::with_capacity(PREV + 8 + payload.len());
102    b.extend_from_slice(&prev.0);
103    b.extend_from_slice(&seq.to_le_bytes());
104    b.extend_from_slice(payload);
105    Hash::of(&b)
106}
107
108fn crc(body: &[u8]) -> u32 {
109    #[cfg(test)]
110    tests::CRC_BYTES.with(|c| c.set(c.get() + body.len() as u64));
111    crc32c::crc32c_append(crc32c::crc32c(FORMAT), body)
112}
113
114fn frame(seq: u64, prev: &Hash, payload: &[u8]) -> Vec<u8> {
115    let mut body = Vec::with_capacity(8 + PREV + payload.len());
116    body.extend_from_slice(&seq.to_le_bytes());
117    body.extend_from_slice(&prev.0);
118    body.extend_from_slice(payload);
119    let mut out = Vec::with_capacity(HEADER + PREV + payload.len());
120    out.extend_from_slice(&((PREV + payload.len()) as u32).to_le_bytes());
121    out.extend_from_slice(&crc(&body).to_le_bytes());
122    out.extend_from_slice(&body);
123    out
124}
125
126/// An intact frame: its seq, the chain head before it, its payload, and its size.
127struct Frame<'a> {
128    seq: u64,
129    prev: Hash,
130    payload: &'a [u8],
131    size: usize,
132}
133
134/// Parse a frame header + body from `bytes`; `None` if it is not intact.
135fn unframe(bytes: &[u8]) -> Option<Frame<'_>> {
136    if bytes.len() < HEADER {
137        return None;
138    }
139    let len = u32::from_le_bytes(bytes[0..4].try_into().ok()?) as usize;
140    let sum = u32::from_le_bytes(bytes[4..8].try_into().ok()?);
141    if len < PREV || bytes.len() < HEADER + len {
142        return None;
143    }
144    let body = &bytes[8..HEADER + len];
145    if crc(body) != sum {
146        return None;
147    }
148    let seq = u64::from_le_bytes(body[0..8].try_into().ok()?);
149    let prev = Hash(body[8..8 + PREV].try_into().ok()?);
150    Some(Frame { seq, prev, payload: &body[8 + PREV..], size: HEADER + len })
151}
152
153/// Whether `bytes` could start a frame written after `last_seq`, judged on
154/// the header alone, before any CRC (the scan's search for a later record).
155/// Its length fits, and its seq is past `last_seq` by no more than the
156/// number of frames `capacity` holds: every record this segment can contain
157/// is within that, and leftovers from before a compaction carry seqs at or
158/// below the checkpoint's. And its payload starts as every appended one
159/// does, with the entry's own seq (`{"seq":<seq>,`, `Entry`'s first field):
160/// at a misaligned offset inside a leftover frame, a length and a seq can
161/// both look plausible, but not that too. Without these the search ran a
162/// CRC over the claimed length at every byte offset, quadratic in the store
163/// after a compaction (stale seqs and hashes read as plausible lengths).
164fn could_follow(bytes: &[u8], last_seq: u64, capacity: u64) -> bool {
165    if bytes.len() < HEADER {
166        return false;
167    }
168    let len = u32::from_le_bytes(bytes[0..4].try_into().expect("4 bytes")) as usize;
169    let seq = u64::from_le_bytes(bytes[8..16].try_into().expect("8 bytes"));
170    let most = capacity / (HEADER + PREV) as u64;
171    if len < PREV || bytes.len() < HEADER + len || seq <= last_seq || seq - last_seq > most {
172        return false;
173    }
174    let prefix = format!("{{\"seq\":{seq},");
175    bytes[HEADER + PREV..HEADER + len].starts_with(prefix.as_bytes())
176}
177
178/// Fault injection (tests and the gym): rewrite the payload of the `nth`
179/// record in the log (0-based, from offset 0) with `edit`, keeping its `prev`
180/// and recomputing its CRC, as someone editing the file would. The chain then
181/// breaks at the record after it (I18). False if there is no such record or
182/// the edit changes the payload's length.
183pub fn tamper<S: Store>(store: &S, nth: usize, edit: impl FnOnce(&mut Vec<u8>)) -> io::Result<bool> {
184    let mut off = 0u64;
185    for i in 0.. {
186        let header = store.read_at(off, HEADER)?;
187        if header.len() < HEADER {
188            return Ok(false);
189        }
190        let len = u32::from_le_bytes(header[0..4].try_into().expect("4 bytes")) as usize;
191        let bytes = store.read_at(off, HEADER + len)?;
192        let Some(f) = unframe(&bytes) else { return Ok(false) };
193        if i == nth {
194            let mut payload = f.payload.to_vec();
195            edit(&mut payload);
196            if payload.len() != f.payload.len() {
197                return Ok(false);
198            }
199            store.write_at(off, &frame(f.seq, &f.prev, &payload))?;
200            store.sync()?;
201            return Ok(true);
202        }
203        off += f.size as u64;
204    }
205    Ok(false)
206}
207
208/// Fault injection (tests and the gym): remove the `nth` record and re-frame
209/// every record after it one seq lower, each with its own `prev` and a valid
210/// CRC, so every frame is intact and the seqs run on; zero what is left at
211/// the end. The chain then breaks at the record after the deleted one (I18).
212/// False if there is no such record.
213pub fn delete<S: Store>(store: &S, nth: usize) -> io::Result<bool> {
214    let mut off = 0u64;
215    let mut at = None;
216    let mut rest: Vec<(u64, Hash, Vec<u8>)> = vec![];
217    for i in 0.. {
218        let header = store.read_at(off, HEADER)?;
219        if header.len() < HEADER {
220            break;
221        }
222        let len = u32::from_le_bytes(header[0..4].try_into().expect("4 bytes")) as usize;
223        let bytes = store.read_at(off, HEADER + len)?;
224        let Some(f) = unframe(&bytes) else { break };
225        if i == nth {
226            at = Some(off);
227        } else if at.is_some() {
228            rest.push((f.seq - 1, f.prev, f.payload.to_vec()));
229        }
230        off += f.size as u64;
231    }
232    let Some(start) = at else { return Ok(false) };
233    let mut out = vec![];
234    for (seq, prev, payload) in &rest {
235        out.extend_from_slice(&frame(*seq, prev, payload));
236    }
237    let end = start + out.len() as u64;
238    out.resize((off - start) as usize, 0);
239    store.write_at(start, &out)?;
240    debug_assert!(end <= off);
241    store.sync()?;
242    Ok(true)
243}
244
245/// Whether `bytes` start with an intact frame of format 1 (no chain, plain CRC).
246fn legacy_frame(bytes: &[u8]) -> bool {
247    if bytes.len() < HEADER {
248        return false;
249    }
250    let len = u32::from_le_bytes(bytes[0..4].try_into().expect("4 bytes")) as usize;
251    let sum = u32::from_le_bytes(bytes[4..8].try_into().expect("4 bytes"));
252    len > 0 && bytes.len() >= HEADER + len && crc32c::crc32c(&bytes[8..HEADER + len]) == sum
253}
254
255impl<S: Store> Journal<S> {
256    /// Load the checkpoint, replay the log, truncate a torn tail.
257    pub fn open(store: S) -> Result<Journal<S>, JournalError> {
258        let mut j = Journal {
259            store,
260            ledger: Ledger::default(),
261            offset: 0,
262            checkpoint_seq: 0,
263            head: Hash::ZERO,
264            pending_sentinel: None,
265        };
266        {
267            let _g = j.store.lock()?;
268            j.reload()?;
269            // A fence at or past `next_txn` has no record: its prepare died
270            // between creating the fence and journaling `Prepared` (C5).
271            let next = j.ledger.next_txn.max(1);
272            j.store.forget_fences(|t| t.0 < next)?;
273        }
274        Ok(j)
275    }
276
277    fn load_checkpoint(&self) -> Result<Option<(u64, Ledger, Hash)>, JournalError> {
278        let Some(bytes) = self.store.read_checkpoint()? else {
279            let first = self.store.read_at(0, HEADER)?;
280            let len = if first.len() == HEADER { u32::from_le_bytes(first[0..4].try_into().expect("4")) } else { 0 };
281            if len > 0 && legacy_frame(&self.store.read_at(0, HEADER + len as usize)?) {
282                return Err(JournalError::Legacy);
283            }
284            return Ok(None);
285        };
286        let Some(f) = unframe(&bytes) else {
287            return Err(if legacy_frame(&bytes) {
288                JournalError::Legacy
289            } else {
290                JournalError::Integrity("the checkpoint fails its CRC".into())
291            });
292        };
293        let (seq, payload) = (f.seq, f.payload);
294        let ledger: Ledger = serde_json::from_slice(payload)
295            .map_err(|e| JournalError::Integrity(format!("the checkpoint does not parse: {e}")))?;
296        if ledger.last_seq != seq {
297            return Err(JournalError::Integrity("the checkpoint's seq disagrees with its ledger".into()));
298        }
299        // The proofs show `apply` keeps the ledger invariant; a ledger read
300        // from disk is checked against it instead.
301        if !ledger.well_formed() {
302            return Err(JournalError::Integrity("the checkpoint breaks the ledger invariant".into()));
303        }
304        Ok(Some((seq, ledger, f.prev)))
305    }
306
307    fn checkpoint_seq_on_disk(&self) -> Result<u64, JournalError> {
308        Ok(match self.store.read_checkpoint()? {
309            Some(b) => unframe(&b).map(|f| f.seq).unwrap_or(0),
310            None => 0,
311        })
312    }
313
314    fn reload(&mut self) -> Result<(), JournalError> {
315        match self.load_checkpoint()? {
316            Some((seq, ledger, head)) => {
317                self.ledger = ledger;
318                self.checkpoint_seq = seq;
319                self.head = head;
320            }
321            None => {
322                self.ledger = Ledger::default();
323                self.checkpoint_seq = 0;
324                self.head = Hash::ZERO;
325            }
326        }
327        self.offset = 0;
328        // The sentinel's interlock came after the records already in the log
329        // when it was written, and before any `Resolved` that names it.
330        self.pending_sentinel = self.store.read_sentinel()?.and_then(|b| sentinel_txn(&b).map(|t| (t, b)));
331        let scanned = self.scan(true);
332        let pending = self.pending_sentinel.take();
333        scanned?;
334        // Not resolved in the log (or no Resolved since the checkpoint).
335        if let Some((_, bytes)) = pending {
336            self.apply_sentinel(&bytes);
337        }
338        Ok(())
339    }
340
341    /// At compaction: the checkpoint now carries the resolution, so the
342    /// sentinel of a resolved transaction is zeroed and a reload does not set
343    /// the interlock again. (A crash before the zeroing leaves it set: the
344    /// safe side; resolving again clears it.)
345    fn clear_resolved_sentinel(&mut self) -> Result<(), JournalError> {
346        if let Some(t) = self.store.read_sentinel()?.as_deref().and_then(sentinel_txn)
347            && !self.ledger.interlock.contains_key(&t)
348        {
349            self.store.write_sentinel(&[0; crate::store::SENTINEL_SIZE])?;
350        }
351        Ok(())
352    }
353
354    /// Read records from `self.offset` onward; the first frame that is not
355    /// intact, or whose seq does not follow, ends the scan.
356    ///
357    /// `reload` (scanning from offset 0 after loading the checkpoint) differs in
358    /// two ways. A leading run of frames at or below the checkpoint's seq is
359    /// skipped, which happens only if a crash came between writing a checkpoint
360    /// and resetting the log. And the frame that ends the scan has its header
361    /// zeroed (torn-tail truncation). When catching up from a later offset,
362    /// nothing is skipped: a frame there with an old seq is a leftover from
363    /// before a compaction, and skipping it would move the write offset past it.
364    fn scan(&mut self, reload: bool) -> Result<(), JournalError> {
365        let truncate = reload;
366        let mut skipping = reload && self.offset == 0;
367        let mut prev: Option<u64> = None;
368        loop {
369            let header = self.store.read_at(self.offset, HEADER)?;
370            let len = if header.len() == HEADER {
371                u32::from_le_bytes(header[0..4].try_into().expect("4 bytes")) as usize
372            } else {
373                0
374            };
375            // Read the body only for a header that could be the next record:
376            // its length fits, and its seq follows (or, skipping, is at or
377            // below it). Past the tail after a compaction the bytes are
378            // leftovers whose "length" can claim most of the store; reading
379            // it on every catch-up made each append and renewal cost the
380            // rest of the store. A header that could not be the next record
381            // takes the same path as a frame that is not intact.
382            let next = header.len() == HEADER && {
383                let seq = u64::from_le_bytes(header[8..16].try_into().expect("8 bytes"));
384                let fits = len >= PREV && self.offset.saturating_add((HEADER + len) as u64) <= self.store.capacity();
385                fits && (seq == self.ledger.last_seq.wrapping_add(1) || (skipping && seq <= self.ledger.last_seq))
386            };
387            // Tests compare against the scan before this check (`OLD_SCAN`).
388            #[cfg(test)]
389            let next = next || tests::OLD_SCAN.with(std::cell::Cell::get);
390            let bytes = if len == 0 || !next { header } else { self.store.read_at(self.offset, HEADER + len)? };
391            let Some(f) = unframe(&bytes) else {
392                if truncate && bytes.iter().take(HEADER).any(|b| *b != 0) {
393                    return self.stop_at_tail(truncate);
394                }
395                // A zeroed header is the end of the log only if no intact
396                // later record follows it; otherwise a record was erased (I18).
397                // (An erased header leaves the record's prev hash right after
398                // it; a fresh tail is zeros there, and needs no search.)
399                let after = self.store.read_at(self.offset + HEADER as u64, PREV)?;
400                if truncate
401                    && after.iter().any(|b| *b != 0)
402                    && let Some(seq) = self.later_record()?
403                {
404                    return Err(JournalError::Integrity(format!(
405                        "the record at byte {} was erased, but record {seq} follows it",
406                        self.offset
407                    )));
408                }
409                return Ok(());
410            };
411            let (seq, payload, size) = (f.seq, f.payload, f.size);
412            let consecutive = prev.is_none_or(|p| seq == p + 1);
413            if !consecutive {
414                return self.stop_at_tail(truncate);
415            }
416            if skipping && seq <= self.ledger.last_seq {
417                prev = Some(seq);
418                self.offset += size as u64;
419                continue;
420            }
421            skipping = false;
422            match chain::follows(self.ledger.last_seq, &self.head, seq, &f.prev) {
423                Follows::Yes => {}
424                Follows::OutOfSequence => return self.stop_at_tail(truncate),
425                Follows::Unchained => {
426                    return Err(JournalError::Integrity(format!(
427                        "record {seq} does not follow the hash chain (it may have been edited)"
428                    )));
429                }
430            }
431            // §12c Format: only a torn or mismatched tail is truncated (the
432            // frame check above). This frame is intact (its CRC holds and it
433            // follows the chain) yet does not parse: a retained record that
434            // does not parse is Integrity, here or anywhere, and nothing is
435            // truncated or zeroed.
436            let entry: Entry = match serde_json::from_slice(payload) {
437                Ok(e) => e,
438                Err(e) => {
439                    return Err(JournalError::Integrity(format!("record {seq} is intact but does not parse: {e}")));
440                }
441            };
442            if entry.seq != seq {
443                return self.stop_at_tail(truncate);
444            }
445            if let Record::Resolved { txn, .. } = &entry.record
446                && self.pending_sentinel.as_ref().is_some_and(|(t, _)| t == txn)
447                && let Some((_, bytes)) = self.pending_sentinel.take()
448            {
449                self.apply_sentinel(&bytes);
450            }
451            // A retained, intact record the ledger refuses is damage, not a
452            // reason to start empty: Integrity, never Absent (§12c, C16).
453            self.ledger
454                .apply(entry)
455                .map_err(|e| JournalError::Integrity(format!("record {seq} is refused on replay: {e:?}")))?;
456            self.head = chain(&f.prev, seq, payload);
457            prev = Some(seq);
458            self.offset += size as u64;
459        }
460    }
461
462    /// The scan stopped at `self.offset`. On reload that is the torn tail,
463    /// truncated, unless an intact record with a later seq follows it: then a
464    /// record before the tail was damaged or edited (I18), and nothing is
465    /// truncated.
466    fn stop_at_tail(&mut self, truncate: bool) -> Result<(), JournalError> {
467        if truncate {
468            if let Some(seq) = self.later_record()? {
469                return Err(JournalError::Integrity(format!(
470                    "the record at byte {} is damaged, but record {seq} follows it",
471                    self.offset
472                )));
473            }
474            self.store.write_at(self.offset, &[0; HEADER])?;
475            self.store.sync()?;
476        }
477        Ok(())
478    }
479
480    /// An intact frame after `self.offset` whose seq is past the ledger's: the
481    /// sign of a damaged record rather than a torn tail. Bytes left over from
482    /// before a compaction all carry seqs at or below the checkpoint's.
483    fn later_record(&self) -> Result<Option<u64>, JournalError> {
484        let start = self.offset + 1;
485        let rest = self.store.read_at(start, self.store.capacity().saturating_sub(start) as usize)?;
486        let mut i = 0;
487        let (last, capacity) = (self.ledger.last_seq, self.store.capacity());
488        while i + HEADER <= rest.len() {
489            if could_follow(&rest[i..], last, capacity)
490                && let Some(f) = unframe(&rest[i..])
491                && f.seq > last
492            {
493                return Ok(Some(f.seq));
494            }
495            i += 1;
496        }
497        Ok(None)
498    }
499
500    /// Re-read the whole segment from the checkpoint and check that it still
501    /// chains to the head this handle holds (I18): catches a retained record
502    /// edited on disk after it was read. Read-only.
503    pub fn verify_chain(&mut self) -> Result<(), JournalError> {
504        let _g = self.store.lock()?;
505        let (start, ckpt) = match self.load_checkpoint()? {
506            Some((seq, _, h)) => (h, seq),
507            None => (Hash::ZERO, 0),
508        };
509        let mut off = 0u64;
510        let mut links = Vec::new();
511        while off < self.offset {
512            let header = self.store.read_at(off, HEADER)?;
513            let len = if header.len() == HEADER {
514                u32::from_le_bytes(header[0..4].try_into().expect("4 bytes")) as usize
515            } else {
516                0
517            };
518            let bytes = self.store.read_at(off, HEADER + len)?;
519            let Some(f) = unframe(&bytes) else {
520                return Err(JournalError::Integrity(format!("the record at byte {off} is damaged")));
521            };
522            if f.seq <= ckpt {
523                off += f.size as u64;
524                continue;
525            }
526            links.push(Link { seq: f.seq, prev: f.prev, next: chain(&f.prev, f.seq, f.payload) });
527            off += f.size as u64;
528        }
529        // Verified: accepts exactly a chained segment, else names its first break.
530        let head = match chain::verify(ckpt, start, &links) {
531            Verdict::Chained { head, .. } => head,
532            Verdict::Break { at, .. } => {
533                return Err(JournalError::Integrity(format!(
534                    "record {} does not follow the hash chain",
535                    links[at].seq
536                )));
537            }
538        };
539        if ckpt == self.checkpoint_seq && head != self.head {
540            return Err(JournalError::Integrity("the journal no longer matches what was read".into()));
541        }
542        Ok(())
543    }
544
545    /// The chain head after the last record (carried by the next checkpoint).
546    pub fn head(&self) -> Hash {
547        self.head
548    }
549
550    /// Catch up on records written by other handles since this one last looked.
551    fn refresh(&mut self) -> Result<(), JournalError> {
552        if self.checkpoint_seq_on_disk()? != self.checkpoint_seq {
553            // Someone compacted: the log restarted at offset 0.
554            return self.reload();
555        }
556        self.scan(false)
557    }
558
559    pub fn ledger(&self) -> &Ledger {
560        &self.ledger
561    }
562
563    pub fn store(&self) -> &S {
564        &self.store
565    }
566
567    /// Re-read the shared journal and return the current ledger.
568    pub fn sync_ledger(&mut self) -> Result<&Ledger, JournalError> {
569        let _g = self.store.lock()?;
570        self.refresh()?;
571        Ok(&self.ledger)
572    }
573
574    /// Validate, write, fsync, then apply (write-ahead). Returns the entry.
575    pub fn append(&mut self, at: Timestamp, record: Record) -> Result<Entry, JournalError> {
576        let _g = self.store.lock()?;
577        self.refresh()?;
578        self.append_locked(at, record)
579    }
580
581    /// Run `f` with the store lock held, after catching up. Used for
582    /// read-decide-append sequences that must not interleave with another
583    /// handle (lease takeover).
584    pub fn locked<R>(&mut self, f: impl FnOnce(&mut Locked<'_, S>) -> R) -> Result<R, JournalError> {
585        let _g = self.store.lock()?;
586        self.refresh()?;
587        Ok(f(&mut Locked(self)))
588    }
589
590    fn append_locked(&mut self, at: Timestamp, record: Record) -> Result<Entry, JournalError> {
591        // The next seq and the current head (verified: what is appended
592        // always follows the chain, `chain::appending_keeps_the_chain`).
593        let (seq, prev) = chain::next_link(self.ledger.last_seq, &self.head);
594        let entry = Entry { seq, at, record };
595        self.ledger.validate(&entry)?;
596        let payload = serde_json::to_vec(&entry).map_err(|e| JournalError::Corrupt(e.to_string()))?;
597        let bytes = frame(seq, &prev, &payload);
598        if self.offset + bytes.len() as u64 > self.store.capacity() {
599            return Err(JournalError::Full);
600        }
601        self.store.write_at(self.offset, &bytes)?;
602        self.store.sync()?;
603        self.ledger.apply(entry.clone())?;
604        self.head = chain(&prev, seq, &payload);
605        self.offset += bytes.len() as u64;
606        Ok(entry)
607    }
608
609    /// The entries in the current log segment (everything since the last
610    /// checkpoint), in order.
611    pub fn segment(&mut self) -> Result<Vec<Entry>, JournalError> {
612        let _g = self.store.lock()?;
613        let mut out = vec![];
614        let mut off = 0u64;
615        loop {
616            let header = self.store.read_at(off, HEADER)?;
617            if header.len() < HEADER {
618                break;
619            }
620            let len = u32::from_le_bytes(header[0..4].try_into().expect("4 bytes")) as usize;
621            let bytes = self.store.read_at(off, HEADER + len)?;
622            let Some(f) = unframe(&bytes) else { break };
623            if f.seq <= self.checkpoint_seq || out.last().is_some_and(|e: &Entry| e.seq + 1 != f.seq) {
624                break;
625            }
626            match serde_json::from_slice::<Entry>(f.payload) {
627                Ok(e) => out.push(e),
628                Err(_) => break,
629            }
630            off += f.size as u64;
631        }
632        Ok(out)
633    }
634
635    /// Byte offset where the next record will be written.
636    pub fn offset(&self) -> u64 {
637        self.offset
638    }
639
640    /// Fraction of the preallocation in use, in per-mille.
641    pub fn usage_permille(&self) -> u64 {
642        self.offset * 1000 / self.store.capacity().max(1)
643    }
644
645    /// Compaction triggers at 75% full (§12c).
646    pub fn needs_compaction(&self) -> bool {
647        self.usage_permille() >= 750
648    }
649
650    /// Checkpoint-then-truncate. If the checkpoint write fails, nothing changes.
651    pub fn compact(&mut self) -> Result<(), JournalError> {
652        let _g = self.store.lock()?;
653        self.refresh()?;
654        let payload = serde_json::to_vec(&self.ledger).map_err(|e| JournalError::Corrupt(e.to_string()))?;
655        self.store.write_checkpoint(&frame(self.ledger.last_seq, &self.head, &payload))?;
656        self.checkpoint_seq = self.ledger.last_seq;
657        self.store.write_at(0, &[0; HEADER])?;
658        self.store.sync()?;
659        self.offset = 0;
660        // Terminal transactions' fences go once the checkpoint that records
661        // them as terminal is durable (§9 step 4, C5). Live ones stay.
662        let live = &self.ledger.live;
663        self.store.forget_fences(|t| live.get(&t).is_some())?;
664        self.clear_resolved_sentinel()
665    }
666
667    /// The fixed-size fallback when a recovery-path record cannot be written
668    /// (§12c): marks the transaction RecoveryFailed outside the journal and sets
669    /// the interlock.
670    pub fn write_sentinel(&mut self, txn: TxnId, note: &str) -> Result<(), JournalError> {
671        let text = format!("RecoveryFailed txn={} {}", txn.0, note);
672        self.store.write_sentinel(text.as_bytes())?;
673        self.apply_sentinel(text.as_bytes());
674        Ok(())
675    }
676
677    fn apply_sentinel(&mut self, bytes: &[u8]) {
678        let Some(txn) = sentinel_txn(bytes) else { return };
679        let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len());
680        let text = String::from_utf8_lossy(&bytes[..end]).into_owned();
681        if self.ledger.interlock.contains_key(&txn) {
682            return;
683        }
684        let (transition, revert_to) = match self.ledger.live.get(&txn) {
685            Some(l) => (l.txn.transition.clone(), l.txn.revert_to),
686            None => (Transition::Rollback, GenerationId(0)),
687        };
688        self.ledger.add_interlock(InterlockEntry {
689            txn,
690            transition,
691            failed_from: "sentinel".into(),
692            base_override: None,
693            candidate_override: None,
694            revert_to,
695            note: text,
696        });
697    }
698}
699
700/// The transaction a sentinel names (`RecoveryFailed txn=<id> …`), if any.
701fn sentinel_txn(bytes: &[u8]) -> Option<TxnId> {
702    let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len());
703    let text = std::str::from_utf8(&bytes[..end]).ok()?;
704    let id = text.strip_prefix("RecoveryFailed txn=")?.split_whitespace().next()?;
705    id.parse::<u64>().ok().map(TxnId)
706}
707
708#[cfg(test)]
709mod tests;