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