storage/jsonl/ledger.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 types: the struct, its reports, and segment grammar.
7
8use std::io;
9use std::path::{Path, PathBuf};
10
11use kernel::{B3Hash, EventRecord, Seq};
12
13use crate::error::{StorageError, io_err};
14use crate::vfs::Vfs;
15
16/// Segment rolling threshold. Internal affair: changing it changes how
17/// files are cut, never any observable semantics (`crates/storage/Spec.lean` §14).
18pub(crate) const SEGMENT_ROLL_BYTES: u64 = 64 * 1024 * 1024;
19
20/// How a segment's file is sized while it is written (storage D31).
21///
22/// Both arms keep the same durability and the same history on disk; an
23/// arm changes only what the barrier has to flush. The preallocated
24/// segment ends in zero bytes past its last record, which the reader
25/// strips (`crates/storage/spec/Jsonl/Preallocate.lean`), so the writer's
26/// position is the end of the records, never the end of the file.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub(crate) enum SegmentPreallocation {
29 /// The file grows by one append at its end per wave.
30 Grow,
31 /// A new segment is set to the roll size (`File::set_len`) when it is
32 /// created, and each wave writes at the end of its records, inside
33 /// that length: `SetFileInformationByHandle(FileEndOfFileInfo)` on
34 /// Windows, `ftruncate` on Linux and macOS.
35 ToRollSize,
36}
37
38impl SegmentPreallocation {
39 /// The length `open` leaves the last segment at, given the end of its
40 /// records (`keep`), the bytes of tear it drops after them
41 /// (`dropped`, zeros not counted) and its length on disk (`total`).
42 /// A grown segment ends at its records, because the next append writes
43 /// at the end of the file. A preallocated one keeps its zero tail when
44 /// nothing was torn, because the next wave writes at `keep` anyway.
45 pub(crate) fn length_after_open(self, keep: u64, dropped: u64, total: u64) -> u64 {
46 match (self, dropped) {
47 (SegmentPreallocation::ToRollSize, 0) => total,
48 (SegmentPreallocation::ToRollSize | SegmentPreallocation::Grow, _) => keep,
49 }
50 }
51}
52
53/// The arm every ledger this build opens writes with. It stays `Grow`
54/// until the release-build reading of the barrier with each arm (storage
55/// D31) chooses; choosing is this one value, on Windows, macOS and Linux
56/// alike.
57pub(crate) const SEGMENT_PREALLOCATION: SegmentPreallocation = SegmentPreallocation::Grow;
58
59/// Where the records in `segment` end: its length without the run of
60/// zero bytes a preallocated segment holds past its last record. A zero
61/// byte is space no write has reached yet, never a byte of a record, so
62/// `open` scans the last segment up to here and the side index stops
63/// reading at the first window whose records end before the window does
64/// (`crates/storage/spec/Jsonl/Preallocate.lean`, storage D31 and D32).
65pub(crate) fn records_end(segment: &[u8]) -> usize {
66 segment
67 .iter()
68 .rposition(|byte| *byte != 0)
69 .map_or(0, |at| at.saturating_add(1))
70}
71
72/// What open found and repaired, and what its tail recovery read and
73/// checked (`crates/storage/spec/Jsonl.lean` §8-34).
74pub struct OpenReport {
75 pub recovered: Option<TailTruncation>,
76 pub counted: crate::ProofCount,
77}
78
79pub struct TailTruncation {
80 pub dropped_bytes: u64,
81}
82
83/// The durable Ledger. See the module doc for the contract.
84pub struct JsonlLedger {
85 pub(crate) vfs: Box<dyn Vfs>,
86 pub(crate) dir: PathBuf,
87 pub(crate) seg_path: PathBuf,
88 pub(crate) seg_len: u64,
89 pub(crate) next_seq: Seq,
90 pub(crate) prev: B3Hash,
91 pub(crate) roll_bytes: u64,
92 /// How this ledger sizes its segments; [`SEGMENT_PREALLOCATION`]
93 /// outside tests.
94 pub(crate) preallocation: SegmentPreallocation,
95 /// Whether `seg_len`, `next_seq` and `prev` still name the end of
96 /// the segments; a failed wave leaves it broken until a reopen.
97 pub(crate) barrier: super::barrier::Barrier,
98 /// The per-room projection of what this ledger appends, laid down
99 /// after a wave is durable. `None` for a ledger opened directly in a
100 /// directory that is not a city's, which has no buildings to file
101 /// sessions under.
102 pub(crate) sessions: Option<crate::sessions::Sessions>,
103 /// The write-path observer, called only after the wave is durable.
104 pub(crate) observer: Option<WriteObserver>,
105 /// This process's hold on the city's writer lock. `None` for a
106 /// directory that is not a city's ledger, and on the fault model,
107 /// whose disk no other process can reach.
108 pub(crate) lock: Option<WriterLock>,
109 /// The stop a failed whole-chain audit trips (`crates/storage/spec/Worktree/Back.lean` §8-27).
110 pub(crate) halt: crate::chain_audit::ChainHalt,
111 /// The segments a failed wave created, while restoring the disk to
112 /// its length before that wave is still owed (`jsonl::unwind`).
113 pub(crate) pending_unwind: Option<Vec<PathBuf>>,
114}
115
116/// This process's exclusive hold on a city's Ledger, released when the
117/// ledger that took it is dropped (`crates/storage/spec/Jsonl.lean` §8-1).
118///
119/// The operating system keeps the lock on an open handle and refuses it
120/// to every other handle on the same file, in this process or in
121/// another, which is what makes "a city has one writer" hold across
122/// processes rather than only inside the one that owns the type. The
123/// file is never removed: a lock file deleted on release would let a
124/// latecomer lock a new file of the same name while the earlier holder
125/// still held the old one.
126pub(crate) struct WriterLock {
127 _held: std::fs::File,
128}
129
130impl WriterLock {
131 /// Takes the writer lock of the ledger directory `dir`: the file
132 /// `<name>.lock` beside it, named from `dir` alone so that this
133 /// module, not the city layout, owns where the lock lies
134 /// (storage D18).
135 ///
136 /// # Errors
137 /// `LedgerHeld` when another handle holds the lock; `Io` when `dir`
138 /// has no name to put a sibling beside (a root, `..`), or the lock
139 /// file cannot be made, or the lock cannot be asked for.
140 pub(crate) fn take(dir: &Path) -> Result<WriterLock, StorageError> {
141 let name = dir.file_name().ok_or_else(|| {
142 io_err("name the ledger lock", dir)(std::io::Error::new(
143 std::io::ErrorKind::InvalidInput,
144 "a ledger directory needs a name for its lock to sit beside it",
145 ))
146 })?;
147 let mut lock_name = name.to_os_string();
148 lock_name.push(".lock");
149 let path = dir.with_file_name(lock_name);
150 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
151 std::fs::create_dir_all(parent)
152 .map_err(io_err("create the ledger's parent directory", parent))?;
153 }
154 let file = std::fs::OpenOptions::new()
155 .create(true)
156 .truncate(false)
157 .write(true)
158 .open(&path)
159 .map_err(io_err("open the ledger lock", &path))?;
160 match file.try_lock() {
161 Ok(()) => Ok(WriterLock { _held: file }),
162 Err(std::fs::TryLockError::WouldBlock) => Err(StorageError::LedgerHeld {
163 dir: dir.to_path_buf(),
164 }),
165 Err(std::fs::TryLockError::Error(source)) => {
166 Err(io_err("lock the ledger", &path)(source))
167 }
168 }
169 }
170}
171
172/// The write-path observer: what a live reader (the control surface's
173/// event stream) installs to hear each line once it is durable.
174pub type WriteObserver = Box<dyn FnMut(&EventRecord) + Send>;
175
176/// Chain state at the entrance of the last segment.
177pub(crate) struct TailBoundary {
178 pub(crate) prev: B3Hash,
179 pub(crate) next_seq: Seq,
180 pub(crate) prior: Option<PriorSegment>,
181}
182
183pub(crate) struct PriorSegment {
184 pub(crate) path: PathBuf,
185 pub(crate) len: u64,
186}
187
188/// The two names a segment is made of. One home for the grammar: the
189/// writer that names a segment and the readers that recognise one, or
190/// read a sequence back off one, agree because they read these.
191const SEGMENT_PREFIX: &str = "ledger-";
192const SEGMENT_SUFFIX: &str = ".jsonl";
193
194pub(crate) fn segment_file_name(first_seq: Seq) -> String {
195 format!("{SEGMENT_PREFIX}{:020}{SEGMENT_SUFFIX}", first_seq.value())
196}
197
198pub(crate) fn is_segment(path: &Path) -> bool {
199 match path.file_name().and_then(|n| n.to_str()) {
200 Some(name) => name.starts_with(SEGMENT_PREFIX) && name.ends_with(SEGMENT_SUFFIX),
201 None => false,
202 }
203}
204
205/// The first sequence a segment's name claims, for a reader that wants
206/// only the records after a sequence it already holds: it skips the
207/// segments that cannot contain them by reading the name, rather than by
208/// opening the file. `None` for a name that is not this grammar.
209///
210/// The inverse of [`segment_file_name`], and it lives beside it for the
211/// reason the prefix and suffix are constants here: one reader of a
212/// grammar, not two spellings of it.
213pub(crate) fn segment_first_seq(name: &str) -> Option<Seq> {
214 let digits = name
215 .strip_prefix(SEGMENT_PREFIX)?
216 .strip_suffix(SEGMENT_SUFFIX)?;
217 digits.parse::<u64>().ok().map(Seq::new)
218}
219
220/// The segment files of `dir`, in the ledger's own order.
221///
222/// `Vfs::list` answers files only, already sorted: zero-padded names
223/// sort lexically the way they sort numerically, so the order is the
224/// ledger's own rather than the filesystem's.
225pub(crate) fn segment_names(vfs: &dyn Vfs, dir: &Path) -> Result<Vec<String>, StorageError> {
226 let mut names = Vec::new();
227 for path in vfs.list(dir).map_err(io_err("list ledger dir", dir))? {
228 if !is_segment(&path) {
229 continue;
230 }
231 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
232 names.push(name.to_owned());
233 }
234 }
235 Ok(names)
236}
237
238/// A count of segment bytes or lines as the `u64` a file offset and a
239/// line number are. `usize` is at most 64 bits wide on every target this
240/// crate builds for, so this refuses only on a wider one, and it refuses
241/// rather than substitute a number the count is not.
242///
243/// # Errors
244/// `io::Error` when `count` does not fit a `u64`.
245pub(crate) fn u64_count(count: usize) -> io::Result<u64> {
246 u64::try_from(count).map_err(io::Error::other)
247}
248
249/// Complete (`\n`-terminated) lines and the leftover tail bytes.
250pub(crate) fn complete_lines(bytes: &[u8]) -> (Vec<&[u8]>, usize) {
251 let mut lines = Vec::new();
252 let mut consumed = 0usize;
253 let mut start = 0usize;
254 for (index, byte) in bytes.iter().enumerate() {
255 if *byte == b'\n' {
256 if let Some(line) = bytes.get(start..index) {
257 lines.push(line);
258 }
259 start = index.saturating_add(1);
260 consumed = start;
261 }
262 }
263 (lines, consumed)
264}
265
266#[cfg(test)]
267#[allow(
268 clippy::unwrap_used,
269 clippy::expect_used,
270 clippy::panic,
271 clippy::indexing_slicing,
272 reason = "test code"
273)]
274mod tests {
275 use super::super::*;
276 use kernel::{EventDraft, EventKind, Payload, RunId, TimeMs};
277 use std::fs;
278
279 fn draft(kind: EventKind, t: u64) -> EventDraft {
280 EventDraft {
281 run: RunId::CITY,
282 t: TimeMs::new(t),
283 who: "city".to_string(),
284 addr: None,
285 kind,
286 data: Payload::empty(),
287 ig: false,
288 }
289 }
290
291 /// The ledger directory holds segments and may hold other files; only a
292 /// file named like a segment is read or repaired as one.
293 #[test]
294 fn a_file_not_named_like_a_segment_is_neither_read_nor_repaired() {
295 let dir = tempfile::tempdir().unwrap();
296 let (mut ledger, _) = JsonlLedger::open(dir.path(), TimeMs::new(0)).unwrap();
297 ledger
298 .append_all(vec![draft(EventKind::CityInitialized, 0)])
299 .unwrap();
300 let lines = ledger.read_raw_lines().unwrap();
301 drop(ledger);
302 let strays = [
303 ("notes.txt", b"not a line".as_slice()),
304 ("other.jsonl", b"{\"torn"),
305 ];
306 for (name, bytes) in strays {
307 fs::write(dir.path().join(name), bytes).unwrap();
308 }
309
310 let (reopened, report) = JsonlLedger::open(dir.path(), TimeMs::new(1)).unwrap();
311 let kept: Vec<Vec<u8>> = strays
312 .iter()
313 .map(|(name, _)| fs::read(dir.path().join(name)).unwrap())
314 .collect();
315 assert_eq!(
316 (
317 report.recovered.is_none(),
318 reopened.read_raw_lines().unwrap(),
319 kept
320 ),
321 (
322 true,
323 lines,
324 strays.iter().map(|(_, bytes)| bytes.to_vec()).collect()
325 )
326 );
327 }
328}