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