Skip to main content

storage/jsonl/
append.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//! Ledger appends: waves, reads, and the kernel Ledger face.
7
8use std::path::PathBuf;
9
10use kernel::ledger::chain_hash;
11use kernel::{
12    AxCode, AxError, EventDraft, EventKind, EventRecord, EventRef, Payload, RunId, Seq, TimeMs,
13};
14
15use crate::error::{StorageError, io_err};
16
17use super::barrier::Barrier;
18use super::ledger::{
19    JsonlLedger, WriteObserver, complete_lines, is_segment, segment_file_name, u64_count,
20};
21
22impl JsonlLedger {
23    pub(crate) fn append_log_truncated(
24        &mut self,
25        now: TimeMs,
26        dropped: u64,
27    ) -> Result<(), StorageError> {
28        let data = Payload::of(&kernel::event::record::LogTruncated {
29            dropped_bytes: dropped,
30        })
31        .map_err(|source| StorageError::Draft { source })?;
32        let draft = EventDraft {
33            run: RunId::CITY,
34            t: now,
35            who: kernel::event::Who::City.to_string(),
36            addr: None,
37            kind: EventKind::LogTruncated,
38            data,
39            ig: false,
40        };
41        self.append_all(vec![draft]).map(|_| ())
42    }
43
44    /// Group commit: one durability barrier for the whole wave
45    /// (`crates/storage/Spec.lean` §3, item 1: the batch is what the wave delivered).
46    pub fn append_all(&mut self, drafts: Vec<EventDraft>) -> Result<Vec<EventRef>, StorageError> {
47        // A restore a failed wave left owed runs first: it is what mends
48        // the barrier, and it may remove the segment this wave would
49        // append to.
50        self.finish_unwind()?;
51        self.barrier.admit(&self.dir, self.next_seq)?;
52        self.halt.admit()?;
53        if drafts.is_empty() {
54            return Ok(Vec::new());
55        }
56        let mut seq = self.next_seq;
57        let mut prev = self.prev;
58        let mut cur_path = self.seg_path.clone();
59        let mut cur_len = self.seg_len;
60        let mut writes: Vec<(PathBuf, u64, Vec<u8>)> = Vec::new();
61        let mut created: Vec<PathBuf> = Vec::new();
62        let mut records = Vec::with_capacity(drafts.len());
63
64        if cur_len == 0 && !self.vfs.exists(&cur_path) {
65            created.push(cur_path.clone());
66        }
67
68        for draft in drafts {
69            let record = EventRecord::from_draft(draft, seq, prev);
70            let line = record
71                .canonical_line()
72                .map_err(|source| StorageError::Draft { source })?;
73            let line_len = u64_count(line.len())
74                .map_err(io_err("measure an event line", &cur_path))?
75                .saturating_add(1);
76            if cur_len > 0 && cur_len.saturating_add(line_len) > self.roll_bytes {
77                cur_path = self.dir.join(segment_file_name(seq));
78                cur_len = 0;
79                created.push(cur_path.clone());
80            }
81            prev = chain_hash(&line);
82            seq = seq
83                .next()
84                .map_err(|source| StorageError::Draft { source })?;
85            records.push(record);
86            let mut terminated = line;
87            terminated.push(b'\n');
88            match writes.last_mut() {
89                Some((path, _, buffer)) if *path == cur_path => {
90                    buffer.extend_from_slice(&terminated);
91                }
92                _ => writes.push((cur_path.clone(), cur_len, terminated)),
93            }
94            cur_len = cur_len.saturating_add(line_len);
95        }
96
97        self.barrier = Barrier::Broken;
98        self.write_wave(&writes, created)?;
99
100        self.barrier = Barrier::Whole;
101        self.seg_path = cur_path;
102        self.seg_len = cur_len;
103        self.next_seq = seq;
104        self.prev = prev;
105
106        // The per-room projection is laid down only now, after the wave
107        // is durable: the bytes it copies exist before it runs. A
108        // refusal is reported and skipped rather than returned - the
109        // history already has the record, and a disposable artifact
110        // must never fail history's caller (`crates/storage/spec/Sessions.lean` §8-24).
111        if let Some(sessions) = self.sessions.as_mut() {
112            for record in &records {
113                if let Err(error) = sessions.absorb(record) {
114                    eprintln!("a session slice was refused and skipped: {error}");
115                }
116            }
117        }
118
119        // Only now, with the bytes synced, does anyone else hear about
120        // them: an observer that saw an event the disk never got would be
121        // telling the interface something the history does not contain.
122        if let Some(observer) = self.observer.as_mut() {
123            for record in &records {
124                observer(record);
125            }
126        }
127        Ok(records.iter().map(EventRecord::to_ref).collect())
128    }
129
130    /// Hands this ledger's session slices to whoever will file them from
131    /// now on; this writer files none after it. `None` for a ledger that
132    /// is not a city's, and for one that already handed them off
133    /// (`crates/storage/spec/Sessions.lean` §8-24).
134    pub fn hand_off_session_slices(&mut self) -> Option<crate::sessions::Sessions> {
135        self.sessions.take()
136    }
137
138    /// The position a record written now would take.
139    ///
140    /// A position, never content: this is what anchors a diagnostic log
141    /// line to the only history (`docs/logging.md` section 4), and a
142    /// reader accessor that returned records would invite decision logic
143    /// to consult the ledger it is in the middle of writing.
144    #[must_use]
145    pub fn position(&self) -> Seq {
146        self.next_seq
147    }
148
149    /// Installs the write-path observer, replacing any earlier one. The
150    /// sink runs on the appending thread after durability, so it must not
151    /// block; a bounded send that drops on lag is the shape this expects.
152    pub fn observe(&mut self, sink: WriteObserver) {
153        self.observer = Some(sink);
154    }
155
156    /// The read face for replay, fixtures and inspection: every canonical
157    /// line (no terminators), segments flattened in order.
158    pub fn read_raw_lines(&self) -> Result<Vec<Vec<u8>>, StorageError> {
159        let mut out = Vec::new();
160        let segments: Vec<PathBuf> = self
161            .vfs
162            .list(&self.dir)
163            .map_err(io_err("list ledger dir", &self.dir))?
164            .into_iter()
165            .filter(|p| is_segment(p))
166            .collect();
167        for seg in segments {
168            let bytes = self.vfs.read(&seg).map_err(io_err("read segment", &seg))?;
169            let (lines, _) = complete_lines(&bytes);
170            for line in lines {
171                if !line.is_empty() {
172                    out.push(line.to_vec());
173                }
174            }
175        }
176        Ok(out)
177    }
178
179    #[cfg(test)]
180    pub(crate) fn set_roll_bytes_for_test(&mut self, roll_bytes: u64) {
181        self.roll_bytes = roll_bytes;
182    }
183}
184
185impl kernel::Ledger for JsonlLedger {
186    fn append(&mut self, draft: EventDraft) -> Result<EventRef, AxError> {
187        let refs = self
188            .append_all(vec![draft])
189            .map_err(StorageError::into_ax)?;
190        refs.into_iter().next().ok_or_else(|| {
191            AxError::failure(AxCode::InvalidArgs, "append event", "empty wave echo").with_recovery(
192                "report this against storage::jsonl::append with the city path: \
193                         append_all owes one echo per draft and returned none",
194            )
195        })
196    }
197
198    /// One buffer, one write and one barrier for the whole wave.
199    fn append_all(&mut self, drafts: Vec<EventDraft>) -> Result<Vec<EventRef>, AxError> {
200        JsonlLedger::append_all(self, drafts).map_err(StorageError::into_ax)
201    }
202}
203
204#[cfg(any(test, feature = "conformance"))]
205impl kernel::ledger::conformance::LedgerInspect for JsonlLedger {
206    fn raw_lines(&self) -> Result<Vec<Vec<u8>>, AxError> {
207        self.read_raw_lines().map_err(StorageError::into_ax)
208    }
209}
210
211#[cfg(test)]
212#[allow(
213    clippy::unwrap_used,
214    clippy::expect_used,
215    clippy::panic,
216    clippy::indexing_slicing,
217    reason = "test code"
218)]
219mod tests {
220    use super::*;
221    use kernel::GENESIS_PREV;
222    use std::fs;
223    use std::path::Path;
224    fn draft(kind: EventKind, t: u64) -> EventDraft {
225        EventDraft {
226            run: RunId::CITY,
227            t: TimeMs::new(t),
228            who: "city".to_string(),
229            addr: None,
230            kind,
231            data: Payload::empty(),
232            ig: false,
233        }
234    }
235    fn verify_chain(lines: &[Vec<u8>]) {
236        let mut prev = GENESIS_PREV;
237        for (i, line) in lines.iter().enumerate() {
238            let record = EventRecord::parse_line(line).unwrap();
239            assert_eq!(record.prev(), prev, "prev broken at line {i}");
240            assert_eq!(record.seq(), Seq::new(u64::try_from(i).unwrap()));
241            prev = chain_hash(line);
242        }
243    }
244
245    #[test]
246    fn passes_the_kernel_conformance_suite() {
247        use kernel::ledger::conformance::assert_ledger_conformance;
248        let keep: std::cell::RefCell<Vec<tempfile::TempDir>> = std::cell::RefCell::new(Vec::new());
249        assert_ledger_conformance(|| {
250            let dir = tempfile::tempdir().unwrap();
251            let (ledger, _) = JsonlLedger::open(dir.path(), TimeMs::new(0)).unwrap();
252            keep.borrow_mut().push(dir);
253            ledger
254        });
255    }
256
257    #[test]
258    fn rolls_segments_without_breaking_seq_or_chain() {
259        let dir = tempfile::tempdir().unwrap();
260        let (mut ledger, _) = JsonlLedger::open(dir.path(), TimeMs::new(0)).unwrap();
261        ledger.set_roll_bytes_for_test(1);
262        for t in 0..4 {
263            ledger
264                .append_all(vec![draft(EventKind::GateChecked, t)])
265                .unwrap();
266        }
267        let segments = fs::read_dir(dir.path()).unwrap().count();
268        assert!(segments >= 4, "tiny roll budget must produce many segments");
269        let lines = ledger.read_raw_lines().unwrap();
270        assert_eq!(lines.len(), 4);
271        verify_chain(&lines);
272
273        drop(ledger);
274        let (mut reopened, report) = JsonlLedger::open(dir.path(), TimeMs::new(9)).unwrap();
275        assert!(report.recovered.is_none());
276        reopened
277            .append_all(vec![draft(EventKind::RunFrozen, 9)])
278            .unwrap();
279        verify_chain(&reopened.read_raw_lines().unwrap());
280    }
281
282    /// What one production append costs the disk, counted rather than
283    /// timed: `kernel::Ledger::append` is a batch of one, so every
284    /// record pays a whole durability barrier. The op count is the
285    /// budget reading; the wall clock belongs to the machine.
286    #[test]
287    fn one_append_costs_a_fixed_number_of_disk_operations() {
288        let dir = tempfile::tempdir().unwrap();
289        let fs = crate::fault_fs::FaultFs::new(crate::fault_fs::FaultPlan {
290            cut_at_op: None,
291            cut_on_write: None,
292            torn_tail: crate::fault_fs::TornTail::None,
293        });
294        let (mut ledger, _) =
295            JsonlLedger::open_faulty(fs.clone(), dir.path(), TimeMs::new(0)).unwrap();
296        let opened = fs.op_count();
297        kernel::Ledger::append(&mut ledger, draft(EventKind::GateChecked, 1)).unwrap();
298        let first = fs.op_count().saturating_sub(opened);
299        kernel::Ledger::append(&mut ledger, draft(EventKind::GateChecked, 2)).unwrap();
300        let second = fs.op_count().saturating_sub(opened).saturating_sub(first);
301        let bytes: u64 = ledger
302            .read_raw_lines()
303            .unwrap()
304            .iter()
305            .map(|line| u64::try_from(line.len().saturating_add(1)).unwrap())
306            .sum();
307        eprintln!(
308            "ledger_append_ops: {first} disk ops for the first record, {second} for \n             every one after it, {bytes} B on disk for two records"
309        );
310        assert!(
311            first > second,
312            "the first record also creates the segment and syncs its directory entry"
313        );
314    }
315
316    fn only_segment(dir: &Path) -> std::path::PathBuf {
317        let mut files: Vec<_> = fs::read_dir(dir)
318            .unwrap()
319            .map(|e| e.unwrap().path())
320            .collect();
321        files.sort();
322        assert_eq!(files.len(), 1);
323        files.remove(0)
324    }
325
326    fn faulty(dir: &Path) -> (JsonlLedger, crate::fault_fs::FaultFs) {
327        let fs = crate::fault_fs::FaultFs::new(crate::fault_fs::FaultPlan {
328            cut_at_op: None,
329            cut_on_write: None,
330            torn_tail: crate::fault_fs::TornTail::None,
331        });
332        let (ledger, _) = JsonlLedger::open_faulty(fs.clone(), dir, TimeMs::new(0)).unwrap();
333        (ledger, fs)
334    }
335
336    fn drafts(count: u64) -> Vec<EventDraft> {
337        (0..count)
338            .map(|t| draft(EventKind::GateChecked, t))
339            .collect()
340    }
341
342    /// Group commit: a wave of three records in one segment costs the
343    /// disk what a wave of one costs - one write and one barrier.
344    #[test]
345    fn a_wave_costs_one_write_and_one_barrier_whatever_it_carries() {
346        let dir = tempfile::tempdir().unwrap();
347        let (mut ledger, fs) = faulty(dir.path());
348        ledger.append_all(drafts(1)).unwrap();
349        let before_one = fs.op_count();
350        ledger.append_all(drafts(1)).unwrap();
351        let one = fs.op_count() - before_one;
352        let before_three = fs.op_count();
353        ledger.append_all(drafts(3)).unwrap();
354        let three = fs.op_count() - before_three;
355        assert_eq!(three, one);
356    }
357
358    /// A wave that crosses the roll puts each line in the segment its
359    /// seq names, so the names stay the authority on where a seq lives.
360    #[test]
361    fn a_wave_across_the_roll_puts_each_line_in_the_segment_named_for_it() {
362        let dir = tempfile::tempdir().unwrap();
363        let (mut ledger, _) = JsonlLedger::open(dir.path(), TimeMs::new(0)).unwrap();
364        ledger.set_roll_bytes_for_test(1);
365        ledger.append_all(drafts(3)).unwrap();
366        let mut held: Vec<(String, Vec<u64>)> = fs::read_dir(dir.path())
367            .unwrap()
368            .map(|entry| entry.unwrap().path())
369            .filter(|path| is_segment(path))
370            .map(|path| {
371                let seqs = fs::read(&path)
372                    .unwrap()
373                    .split(|byte| *byte == b'\n')
374                    .filter(|line| !line.is_empty())
375                    .map(|line| EventRecord::parse_line(line).unwrap().seq().value())
376                    .collect();
377                (
378                    path.file_name().unwrap().to_string_lossy().into_owned(),
379                    seqs,
380                )
381            })
382            .collect();
383        held.sort();
384        let expected: Vec<(String, Vec<u64>)> = (0..3)
385            .map(|seq| (segment_file_name(Seq::new(seq)), vec![seq]))
386            .collect();
387        assert_eq!(held, expected);
388    }
389
390    /// The roll is for a line that would carry a segment past its
391    /// budget; a line that fills it exactly stays.
392    #[test]
393    fn a_line_that_fills_the_segment_exactly_does_not_roll() {
394        let dir = tempfile::tempdir().unwrap();
395        let (mut ledger, _) = JsonlLedger::open(dir.path(), TimeMs::new(0)).unwrap();
396        ledger.append_all(drafts(2)).unwrap();
397        let exact = u64::try_from(fs::read(only_segment(dir.path())).unwrap().len()).unwrap();
398        let fresh = tempfile::tempdir().unwrap();
399        let (mut ledger, _) = JsonlLedger::open(fresh.path(), TimeMs::new(0)).unwrap();
400        ledger.set_roll_bytes_for_test(exact);
401        ledger.append_all(drafts(1)).unwrap();
402        ledger.append_all(drafts(1)).unwrap();
403        assert_eq!(
404            fs::read(only_segment(fresh.path())).unwrap().len(),
405            usize::try_from(exact).unwrap()
406        );
407    }
408
409    /// The observer hears every record of a wave, in seq order, once the
410    /// wave is durable.
411    #[test]
412    fn the_observer_hears_each_record_of_a_wave_in_order() {
413        let dir = tempfile::tempdir().unwrap();
414        let (mut ledger, _) = JsonlLedger::open(dir.path(), TimeMs::new(0)).unwrap();
415        let heard = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
416        let sink = std::sync::Arc::clone(&heard);
417        ledger.observe(Box::new(move |record| {
418            sink.lock().unwrap().push(record.seq().value());
419        }));
420        ledger.append_all(drafts(3)).unwrap();
421        assert_eq!(*heard.lock().unwrap(), vec![0, 1, 2]);
422    }
423
424    /// The port's batch append echoes one reference per draft, the
425    /// reference of the line it wrote.
426    #[test]
427    fn the_port_s_batch_append_echoes_what_it_wrote() {
428        let dir = tempfile::tempdir().unwrap();
429        let (mut ledger, _) = JsonlLedger::open(dir.path(), TimeMs::new(0)).unwrap();
430        let echoed = kernel::Ledger::append_all(&mut ledger, drafts(2)).unwrap();
431        let written: Vec<_> = ledger
432            .read_raw_lines()
433            .unwrap()
434            .iter()
435            .map(|line| EventRecord::parse_line(line).unwrap().to_ref())
436            .collect();
437        assert_eq!(echoed, written);
438    }
439
440    /// A segment a crash created before its first byte is appended to,
441    /// not created again: its directory entry is already durable, so the
442    /// first wave pays no directory barrier.
443    #[test]
444    fn an_empty_segment_a_crash_left_is_appended_to_without_a_directory_barrier() {
445        let dir = tempfile::tempdir().unwrap();
446        let fs = crate::fault_fs::FaultFs::new(crate::fault_fs::FaultPlan {
447            cut_at_op: None,
448            cut_on_write: None,
449            torn_tail: crate::fault_fs::TornTail::None,
450        });
451        // The opening reads the tail and keeps the empty segment where
452        // it is (`crates/storage/Spec.lean` §8-1 step 5: nothing is cut).
453        let mut seeded = fs.clone();
454        crate::vfs::Vfs::create_dir_all(&mut seeded, dir.path()).unwrap();
455        crate::vfs::Vfs::append(
456            &mut seeded,
457            &dir.path().join(segment_file_name(Seq::FIRST)),
458            b"",
459        )
460        .unwrap();
461        let (mut ledger, _) =
462            JsonlLedger::open_faulty(fs.clone(), dir.path(), TimeMs::new(0)).unwrap();
463        let before_first = fs.op_count();
464        ledger.append_all(drafts(1)).unwrap();
465        let first = fs.op_count() - before_first;
466        let before_second = fs.op_count();
467        ledger.append_all(drafts(1)).unwrap();
468        let second = fs.op_count() - before_second;
469        assert_eq!(first, second);
470    }
471
472    proptest::proptest! {
473        #[test]
474        fn any_tail_damage_recovers_to_a_valid_chain(
475            n in 1usize..5,
476            cut_back in 1usize..40,
477            garbage in proptest::collection::vec(1u8..=255, 0..24),
478        ) {
479            let dir = tempfile::tempdir().unwrap();
480            let (mut ledger, _) = JsonlLedger::open(dir.path(), TimeMs::new(0)).unwrap();
481            let drafts: Vec<_> = (0..n)
482                .map(|i| draft(EventKind::GateChecked, u64::try_from(i).unwrap()))
483                .collect();
484            ledger.append_all(drafts).unwrap();
485            let baseline = ledger.read_raw_lines().unwrap();
486            let seg = only_segment(dir.path());
487            drop(ledger);
488
489            let mut bytes = fs::read(&seg).unwrap();
490            let keep = bytes.len().saturating_sub(cut_back);
491            bytes.truncate(keep);
492            bytes.extend_from_slice(&garbage);
493            fs::write(&seg, &bytes).unwrap();
494
495            let (reopened, _) = JsonlLedger::open(dir.path(), TimeMs::new(999)).unwrap();
496            let lines = reopened.read_raw_lines().unwrap();
497            verify_chain(&lines);
498            // Every surviving pre-damage line is a byte-exact prefix entry.
499            let survivors = lines
500                .iter()
501                .filter(|l| {
502                    EventRecord::parse_line(l).unwrap().kind() != EventKind::LogTruncated
503                })
504                .count();
505            proptest::prop_assert!(survivors <= baseline.len());
506            for (mine, original) in lines.iter().take(survivors).zip(baseline.iter()) {
507                proptest::prop_assert_eq!(mine, original);
508            }
509        }
510    }
511}