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::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;