Skip to main content

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;