Skip to main content

storage/index/
reader.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//! Index readers: seeking lines without scanning.
7
8use std::fs::File;
9use std::io::{BufRead, BufReader, Seek, SeekFrom};
10use std::path::{Path, PathBuf};
11
12use kernel::Seq;
13
14use super::ledger::LedgerIndex;
15use crate::error::{StorageError, io_err};
16
17/// Reads single lines by seq, holding one segment open across reads.
18///
19/// One handle and one buffer serve the whole stretch, and a walk that
20/// goes forward never seeks at all — the position the previous line
21/// left is the position the next one wants. A jump from a known
22/// position moves relative to it, so a line that already sits in the
23/// buffer — the next line of one run, a few lines of other runs
24/// further on — is read without a seek or a refill. What this replaces opened
25/// the segment again for every line and then read it one byte at a
26/// time. Over a fifty-thousand record ledger on one windows-x86_64
27/// NVMe machine (2026-09-02): 734 µs a line then, against 0.89 µs
28/// walking forward and 5.82 µs walking backward now.
29///
30/// This held handle is why line reading is the index's one step
31/// outside `Vfs` (`crates/storage/spec/Vfs.lean` §8-15): the seam's `read_at` opens the
32/// segment per call, which is the 734 µs this type exists to avoid.
33/// Nothing here writes, so the crash semantics the seam is there to
34/// model have nothing to say about it.
35pub struct LineReader<'index> {
36    pub(crate) index: &'index LedgerIndex,
37    pub(crate) dir: PathBuf,
38    pub(crate) open: Option<OpenSegment>,
39}
40
41impl LineReader<'_> {
42    /// One line, without its terminator. A seq absent from the index is
43    /// a caller error, not a corrupt ledger.
44    pub fn line_at(&mut self, seq: Seq) -> Result<Vec<u8>, StorageError> {
45        let Some((name, offset)) = self.index.folded.loc_of(seq) else {
46            return Err(StorageError::SeqMissing { seq: seq.value() });
47        };
48        let segment = match self.open.take() {
49            Some(open) if open.name == *name => open,
50            _ => OpenSegment::open(&self.dir, name)?,
51        };
52        self.open.insert(segment).line_at(offset)
53    }
54}
55
56/// The segment a `LineReader` currently holds open, and where reading
57/// it would resume.
58pub(crate) struct OpenSegment {
59    name: String,
60    path: PathBuf,
61    file: BufReader<File>,
62    /// The offset the next read starts at, while that offset is known.
63    /// `None` means the position must be sought before it is used —
64    /// a remembered position that might be wrong would hand the caller
65    /// another line's bytes under the seq it asked for.
66    resume: Option<u64>,
67}
68
69impl OpenSegment {
70    fn open(dir: &Path, name: &str) -> Result<OpenSegment, StorageError> {
71        let path = dir.join(name);
72        let file = File::open(&path).map_err(io_err("open segment", &path))?;
73        Ok(OpenSegment {
74            name: name.to_owned(),
75            path,
76            file: BufReader::new(file),
77            resume: Some(0),
78        })
79    }
80
81    fn line_at(&mut self, offset: u64) -> Result<Vec<u8>, StorageError> {
82        if self.resume != Some(offset) {
83            // How far the wanted line sits from where the buffer stands;
84            // unknown when the position is unknown or the distance does
85            // not fit an `i64`, and then the seek is absolute.
86            let distance = self.resume.and_then(|at| {
87                i64::try_from(offset)
88                    .ok()?
89                    .checked_sub(i64::try_from(at).ok()?)
90            });
91            self.resume = None;
92            let moved = match distance {
93                Some(distance) => self.file.seek_relative(distance),
94                None => self.file.seek(SeekFrom::Start(offset)).map(|_start| ()),
95            };
96            moved.map_err(io_err("seek segment", &self.path))?;
97        }
98        self.resume = None;
99        let mut line = Vec::new();
100        let read = self
101            .file
102            .read_until(b'\n', &mut line)
103            .map_err(io_err("read segment", &self.path))?;
104        self.resume = u64::try_from(read)
105            .ok()
106            .and_then(|len| offset.checked_add(len));
107        if line.last().copied() == Some(b'\n') {
108            line.pop();
109        }
110        Ok(line)
111    }
112}
113
114#[cfg(test)]
115#[allow(
116    clippy::unwrap_used,
117    clippy::expect_used,
118    clippy::panic,
119    clippy::indexing_slicing,
120    reason = "test code"
121)]
122mod tests {
123    use super::*;
124    use kernel::{B3Hash, EventDraft, EventKind, EventRecord, Payload, RunId, TimeMs};
125    /// Writes `count` records into segments that roll every
126    /// `per_segment` lines, and returns each line in seq order.
127    fn write_ledger_rolling(dir: &Path, count: u64, per_segment: u64) -> Vec<Vec<u8>> {
128        let run = RunId::from_bytes([7u8; 16]);
129        let mut lines = Vec::new();
130        let mut blob = Vec::new();
131        let mut first_seq = 0u64;
132        let mut prev = B3Hash::digest(b"");
133        for i in 0..count {
134            let draft = EventDraft {
135                run,
136                t: TimeMs::new(i),
137                who: "tester".to_owned(),
138                addr: None,
139                kind: EventKind::RunStarted,
140                data: Payload::new(serde_json::Map::new()).unwrap(),
141                ig: false,
142            };
143            let record = EventRecord::from_draft(draft, Seq::new(i), prev);
144            let line = record.canonical_line().unwrap();
145            prev = B3Hash::digest(&line);
146            blob.extend_from_slice(&line);
147            blob.push(b'\n');
148            lines.push(line);
149            let next = i.saturating_add(1);
150            if next.checked_rem(per_segment) == Some(0) && next < count {
151                std::fs::write(dir.join(format!("ledger-{first_seq:020}.jsonl")), &blob).unwrap();
152                blob.clear();
153                first_seq = next;
154            }
155        }
156        std::fs::write(dir.join(format!("ledger-{first_seq:020}.jsonl")), &blob).unwrap();
157        lines
158    }
159
160    fn write_interleaved(dir: &Path, runs: &[RunId], count: u64) -> Vec<(RunId, Vec<Seq>)> {
161        let mut blob = Vec::new();
162        let mut prev = B3Hash::digest(b"");
163        let mut owned: Vec<(RunId, Vec<Seq>)> = runs.iter().map(|run| (*run, Vec::new())).collect();
164        for i in 0..count {
165            let slot = usize::try_from(i)
166                .unwrap()
167                .checked_rem(runs.len())
168                .expect("at least one run to cycle through");
169            let run = runs[slot];
170            let draft = EventDraft {
171                run,
172                t: TimeMs::new(i),
173                who: "tester".to_owned(),
174                addr: None,
175                kind: EventKind::RunStarted,
176                data: Payload::new(serde_json::Map::new()).unwrap(),
177                ig: false,
178            };
179            let record = EventRecord::from_draft(draft, Seq::new(i), prev);
180            let line = record.canonical_line().unwrap();
181            prev = B3Hash::digest(&line);
182            blob.extend_from_slice(&line);
183            blob.push(b'\n');
184            owned[slot].1.push(Seq::new(i));
185        }
186        std::fs::write(dir.join("ledger-00000000000000000000.jsonl"), &blob).unwrap();
187        owned
188    }
189
190    /// The whole point of the run index: one session's lines are found
191    /// without reading anybody else's. The assertion is on the seqs the
192    /// index hands back, because that set *is* what the reader will open
193    /// the segment for - a walk that returned the other runs' seqs and
194    /// left the filtering to the caller would have read them all.
195    #[test]
196    fn one_reader_answers_out_of_order_seeks_across_segments() {
197        let tmp = tempfile::tempdir().unwrap();
198        let lines = write_ledger_rolling(tmp.path(), 9, 4);
199        let index = LedgerIndex::rebuild(tmp.path()).unwrap();
200        assert_eq!(index.len(), 9, "three segments, nine lines");
201        let mut reader = index.reader(tmp.path());
202        // Backwards, repeated, and ping-ponging between segments. A
203        // reader that holds a handle open must hold its position honest
204        // too: every one of these asks for a line the buffer is not
205        // already sitting on.
206        for value in [8u64, 0, 8, 3, 4, 3, 7, 1, 1, 5] {
207            let got = reader.line_at(Seq::new(value)).unwrap();
208            let expected = &lines[usize::try_from(value).unwrap()];
209            assert_eq!(&got, expected, "seq {value} reads back verbatim");
210        }
211    }
212
213    #[test]
214    fn a_run_s_own_lines_are_found_without_walking_anybody_else_s() {
215        let tmp = tempfile::tempdir().unwrap();
216        let mine = RunId::from_bytes([1u8; 16]);
217        let yours = RunId::from_bytes([2u8; 16]);
218        let city = RunId::CITY;
219        let owned = write_interleaved(tmp.path(), &[mine, yours, city], 30);
220        let index = LedgerIndex::rebuild(tmp.path()).unwrap();
221
222        let all: Vec<Seq> = index.run_seqs_before(mine, None).collect();
223        let mut expected = owned[0].1.clone();
224        expected.reverse();
225        assert_eq!(all, expected, "newest first, and only this run's lines");
226
227        // Bounded by the answer rather than by the ledger: taking two
228        // reads two, however many lines the other runs wrote between.
229        let newest_two: Vec<Seq> = index.run_seqs_before(mine, None).take(2).collect();
230        assert_eq!(newest_two, [Seq::new(27), Seq::new(24)]);
231
232        // `before` is exclusive, which is what the wire's `earlier`
233        // cursor already means; paging with it never repeats a record.
234        let older: Vec<Seq> = index
235            .run_seqs_before(mine, Some(Seq::new(24)))
236            .take(2)
237            .collect();
238        assert_eq!(older, [Seq::new(21), Seq::new(18)]);
239
240        // A session this ledger never held has no lines, which is a
241        // different answer from an empty ledger and needs no special case.
242        let stranger: Vec<Seq> = index
243            .run_seqs_before(RunId::from_bytes([9u8; 16]), None)
244            .collect();
245        assert!(stranger.is_empty());
246
247        // The city's own records are a run like any other here.
248        assert_eq!(index.run_seqs_before(city, None).count(), 10);
249    }
250
251    #[test]
252    fn the_run_map_survives_a_refresh() {
253        let tmp = tempfile::tempdir().unwrap();
254        let mine = RunId::from_bytes([1u8; 16]);
255        let yours = RunId::from_bytes([2u8; 16]);
256        write_interleaved(tmp.path(), &[mine, yours], 4);
257        let mut index = LedgerIndex::rebuild(tmp.path()).unwrap();
258        assert_eq!(index.run_seqs_before(mine, None).count(), 2);
259
260        // Appended: the refresh folds the new lines into both maps, and a
261        // reopened index reads the same answer from the segments.
262        write_interleaved(tmp.path(), &[mine, yours], 10);
263        index.refresh(tmp.path()).unwrap();
264        assert_eq!(index.run_seqs_before(mine, None).count(), 5);
265
266        let reopened = LedgerIndex::rebuild(tmp.path()).unwrap();
267        assert_eq!(reopened.len(), 10);
268        assert_eq!(reopened.run_seqs_before(mine, None).count(), 5);
269    }
270}