Skip to main content

storage/jsonl/
open.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Ledger opening: version probes and tail recovery.
7
8use std::path::{Path, PathBuf};
9
10use kernel::consts_external::{LogVersion, readable_log_v};
11use kernel::{AxCode, AxError, GENESIS_PREV, Seq, TimeMs};
12
13use crate::chain_audit::ProofCount;
14use crate::error::{StorageError, io_err};
15use crate::real_fs::RealFs;
16use crate::verified_prefix::{ProofRecords, TailStart};
17use crate::vfs::Vfs;
18
19use super::first_line::first_line;
20use super::ledger::{
21    JsonlLedger, OpenReport, SEGMENT_ROLL_BYTES, TailTruncation, WriterLock, complete_lines,
22    is_segment, segment_file_name,
23};
24use super::verify::{LineCheck, LineFault};
25
26impl JsonlLedger {
27    /// Production entrance: std filesystem underneath, and the ledger
28    /// directory's writer lock, taken before anything is read or
29    /// repaired and held for as long as the returned ledger lives.
30    ///
31    /// # Errors
32    /// `LedgerHeld` when another writer holds this ledger, in this
33    /// process or another; otherwise whatever reading and repairing the
34    /// segments reports.
35    pub fn open(dir: &Path, now: TimeMs) -> Result<(Self, OpenReport), StorageError> {
36        JsonlLedger::open_locked(dir, now, TailProof::Strict)
37    }
38
39    /// [`JsonlLedger::open`], with the last segment's verified-prefix
40    /// record in `records` standing in for checking its prefix line by
41    /// line when the record holds (`crates/storage/spec/Jsonl.lean` §8-34). Records are read,
42    /// never written: the proof that holds the writer lock writes them.
43    ///
44    /// # Errors
45    /// Those of [`JsonlLedger::open`].
46    pub fn open_reusing(
47        dir: &Path,
48        now: TimeMs,
49        records: &ProofRecords,
50    ) -> Result<(Self, OpenReport), StorageError> {
51        JsonlLedger::open_locked(dir, now, TailProof::Records(records))
52    }
53
54    fn open_locked(
55        dir: &Path,
56        now: TimeMs,
57        proof: TailProof<'_>,
58    ) -> Result<(Self, OpenReport), StorageError> {
59        let lock = WriterLock::take(dir)?;
60        let (mut ledger, report) =
61            JsonlLedger::open_through(Box::new(RealFs::new()), dir, now, proof)?;
62        ledger.lock = Some(lock);
63        Ok((ledger, report))
64    }
65
66    /// The `fault` entrance: the same ledger, over the deterministic
67    /// power-loss model, for a caller that needs one named write to fail.
68    ///
69    /// Takes the concrete adapter rather than the trait, so the `Vfs`
70    /// seam stays inner (`crates/storage/spec/Jsonl.lean` §8-1) and no `pub trait` leaves this
71    /// crate. What comes back is the same `JsonlLedger` production uses -
72    /// a caller above this crate exercises its real code and loses only
73    /// the write it named.
74    ///
75    /// # Errors
76    /// Propagates whatever [`JsonlLedger::open`] would, and the power
77    /// loss itself when the plan cuts during the open.
78    #[cfg(any(test, feature = "fault"))]
79    pub fn open_faulty(
80        fs: crate::fault_fs::FaultFs,
81        dir: &Path,
82        now: TimeMs,
83    ) -> Result<(Self, OpenReport), StorageError> {
84        JsonlLedger::open_with(Box::new(fs), dir, now)
85    }
86
87    /// Injection point for the second Vfs adapter, checking the last
88    /// segment line by line; `open_faulty` is its entrance.
89    #[cfg(any(test, feature = "fault"))]
90    pub(crate) fn open_with(
91        vfs: Box<dyn Vfs>,
92        dir: &Path,
93        now: TimeMs,
94    ) -> Result<(Self, OpenReport), StorageError> {
95        JsonlLedger::open_through(vfs, dir, now, TailProof::Strict)
96    }
97
98    fn open_through(
99        mut vfs: Box<dyn Vfs>,
100        dir: &Path,
101        now: TimeMs,
102        proof: TailProof<'_>,
103    ) -> Result<(Self, OpenReport), StorageError> {
104        vfs.create_dir_all(dir)
105            .map_err(io_err("create ledger dir", dir))?;
106        let segments: Vec<PathBuf> = vfs
107            .list(dir)
108            .map_err(io_err("list ledger dir", dir))?
109            .into_iter()
110            .filter(|p| is_segment(p))
111            .collect();
112
113        let mut ledger = JsonlLedger {
114            vfs,
115            dir: dir.to_path_buf(),
116            seg_path: dir.join(segment_file_name(Seq::FIRST)),
117            seg_len: 0,
118            next_seq: Seq::FIRST,
119            prev: GENESIS_PREV,
120            roll_bytes: SEGMENT_ROLL_BYTES,
121            barrier: super::barrier::Barrier::Whole,
122            // The projection exists only when this ledger is a city's:
123            // it is what tells a writer handed the ledger directory
124            // alone where the buildings' sessions lie. A store opened
125            // directly has no buildings, and files nothing.
126            sessions: crate::sessions::Sessions::for_ledger(dir),
127            observer: None,
128            lock: None,
129            halt: crate::chain_audit::ChainHalt::default(),
130            pending_unwind: None,
131        };
132
133        let Some(last) = segments.last().cloned() else {
134            return Ok((
135                ledger,
136                OpenReport {
137                    recovered: None,
138                    counted: ProofCount::default(),
139                },
140            ));
141        };
142
143        ledger.probe_version(&segments)?;
144        let (dropped, counted) = ledger.recover_tail(&segments, &last, proof)?;
145        let recovered = if dropped > 0 {
146            if ledger.next_seq != Seq::FIRST {
147                ledger.append_log_truncated(now, dropped)?;
148            }
149            Some(TailTruncation {
150                dropped_bytes: dropped,
151            })
152        } else {
153            None
154        };
155        Ok((ledger, OpenReport { recovered, counted }))
156    }
157
158    /// Direction-aware version refusal, before any repair or parse
159    /// (never a partial read of a newer ledger).
160    fn probe_version(&mut self, segments: &[PathBuf]) -> Result<(), StorageError> {
161        let Some(first) = segments.first() else {
162            return Ok(());
163        };
164        let Some(first_line) =
165            first_line(self.vfs.as_ref(), first).map_err(io_err("read segment", first))?
166        else {
167            // Empty or torn-before-first-line segment: version unknowable;
168            // tail recovery decides what remains.
169            return Ok(());
170        };
171        // A mangled first line in a single-segment ledger is tail damage:
172        // it carries no version information, and tail recovery owns it.
173        // With more segments behind it the same damage is non-tail and
174        // must refuse instead (`crates/storage/spec/Jsonl.lean` §8-1).
175        let probed = serde_json::from_slice::<serde_json::Value>(&first_line)
176            .ok()
177            .and_then(|value| value.get("v").and_then(serde_json::Value::as_u64));
178        let v = match probed {
179            Some(v) => v,
180            None if segments.len() > 1 => {
181                return Err(StorageError::Envelope {
182                    path: first.clone(),
183                    line: 1,
184                    source: AxError::failure(
185                        AxCode::InvalidArgs,
186                        "probe ledger version",
187                        "first line is not a version-bearing record",
188                    )
189                    .with_recovery(
190                        "restore this segment from its checkpoint commit, then run \
191                         `sprawling replay <ledger-dir>`: line 1 of every segment carries `v`",
192                    ),
193                });
194            }
195            None => return Ok(()),
196        };
197        match readable_log_v(v) {
198            // An older ledger opens: the history is append-only and the
199            // lines an earlier build wrote are still its history.
200            LogVersion::Current | LogVersion::Older => Ok(()),
201            LogVersion::Ahead => Err(StorageError::VersionAhead {
202                path: first.clone(),
203                v,
204            }),
205            LogVersion::NotAVersion => Err(StorageError::Envelope {
206                path: first.clone(),
207                line: 1,
208                source: unversioned(v),
209            }),
210        }
211    }
212
213    /// Tail-truncation recovery over the last segment, taking its
214    /// verified prefix from `proof` when the record holds (`crates/storage/spec/Jsonl.lean`
215    /// §8-34). Returns dropped bytes and what was read and checked; on
216    /// return the ledger state points at the surviving tail.
217    fn recover_tail(
218        &mut self,
219        segments: &[PathBuf],
220        last: &Path,
221        proof: TailProof<'_>,
222    ) -> Result<(u64, ProofCount), StorageError> {
223        let boundary = self.boundary(segments, last)?;
224        let prior = boundary.prior;
225        let bytes = self.vfs.read(last).map_err(io_err("read segment", last))?;
226        let start = proof.start(
227            last,
228            &bytes,
229            LineCheck::after(boundary.prev, boundary.next_seq),
230        );
231        let mut check = start.check;
232        let mut counted = start.counted;
233        let (lines, _terminated_len) = complete_lines(bytes.get(start.from..).unwrap_or_default());
234
235        let mut valid_len = start.from;
236        for (index, line) in lines.iter().enumerate() {
237            let at = u64::try_from(index)
238                .unwrap_or(u64::MAX)
239                .saturating_add(1)
240                .saturating_add(start.lines);
241            counted.lines_checked = counted.lines_checked.saturating_add(1);
242            let fault = match check.advance(line) {
243                Ok(_) => {
244                    valid_len = valid_len.saturating_add(line.len()).saturating_add(1);
245                    continue;
246                }
247                Err(fault) => fault,
248            };
249            // What a version refuses, it refuses by name: a line this
250            // build cannot read is not tail damage, and truncating it
251            // would delete a newer build's history (`crates/storage/spec/Jsonl.lean` §8-1).
252            // A tear only ever damages the tail and never leaves a record
253            // behind, so a break is refused - not truncated - when the
254            // breaking line carries an envelope (a fork: a second writer
255            // continued the same prev) or intact records still follow it
256            // (non-tail damage). Newline-bearing garbage is no record.
257            let later_intact = || {
258                lines
259                    .iter()
260                    .skip(index.saturating_add(1))
261                    .any(|l| LineCheck::carries_envelope(l))
262            };
263            let source = match fault {
264                LineFault::VersionAhead(v) => {
265                    return Err(StorageError::VersionAhead {
266                        path: last.to_path_buf(),
267                        v,
268                    });
269                }
270                LineFault::NotAVersion(v) => unversioned(v),
271                LineFault::NotALine(_) if !later_intact() => break,
272                LineFault::ChainBreak | LineFault::SeqGap { .. } | LineFault::NotALine(_) => {
273                    AxError::failure(
274                        AxCode::InvalidArgs,
275                        "verify chain",
276                        "a line does not continue the chain",
277                    )
278                    .with_recovery(
279                        "run `sprawling replay <ledger-dir>` to see the first line \
280                     that breaks, then restore that segment from its \
281                     checkpoint commit",
282                    )
283                }
284                other @ (LineFault::UnknownKind(_)
285                | LineFault::NotCanonical(_)
286                | LineFault::SeqExhausted(_)) => other.into_ax(at),
287            };
288            return Err(StorageError::Envelope {
289                path: last.to_path_buf(),
290                line: at,
291                source,
292            });
293        }
294        let run_prev = check.prev();
295        let run_seq = check.expected();
296
297        let total = bytes.len();
298        let dropped = u64::try_from(total.saturating_sub(valid_len)).unwrap_or(u64::MAX);
299
300        if dropped > 0 {
301            if valid_len == 0 {
302                // Whole last segment is torn. Remove it; the tail falls
303                // back to the prior segment (or to an empty city when this
304                // was the only one — the genesis append never returned Ok).
305                self.vfs
306                    .truncate(last, 0)
307                    .map_err(io_err("truncate segment", last))?;
308                self.vfs
309                    .sync_data(last)
310                    .map_err(io_err("sync segment", last))?;
311                self.vfs
312                    .remove_file(last)
313                    .map_err(io_err("remove empty segment", last))?;
314                self.vfs
315                    .sync_dir(&self.dir.clone())
316                    .map_err(io_err("sync ledger dir", &self.dir.clone()))?;
317                match prior {
318                    Some(segment) => {
319                        self.seg_path = segment.path;
320                        self.seg_len = segment.len;
321                    }
322                    None => {
323                        self.seg_path = self.dir.join(segment_file_name(Seq::FIRST));
324                        self.seg_len = 0;
325                    }
326                }
327            } else {
328                let keep = u64::try_from(valid_len).unwrap_or(u64::MAX);
329                self.vfs
330                    .truncate(last, keep)
331                    .map_err(io_err("truncate segment", last))?;
332                self.vfs
333                    .sync_data(last)
334                    .map_err(io_err("sync segment", last))?;
335                self.seg_path = last.to_path_buf();
336                self.seg_len = keep;
337            }
338        } else {
339            self.seg_path = last.to_path_buf();
340            self.seg_len = u64::try_from(total).unwrap_or(u64::MAX);
341        }
342        self.prev = run_prev;
343        self.next_seq = run_seq;
344        Ok((dropped, counted))
345    }
346}
347
348/// What tail recovery may take in place of checking the last segment's
349/// prefix line by line.
350#[derive(Clone, Copy)]
351enum TailProof<'a> {
352    /// Every line of the last segment is checked.
353    Strict,
354    /// The last segment's record in these, when it holds (`crates/storage/spec/Jsonl.lean` §8-34).
355    Records(&'a ProofRecords),
356}
357
358impl TailProof<'_> {
359    /// Where checking `bytes`, the last segment at `last`, begins when
360    /// the chain state entering it is `entry`.
361    fn start(self, last: &Path, bytes: &[u8], entry: LineCheck) -> TailStart {
362        match (self, last.file_name().and_then(|name| name.to_str())) {
363            (TailProof::Records(records), Some(name)) => records.tail_start(name, bytes, entry),
364            (TailProof::Records(_), None) | (TailProof::Strict, _) => {
365                TailStart::strict(bytes, entry)
366            }
367        }
368    }
369}
370
371/// The refusal a line below the first ledger version earns.
372///
373/// One sentence for both readers, because "nobody ever wrote this" is
374/// one fact whether the probe or the tail scan meets it first.
375fn unversioned(v: u64) -> AxError {
376    AxError::failure(
377        AxCode::InvalidArgs,
378        "read a ledger line's version",
379        format!("v{v} is below the first version any build wrote"),
380    )
381    .with_recovery(
382        "restore this segment from its checkpoint commit: the ledger version starts at 1 \
383         and this line declares less",
384    )
385}
386
387#[cfg(test)]
388#[allow(
389    clippy::unwrap_used,
390    clippy::expect_used,
391    clippy::panic,
392    clippy::indexing_slicing,
393    clippy::wildcard_enum_match_arm,
394    clippy::let_underscore_must_use,
395    clippy::let_underscore_untyped,
396    reason = "test code"
397)]
398mod tests;