storage/index/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//! The side index: seq to (segment, byte offset).
7
8use std::io;
9use std::path::Path;
10use std::sync::{Mutex, MutexGuard, PoisonError};
11
12use kernel::{AxError, RunId, Seq};
13
14use crate::error::{StorageError, io_err};
15use crate::jsonl::{FIRST_WINDOW_BYTES, records_end, segment_names};
16use crate::real_fs::RealFs;
17use crate::vfs::Vfs;
18
19use super::fold::{Folded, Located};
20use super::reader::LineReader;
21
22/// What one [`LedgerIndex::refresh`] did, and what it lifted off the
23/// disk doing it.
24///
25/// The byte count is the difference between reading the appended tail
26/// and reading the segment that carries it, so it is the reading the
27/// `index_refresh_read` budget records.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Refreshed {
30 /// Every segment was exactly the length this index had already
31 /// folded, so nothing was opened.
32 Unchanged,
33 /// A segment was longer than what this index had folded, so the
34 /// bytes past that point were read: up to the segment's end, or up to
35 /// the window that reached a preallocated segment's zero tail
36 /// (storage D32), which a refresh reads even when no record arrived.
37 Appended { bytes_read: u64 },
38 /// A segment shrank or vanished, so every offset came from a full
39 /// scan of the directory.
40 Rebuilt,
41}
42
43/// seq → (segment file name, byte offset of the line start).
44///
45/// The maps live in [`Folded`]; what this type adds is the filesystem
46/// seam they are folded from. The seam sits behind a lock so a caller
47/// holding `&LedgerIndex` asks questions without exclusive access, and
48/// every question this type answers is a read: nothing here writes.
49pub struct LedgerIndex {
50 pub(crate) folded: Folded,
51 vfs: Mutex<Box<dyn Vfs>>,
52}
53
54impl LedgerIndex {
55 /// The seam, whatever a thread that held it did before it died: a
56 /// disposable side artifact has no invariant a panic could leave
57 /// half-kept, so a poisoned lock is taken as it stands — the same
58 /// stance `FaultFs` takes on its own state.
59 fn seam(&self) -> MutexGuard<'_, Box<dyn Vfs>> {
60 self.vfs.lock().unwrap_or_else(PoisonError::into_inner)
61 }
62
63 /// Scans the ledger directory into a fresh index.
64 ///
65 /// # Errors
66 /// Propagates a ledger directory that cannot be listed or read.
67 pub fn rebuild(dir: &Path) -> Result<LedgerIndex, StorageError> {
68 let vfs: Box<dyn Vfs> = Box::new(RealFs::new());
69 let folded = scan(vfs.as_ref(), dir)?;
70 Ok(LedgerIndex {
71 folded,
72 vfs: Mutex::new(vfs),
73 })
74 }
75
76 /// Reads the ledger once for a fold and for the index together: each
77 /// complete line is lent to `each` in ledger order, then indexed at
78 /// its offset. `each` returns the line's place when it has read it
79 /// already, so the index does not parse the line again, or `None` for
80 /// the index to locate it by the rule `rebuild` uses. One segment's
81 /// bytes are resident at a time, and the index covers exactly the
82 /// lines `each` saw: a line appended during the fold waits for
83 /// `refresh`.
84 ///
85 /// # Errors
86 /// The first error `each` returns, or a ledger directory that cannot
87 /// be listed or read. The lines before it were already lent, so the
88 /// caller discards what it folded.
89 pub fn folding(
90 dir: &Path,
91 each: impl FnMut(&[u8]) -> Result<Option<Located>, AxError>,
92 ) -> Result<LedgerIndex, AxError> {
93 let vfs: Box<dyn Vfs> = Box::new(RealFs::new());
94 let folded = walk(vfs.as_ref(), dir, each, StorageError::into_ax)?;
95 Ok(LedgerIndex {
96 folded,
97 vfs: Mutex::new(vfs),
98 })
99 }
100
101 /// An index over nothing.
102 ///
103 /// For a holder that wants one before the ledger directory exists: the
104 /// first `refresh` fills it, and until then every lookup answers
105 /// `SeqMissing`, which is the truth.
106 #[must_use]
107 pub fn empty() -> LedgerIndex {
108 LedgerIndex {
109 folded: Folded::empty(),
110 vfs: Mutex::new(Box::new(RealFs::new())),
111 }
112 }
113
114 /// Folds whatever has been appended since the last look.
115 ///
116 /// A resident index is the point: rebuilding one scans every segment
117 /// and allocates a `String` for every line in it, which on a fifty
118 /// thousand record ledger was 14.4 ms charged to every single
119 /// history query. Refreshing costs one directory listing plus a
120 /// `stat` per segment, and reads only the bytes that are new.
121 ///
122 /// The incremental face is here rather than an "index, a record just
123 /// landed" call on the writer's side, because a caller holds an
124 /// `EventRecord` and segments are this crate's own business. Handing
125 /// an offset to an observer would leak the layout to everyone.
126 ///
127 /// A segment that shrank or vanished forces a full rebuild: tail
128 /// recovery truncates, and after it the offsets held for that
129 /// segment describe bytes that are no longer there.
130 ///
131 /// The answer reports how many bytes this refresh read, which is
132 /// what separates seeking to the tail from lifting the segment that
133 /// holds it.
134 ///
135 /// # Errors
136 /// Propagates a ledger directory that cannot be listed, stat'ed or
137 /// read.
138 pub fn refresh(&mut self, dir: &Path) -> Result<Refreshed, StorageError> {
139 let plan = self.plan_refresh(dir)?;
140 match plan {
141 RefreshPlan::Rebuild => {
142 let folded = scan(self.seam().as_ref(), dir)?;
143 self.folded = folded;
144 Ok(Refreshed::Rebuilt)
145 }
146 RefreshPlan::Unchanged => Ok(Refreshed::Unchanged),
147 RefreshPlan::Appended { spans } => self.fold_spans(dir, spans),
148 }
149 }
150
151 /// What this refresh has to do, decided from segment lengths alone.
152 fn plan_refresh(&self, dir: &Path) -> Result<RefreshPlan, StorageError> {
153 let vfs = self.seam();
154 let names = crate::jsonl::segment_names(vfs.as_ref(), dir)?;
155 let mut spans = Vec::new();
156 // Folded segments this listing still holds. Names in one listing
157 // are distinct, so fewer hits than folded segments means one of
158 // them vanished: one ordered lookup per name, not names squared.
159 let mut held_here: usize = 0;
160 for name in &names {
161 let path = dir.join(name);
162 let size = vfs
163 .size(&path)
164 .map_err(crate::error::io_err("stat segment", &path))?;
165 let held = self.folded.scanned.get(name).copied();
166 if held.is_some() {
167 held_here = held_here.saturating_add(1);
168 }
169 match held {
170 Some(done) if done == size => continue,
171 Some(done) if done < size => spans.push(Span {
172 name: name.clone(),
173 from: done,
174 size,
175 }),
176 Some(_) => return Ok(RefreshPlan::Rebuild),
177 None => spans.push(Span {
178 name: name.clone(),
179 from: 0,
180 size,
181 }),
182 }
183 }
184 if held_here < self.folded.scanned.len() {
185 return Ok(RefreshPlan::Rebuild);
186 }
187 if spans.is_empty() {
188 return Ok(RefreshPlan::Unchanged);
189 }
190 Ok(RefreshPlan::Appended { spans })
191 }
192
193 /// Reads the records of each appended span and folds them.
194 fn fold_spans(&mut self, dir: &Path, spans: Vec<Span>) -> Result<Refreshed, StorageError> {
195 let mut bytes_read: u64 = 0;
196 for span in spans {
197 let path = dir.join(&span.name);
198 let (tail, read) = read_records(self.seam().as_ref(), &path, span.from, span.size)
199 .map_err(io_err("read segment", &path))?;
200 bytes_read = bytes_read.saturating_add(read);
201 let indexed = self.folded.fold_segment(&span.name, span.from, &tail);
202 // Only complete lines count as scanned: a torn tail is
203 // overwritten by the next append, and remembering it as read
204 // would skip the record that replaces it.
205 self.folded
206 .scanned
207 .insert(span.name, span.from.saturating_add(indexed).min(span.size));
208 }
209 Ok(Refreshed::Appended { bytes_read })
210 }
211
212 /// A cursor for reading lines out of this index.
213 ///
214 /// The unit of work is a stretch of sequences rather than one of
215 /// them, so the handle belongs to the stretch: a caller asks for a
216 /// reader once and then for lines as often as it likes.
217 pub fn reader(&self, dir: &Path) -> LineReader<'_> {
218 LineReader {
219 index: self,
220 dir: dir.to_path_buf(),
221 open: None,
222 }
223 }
224
225 /// The sequences one run wrote, newest first, below `before`.
226 ///
227 /// Newest first because what a reader opens a session for is its end;
228 /// a caller that wants them oldest first takes what it needs and
229 /// reverses a list it already holds, which also lets it read the
230 /// segment forwards.
231 ///
232 /// `before` is exclusive, the same word and the same meaning the wire
233 /// gives `HistoryAnswer::earlier`, so paging by handing one answer's
234 /// cursor to the next question can neither repeat nor skip a record.
235 ///
236 /// A run this index never saw yields nothing, which is the truth and
237 /// needs no separate answer.
238 ///
239 /// The walk is lazy: taking `n` values costs `n` steps, not the
240 /// length of the run.
241 pub fn run_seqs_before(&self, run: RunId, before: Option<Seq>) -> impl Iterator<Item = Seq> {
242 self.folded.run_seqs_before(run, before)
243 }
244
245 /// Every seq the index holds, ascending: the walk a reader takes
246 /// to read the whole ledger forwards, which is its fast direction.
247 pub fn seqs(&self) -> impl DoubleEndedIterator<Item = Seq> + '_ {
248 self.folded.seqs()
249 }
250
251 /// Every seq the index holds at or after `from`, ascending: the walk
252 /// a reader takes to read a stretch forwards from where it starts.
253 ///
254 /// The walk starts at `from` rather than skipping the seqs before it,
255 /// so reading a stretch costs the stretch, however long the ledger
256 /// before it is (`crates/storage/Spec.lean` §8-38). `from` need not be
257 /// a seq anybody wrote; nothing at or after it yields nothing.
258 pub fn seqs_from(&self, from: Seq) -> impl DoubleEndedIterator<Item = Seq> + '_ {
259 self.folded.seqs_from(from)
260 }
261
262 pub fn tail_seq(&self) -> Option<Seq> {
263 self.folded.tail_seq()
264 }
265
266 pub fn len(&self) -> usize {
267 self.folded.len()
268 }
269
270 pub fn is_empty(&self) -> bool {
271 self.folded.is_empty()
272 }
273}
274
275/// One segment's appended stretch: where this index stopped, and where
276/// the segment now ends.
277struct Span {
278 name: String,
279 from: u64,
280 size: u64,
281}
282
283/// The records of the segment at `path` from `from` to `size`, read
284/// forward one window at a time, with the count of bytes lifted off the
285/// disk to find them.
286///
287/// The read is bounded by the length the caller stat'ed (`size`), so a
288/// record appended between the stat and the read stays for a later pass
289/// rather than being folded at an offset this pass never confirmed. It
290/// stops at the window whose records end before the window does: past
291/// that point a preallocated segment (storage D31) holds only zeros, so
292/// a reader of one takes its records and at most one window of zeros,
293/// never every zero up to the roll size (storage D32 and D33). The
294/// window starts at `FIRST_WINDOW_BYTES` and doubles, so a long stretch
295/// of records costs a number of reads logarithmic in its length.
296///
297/// The refresh and the full scan both read through here, so the
298/// zero-tail rule is `jsonl::records_end` alone and neither caller
299/// reads a preallocated segment the other way.
300fn read_records(vfs: &dyn Vfs, path: &Path, from: u64, size: u64) -> io::Result<(Vec<u8>, u64)> {
301 let mut records = Vec::new();
302 let mut read: u64 = 0;
303 let mut window = FIRST_WINDOW_BYTES;
304 loop {
305 let at = from.saturating_add(read);
306 let asked = window.min(size.saturating_sub(at));
307 if asked == 0 {
308 return Ok((records, read));
309 }
310 let mut chunk = vfs.read_at(path, at, asked)?;
311 let lifted = u64::try_from(chunk.len()).map_err(io::Error::other)?;
312 read = read.saturating_add(lifted);
313 let end = records_end(&chunk);
314 let reached_zeros = end < chunk.len();
315 chunk.truncate(end);
316 records.append(&mut chunk);
317 if reached_zeros || lifted < asked {
318 return Ok((records, read));
319 }
320 window = window.saturating_mul(2);
321 }
322}
323
324/// What a refresh decided before it read anything.
325enum RefreshPlan {
326 Unchanged,
327 Appended { spans: Vec<Span> },
328 Rebuild,
329}
330
331/// Every line indexed by the rule the index owns.
332fn scan(vfs: &dyn Vfs, dir: &Path) -> Result<Folded, StorageError> {
333 walk(vfs, dir, |_| Ok(None), |failure| failure)
334}
335
336/// The one pass over the segments that builds a `Folded`.
337fn walk<E>(
338 vfs: &dyn Vfs,
339 dir: &Path,
340 mut each: impl FnMut(&[u8]) -> Result<Option<Located>, E>,
341 lift: impl Fn(StorageError) -> E,
342) -> Result<Folded, E> {
343 let mut folded = Folded::empty();
344 for name in segment_names(vfs, dir).map_err(&lift)? {
345 let path = dir.join(&name);
346 let size = vfs
347 .size(&path)
348 .map_err(io_err("stat segment", &path))
349 .map_err(&lift)?;
350 // The records, not a preallocated segment's zero tail: the same
351 // window read and the same stop the refresh takes (storage D33).
352 let (bytes, _lifted) = read_records(vfs, &path, 0, size)
353 .map_err(io_err("read segment", &path))
354 .map_err(&lift)?;
355 let mut offset: u64 = 0;
356 for line in bytes.split_inclusive(|b| *b == b'\n') {
357 // A torn tail carries no seq we can trust; it is skipped, and
358 // the next append overwrites it (jsonl owns that repair).
359 let Some(body) = line.strip_suffix(b"\n") else {
360 break;
361 };
362 if !body.is_empty() {
363 match each(body)? {
364 Some(located) => folded.insert_located(&name, offset, located),
365 None => folded.insert_line(&name, offset, body),
366 }
367 }
368 let len = u64::try_from(line.len())
369 .map_err(std::io::Error::other)
370 .map_err(io_err("measure segment line", &path))
371 .map_err(&lift)?;
372 offset = offset.saturating_add(len);
373 }
374 folded.scanned.insert(name, offset);
375 }
376 Ok(folded)
377}
378
379/// Only the folded maps are written: the seam is this process's own, and
380/// an index read back reaches disk through a new one. A views snapshot
381/// carries the index this way (`crates/storage/spec/Index.lean` §8-4).
382impl serde::Serialize for LedgerIndex {
383 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
384 self.folded.serialize(serializer)
385 }
386}
387
388impl<'de> serde::Deserialize<'de> for LedgerIndex {
389 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
390 Folded::deserialize(deserializer).map(|folded| LedgerIndex {
391 folded,
392 vfs: Mutex::new(Box::new(RealFs::new())),
393 })
394 }
395}
396
397#[cfg(test)]
398#[allow(
399 clippy::unwrap_used,
400 clippy::expect_used,
401 clippy::panic,
402 clippy::indexing_slicing,
403 reason = "test code"
404)]
405mod tests;
406
407#[cfg(test)]
408#[allow(
409 clippy::unwrap_used,
410 clippy::expect_used,
411 clippy::panic,
412 clippy::indexing_slicing,
413 reason = "test code"
414)]
415mod scan_tests;