Skip to main content

car_sync/
journal.rs

1//! Durable JSONL persistence for the oplog — the `car-eventlog` journal
2//! idiom: append-only, one record per line, torn-line tolerant on load.
3//!
4//! A crash mid-write leaves at most one torn (unparseable) trailing line;
5//! [`OplogJournal::load`] skips it (and any blank lines) instead of failing,
6//! exactly like `EventLog::load`. Nothing is lost from the sync's point of
7//! view: an op that never fully reached the journal was never acked, and the
8//! oplog's convergence is defined over the op-*set* — re-appending it (or
9//! re-pulling it from a peer/relay in B3) folds to the same state.
10//! Integrity/order checking is deliberately NOT done here — it is the
11//! explicit, separate [`crate::oplog::verify_log`] pass.
12//!
13//! **Single writer, enforced.** A journal is one device's log: two writers
14//! on one path would fork the `seq` chain and can interleave bytes
15//! mid-record. [`OplogJournal::open`] therefore takes an exclusive advisory
16//! lock on `<path>.lock` (held for the journal's lifetime; the OS releases
17//! it on drop) and fails with `WouldBlock` if another holder exists — the
18//! same protocol `car-registry`'s supervisor uses for `agents.json.lock`.
19//! [`OplogJournal::load`] is read-only and takes no lock.
20//!
21//! **Truncated journals are marked and fenced (B4).**
22//! [`OplogJournal::truncate_to`] stamps a [`TruncationMarker`] as the new
23//! file's first line — atomic with the truncation itself (same rename, no
24//! crash window in between). The marker names the covering checkpoint's
25//! content address, and it exists to make a permanent-fork hazard a
26//! **runtime error instead of a documentation footnote**: a device whose
27//! ops were ALL below the frontier leaves no trace of itself in the
28//! retained tail, so `DeviceLog::resume` over that tail would silently
29//! restart it at `seq 0` — an unrecoverable duplicate-seq chain fork.
30//! Therefore [`OplogJournal::load`] **refuses** a marked journal (use
31//! [`OplogJournal::load_with_marker`], fetch the named checkpoint, and go
32//! through [`crate::checkpoint::resume_anchored`] /
33//! [`crate::checkpoint::verify_anchored`]), and `DeviceLog::resume` itself
34//! rejects an own-chain non-zero start as a second fence.
35
36use crate::oplog::OpRecord;
37use serde::{Deserialize, Serialize};
38use std::fs::{self, File, OpenOptions, TryLockError};
39use std::io::{BufRead, BufReader, BufWriter, Write};
40use std::path::{Path, PathBuf};
41
42/// The first line of a truncated journal: names the checkpoint (by
43/// whole-record content address) that accounts for everything the
44/// truncation dropped. Written atomically WITH the truncation by
45/// [`OplogJournal::truncate_to`]; surfaced by
46/// [`OplogJournal::load_with_marker`]; fences [`OplogJournal::load`].
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct TruncationMarker {
49    /// [`crate::checkpoint::Checkpoint::checkpoint_hash`] of the covering
50    /// checkpoint — fetch it and resume via `resume_anchored`.
51    pub checkpoint_hash: String,
52}
53
54/// The on-disk line wrapper — a shape no [`OpRecord`] can collide with.
55#[derive(Debug, Serialize, Deserialize)]
56struct MarkerLine {
57    truncation_marker: TruncationMarker,
58}
59
60/// Append-only JSONL journal for [`OpRecord`]s. Holds an exclusive advisory
61/// lock on `<path>.lock` for its lifetime — one writer per journal path.
62#[derive(Debug)]
63pub struct OplogJournal {
64    path: PathBuf,
65    writer: BufWriter<File>,
66    /// Advisory lock handle: its existence + the exclusive lock are the
67    /// entire protocol (never written; intentionally not unlinked on drop —
68    /// unlink-on-drop races a new acquirer creating the file first).
69    _lock: File,
70    /// Test-only fault seam: when `Some(n)`, the `n`-th subsequent
71    /// [`OplogJournal::append`] fails (as a real ENOSPC-class error would),
72    /// exercising the writer-recreate + caller-rollback recovery paths that
73    /// no portable API can trigger deterministically.
74    #[cfg(test)]
75    pub(crate) fail_append_after: Option<usize>,
76}
77
78impl OplogJournal {
79    /// Open (creating parents and the file if needed) for appending.
80    /// Existing content is preserved — append mode, never truncate.
81    ///
82    /// If the file's last line is torn (a crash mid-write left it without a
83    /// terminating newline), a newline is written first so the next append
84    /// starts a fresh line instead of gluing itself onto the garbage —
85    /// otherwise the first post-crash append would be lost with the tail.
86    pub fn open(path: &Path) -> std::io::Result<Self> {
87        if let Some(parent) = path.parent() {
88            if !parent.as_os_str().is_empty() {
89                fs::create_dir_all(parent)?;
90            }
91        }
92        // Exclusive advisory lock BEFORE touching the journal — a second
93        // writer on this path would fork the seq chain and interleave bytes
94        // mid-record (the car-registry supervisor lock pattern).
95        let lock_path = {
96            let mut s = path.as_os_str().to_owned();
97            s.push(".lock");
98            PathBuf::from(s)
99        };
100        let lock = OpenOptions::new()
101            .read(true)
102            .write(true)
103            .create(true)
104            .truncate(false)
105            .open(&lock_path)?;
106        match lock.try_lock() {
107            Ok(()) => {}
108            Err(TryLockError::WouldBlock) => {
109                return Err(std::io::Error::new(
110                    std::io::ErrorKind::WouldBlock,
111                    format!(
112                        "oplog journal already open by another writer (advisory lock held on {})",
113                        lock_path.display()
114                    ),
115                ));
116            }
117            Err(TryLockError::Error(e)) => return Err(e),
118        }
119        let needs_newline = match File::open(path) {
120            Ok(mut existing) => {
121                use std::io::{Read, Seek, SeekFrom};
122                if existing.metadata()?.len() == 0 {
123                    false
124                } else {
125                    existing.seek(SeekFrom::End(-1))?;
126                    let mut last = [0u8; 1];
127                    existing.read_exact(&mut last)?;
128                    last[0] != b'\n'
129                }
130            }
131            Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
132            Err(e) => return Err(e),
133        };
134        let file = OpenOptions::new().create(true).append(true).open(path)?;
135        let mut writer = BufWriter::new(file);
136        if needs_newline {
137            writer.write_all(b"\n")?;
138            writer.flush()?;
139        }
140        Ok(Self {
141            path: path.to_path_buf(),
142            writer,
143            _lock: lock,
144            #[cfg(test)]
145            fail_append_after: None,
146        })
147    }
148
149    /// Append one op as a single JSON line and flush it to the OS page
150    /// cache, so a subsequent crash can tear at most the *next* record.
151    ///
152    /// **Not a durability barrier** — `flush` clears the `BufWriter` buffer
153    /// to the OS but does not `fsync`. A caller with a
154    /// journal-durable-before-transmit / ack-asserts-durable contract
155    /// (the sync session) MUST call [`OplogJournal::sync`] before it
156    /// transmits or acks; per-op fsync would be needless latency (one batch
157    /// barrier at the contract point is enough).
158    ///
159    /// **Failure recovers the writer.** On a write/flush error the
160    /// `BufWriter` would otherwise *retain* the unflushed line; a later
161    /// append would then emit that rolled-back line beside the caller's
162    /// re-minted same-`seq` op — a permanent chain fork. So a failed append
163    /// **recreates the writer** on a fresh append-mode handle, discarding
164    /// the poisoned buffer, before returning the error (the caller rolls
165    /// its in-memory chain back to the durable ops). Bytes that already
166    /// reached the file are at most one torn trailing line, which
167    /// [`OplogJournal::load`] tolerates.
168    pub fn append(&mut self, op: &OpRecord) -> std::io::Result<()> {
169        #[cfg(test)]
170        if let Some(n) = self.fail_append_after {
171            if n == 0 {
172                self.fail_append_after = None;
173                self.reopen_writer()?; // same recovery a real failure takes
174                return Err(std::io::Error::other("injected append failure"));
175            }
176            self.fail_append_after = Some(n - 1);
177        }
178        let line = serde_json::to_string(op).map_err(std::io::Error::other)?;
179        match self
180            .writer
181            .write_all(line.as_bytes())
182            .and_then(|()| self.writer.write_all(b"\n"))
183            .and_then(|()| self.writer.flush())
184        {
185            Ok(()) => Ok(()),
186            Err(e) => {
187                // Discard the poisoned buffer; surface the reopen error only
188                // if even that fails (then the journal is truly unusable).
189                match self.reopen_writer() {
190                    Ok(()) => Err(e),
191                    Err(reopen_err) => Err(reopen_err),
192                }
193            }
194        }
195    }
196
197    /// Durability barrier: flush the buffer and `fsync` the journal file to
198    /// stable storage. The sync session crosses this before it transmits an
199    /// op (journal-durable-before-transmit, B1 MUST) and before it acks a
200    /// fold frontier (ack-asserts-durable, B4 MUST) — one batch call, not
201    /// per-op.
202    pub fn sync(&mut self) -> std::io::Result<()> {
203        self.writer.flush()?;
204        self.writer.get_ref().sync_all()
205    }
206
207    /// Recreate the buffered writer on a fresh append-mode handle, dropping
208    /// any bytes buffered (but not yet flushed) in the current one.
209    fn reopen_writer(&mut self) -> std::io::Result<()> {
210        let file = OpenOptions::new().create(true).append(true).open(&self.path)?;
211        self.writer = BufWriter::new(file);
212        Ok(())
213    }
214
215    pub fn path(&self) -> &Path {
216        &self.path
217    }
218
219    /// Rewrite the journal to exactly `ops`, stamped with a
220    /// [`TruncationMarker`] naming `checkpoint_hash` as its first line —
221    /// B4's truncation-below-a-frontier step, executed under the advisory
222    /// lock this open journal already holds (no second writer can
223    /// interleave). The marker travels IN the rewritten file, so it is
224    /// atomic with the truncation: there is no crash window in which the
225    /// journal is truncated but unmarked (or marked but untruncated).
226    ///
227    /// Durability: the retained ops are written to a sibling temp file,
228    /// fsync'd, and atomically renamed over the journal, so a crash at any
229    /// point leaves either the old complete journal or the new complete
230    /// tail — never a partial rewrite. **Crash-ordering invariant (callers
231    /// MUST honor it; [`crate::compact::compact_and_truncate`] does by
232    /// construction): the covering checkpoint is durable BEFORE this runs.**
233    /// Truncation makes the dropped ops unrecoverable from the journal; the
234    /// checkpoint named by the marker is what still accounts for them.
235    pub fn truncate_to(&mut self, ops: &[OpRecord], checkpoint_hash: &str) -> std::io::Result<()> {
236        let tmp_path = {
237            let mut s = self.path.as_os_str().to_owned();
238            s.push(".compact.tmp");
239            PathBuf::from(s)
240        };
241        {
242            let mut tmp = BufWriter::new(File::create(&tmp_path)?);
243            let marker = serde_json::to_string(&MarkerLine {
244                truncation_marker: TruncationMarker {
245                    checkpoint_hash: checkpoint_hash.to_string(),
246                },
247            })
248            .map_err(std::io::Error::other)?;
249            tmp.write_all(marker.as_bytes())?;
250            tmp.write_all(b"\n")?;
251            for op in ops {
252                let line = serde_json::to_string(op).map_err(std::io::Error::other)?;
253                tmp.write_all(line.as_bytes())?;
254                tmp.write_all(b"\n")?;
255            }
256            tmp.flush()?;
257            tmp.get_ref().sync_all()?;
258        }
259        fs::rename(&tmp_path, &self.path)?;
260        // The old writer handle points at the renamed-over inode; reopen on
261        // the new file so subsequent appends land in the truncated journal.
262        let file = OpenOptions::new().create(true).append(true).open(&self.path)?;
263        self.writer = BufWriter::new(file);
264        // Make the rename durable where the platform allows it.
265        #[cfg(unix)]
266        if let Some(parent) = self.path.parent() {
267            if !parent.as_os_str().is_empty() {
268                let _ = File::open(parent).and_then(|d| d.sync_all());
269            }
270        }
271        Ok(())
272    }
273
274    /// Load a **full** (never-truncated) journal: every parseable op, in
275    /// file order. Blank and unparseable (torn) lines are skipped; a
276    /// missing file is an empty log (a fresh device bootstraps from
277    /// nothing).
278    ///
279    /// A journal carrying a [`TruncationMarker`] is **refused** with a
280    /// runtime error: its ops are only the retained tail, and treating them
281    /// as the whole log silently re-mints truncated seqs on
282    /// `DeviceLog::resume` — the permanent chain fork. Use
283    /// [`OplogJournal::load_with_marker`] + the named checkpoint +
284    /// [`crate::checkpoint::resume_anchored`] instead.
285    pub fn load(path: &Path) -> std::io::Result<Vec<OpRecord>> {
286        let (marker, ops) = Self::load_with_marker(path)?;
287        if let Some(marker) = marker {
288            return Err(std::io::Error::new(
289                std::io::ErrorKind::InvalidData,
290                format!(
291                    "journal {} was truncated below checkpoint {} — its ops are a retained \
292                     tail, not the full log; load_with_marker + resume_anchored required",
293                    path.display(),
294                    marker.checkpoint_hash
295                ),
296            ));
297        }
298        Ok(ops)
299    }
300
301    /// Load a journal that may have been truncated: the
302    /// [`TruncationMarker`] (if any) plus every parseable op, in file
303    /// order. Blank and unparseable (torn) lines are skipped; a missing
304    /// file is `(None, [])`. When the marker is `Some`, the ops are a
305    /// retained tail — anchor them on the named checkpoint
306    /// ([`crate::checkpoint::verify_anchored`]) and resume via
307    /// [`crate::checkpoint::resume_anchored`], never `DeviceLog::resume`.
308    pub fn load_with_marker(
309        path: &Path,
310    ) -> std::io::Result<(Option<TruncationMarker>, Vec<OpRecord>)> {
311        let file = match File::open(path) {
312            Ok(f) => f,
313            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((None, Vec::new())),
314            Err(e) => return Err(e),
315        };
316        let reader = BufReader::new(file);
317        let mut marker = None;
318        let mut ops = Vec::new();
319        for line in reader.lines() {
320            let line = line?;
321            let line = line.trim();
322            if line.is_empty() {
323                continue;
324            }
325            if let Ok(op) = serde_json::from_str::<OpRecord>(line) {
326                ops.push(op);
327            } else if let Ok(found) = serde_json::from_str::<MarkerLine>(line) {
328                marker.get_or_insert(found.truncation_marker);
329            }
330        }
331        Ok((marker, ops))
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::fold::{fold, state_hash};
339    use crate::oplog::{verify_log, DeviceLog, Scope, Surface};
340    use serde_json::json;
341
342    fn sample_ops(n: usize) -> Vec<OpRecord> {
343        let mut log = DeviceLog::new("d1");
344        (0..n)
345            .map(|i| {
346                log.append(
347                    Scope::Personal,
348                    Surface::Knowledge,
349                    json!({"id": format!("f{i}"), "n": i}),
350                )
351            })
352            .collect()
353    }
354
355    #[test]
356    fn append_then_load_round_trips() {
357        let dir = tempfile::tempdir().unwrap();
358        let path = dir.path().join("nested").join("oplog.jsonl");
359        let ops = sample_ops(3);
360        {
361            let mut journal = OplogJournal::open(&path).unwrap();
362            for op in &ops {
363                journal.append(op).unwrap();
364            }
365        }
366        let loaded = OplogJournal::load(&path).unwrap();
367        assert_eq!(loaded, ops);
368        verify_log(&loaded).unwrap();
369    }
370
371    #[test]
372    fn reopen_appends_without_truncating() {
373        let dir = tempfile::tempdir().unwrap();
374        let path = dir.path().join("oplog.jsonl");
375        let ops = sample_ops(4);
376        {
377            let mut journal = OplogJournal::open(&path).unwrap();
378            journal.append(&ops[0]).unwrap();
379            journal.append(&ops[1]).unwrap();
380        }
381        {
382            let mut journal = OplogJournal::open(&path).unwrap();
383            journal.append(&ops[2]).unwrap();
384            journal.append(&ops[3]).unwrap();
385        }
386        assert_eq!(OplogJournal::load(&path).unwrap(), ops);
387    }
388
389    #[test]
390    fn torn_tail_and_blank_lines_are_tolerated() {
391        let dir = tempfile::tempdir().unwrap();
392        let path = dir.path().join("oplog.jsonl");
393        let ops = sample_ops(3);
394        {
395            let mut journal = OplogJournal::open(&path).unwrap();
396            for op in &ops {
397                journal.append(op).unwrap();
398            }
399        }
400        // Simulate a crash mid-append: a torn, unterminated final line,
401        // plus a stray blank line.
402        let mut raw = fs::read_to_string(&path).unwrap();
403        raw.push('\n');
404        raw.push_str(r#"{"op_id":"op-torn","hlc":{"wall_ms":9,"count"#);
405        fs::write(&path, raw).unwrap();
406
407        let loaded = OplogJournal::load(&path).unwrap();
408        assert_eq!(loaded, ops, "torn tail is skipped, intact records survive");
409        verify_log(&loaded).unwrap();
410        assert_eq!(state_hash(&fold(&loaded)), state_hash(&fold(&ops)));
411
412        // The device resumes its chain from the loaded log and re-appends
413        // the lost write — convergence is over the op-set, nothing breaks.
414        let mut resumed = DeviceLog::resume("d1", &loaded).unwrap();
415        let recovered = resumed.append(Scope::Personal, Surface::Knowledge, json!({"id": "f-re"}));
416        {
417            let mut journal = OplogJournal::open(&path).unwrap();
418            journal.append(&recovered).unwrap();
419        }
420        let reloaded = OplogJournal::load(&path).unwrap();
421        assert_eq!(reloaded.len(), 4);
422        verify_log(&reloaded).unwrap();
423    }
424
425    #[test]
426    fn second_writer_on_same_path_is_rejected_until_first_drops() {
427        let dir = tempfile::tempdir().unwrap();
428        let path = dir.path().join("oplog.jsonl");
429        let first = OplogJournal::open(&path).unwrap();
430        let second = OplogJournal::open(&path);
431        assert!(second.is_err(), "advisory lock must reject a second writer");
432        assert_eq!(second.unwrap_err().kind(), std::io::ErrorKind::WouldBlock);
433        drop(first);
434        // Lock released on drop — reopening succeeds.
435        OplogJournal::open(&path).unwrap();
436    }
437
438    #[test]
439    fn truncate_to_rewrites_atomically_marks_and_fences_resume() {
440        use crate::checkpoint::{resume_anchored, Checkpoint};
441
442        let dir = tempfile::tempdir().unwrap();
443        let path = dir.path().join("oplog.jsonl");
444        let ops = sample_ops(5);
445        let mut journal = OplogJournal::open(&path).unwrap();
446        for op in &ops {
447            journal.append(op).unwrap();
448        }
449        // Truncate to the tail (ops 3..5) while the journal stays open,
450        // stamping the covering checkpoint's address into the marker.
451        let ckpt = Checkpoint::from_ops(&ops[..3]).unwrap();
452        journal.truncate_to(&ops[3..], &ckpt.checkpoint_hash).unwrap();
453        assert!(!dir.path().join("oplog.jsonl.compact.tmp").exists(), "no temp residue");
454
455        // The fences (kernel-review item): a truncated journal is a RUNTIME
456        // error on the naive path, not a documentation footnote.
457        let err = OplogJournal::load(&path).unwrap_err();
458        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
459        let (marker, tail) = OplogJournal::load_with_marker(&path).unwrap();
460        assert_eq!(marker.unwrap().checkpoint_hash, ckpt.checkpoint_hash);
461        assert_eq!(tail, ops[3..].to_vec());
462        // …and DeviceLog::resume refuses the tail even if handed the ops
463        // directly (own chain starts past seq 0).
464        assert!(matches!(
465            DeviceLog::resume("d1", &tail),
466            Err(crate::oplog::ChainError::TruncatedChain { first_seq: 3, .. })
467        ));
468
469        // The sanctioned path: resume anchored on the checkpoint. Appends
470        // after truncation land in the NEW file (writer reopened) and the
471        // marker survives them.
472        let mut resumed = resume_anchored("d1", &ckpt, &tail).unwrap();
473        let next = resumed.append(Scope::Personal, Surface::Knowledge, json!({"id": "f-post"}));
474        journal.append(&next).unwrap();
475        let (marker, reloaded) = OplogJournal::load_with_marker(&path).unwrap();
476        assert!(marker.is_some(), "marker survives post-truncation appends");
477        assert_eq!(reloaded.len(), 3);
478        assert_eq!(reloaded[2], next);
479        verify_log(&reloaded).expect("truncated chain + new append verifies (non-zero seq start)");
480    }
481
482    #[test]
483    fn missing_file_loads_empty() {
484        let dir = tempfile::tempdir().unwrap();
485        let loaded = OplogJournal::load(&dir.path().join("absent.jsonl")).unwrap();
486        assert!(loaded.is_empty());
487    }
488
489    #[test]
490    fn interleaved_multi_device_appends_fold_identically_to_memory() {
491        let dir = tempfile::tempdir().unwrap();
492        let path = dir.path().join("oplog.jsonl");
493        let mut a = DeviceLog::new("a");
494        let mut b = DeviceLog::new("b");
495        let mut journal = OplogJournal::open(&path).unwrap();
496        let mut ops = Vec::new();
497        for i in 0..3 {
498            let oa = a.append(Scope::Personal, Surface::Knowledge, json!({"id": format!("a{i}")}));
499            b.observe(&oa.hlc);
500            let ob = b.append(
501                Scope::Personal,
502                Surface::Declagent,
503                json!({"id": "shared", "turn": i}),
504            );
505            a.observe(&ob.hlc);
506            journal.append(&oa).unwrap();
507            journal.append(&ob).unwrap();
508            ops.push(oa);
509            ops.push(ob);
510        }
511        let loaded = OplogJournal::load(&path).unwrap();
512        verify_log(&loaded).unwrap();
513        assert_eq!(fold(&loaded), fold(&ops));
514        // The LWW registry converged to the last turn.
515        let state = fold(&loaded);
516        assert_eq!(
517            state.registries[&Surface::Declagent.tag()]["id:shared"].payload["turn"],
518            json!(2)
519        );
520    }
521}