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()
211            .create(true)
212            .append(true)
213            .open(&self.path)?;
214        self.writer = BufWriter::new(file);
215        Ok(())
216    }
217
218    pub fn path(&self) -> &Path {
219        &self.path
220    }
221
222    /// Rewrite the journal to exactly `ops`, stamped with a
223    /// [`TruncationMarker`] naming `checkpoint_hash` as its first line —
224    /// B4's truncation-below-a-frontier step, executed under the advisory
225    /// lock this open journal already holds (no second writer can
226    /// interleave). The marker travels IN the rewritten file, so it is
227    /// atomic with the truncation: there is no crash window in which the
228    /// journal is truncated but unmarked (or marked but untruncated).
229    ///
230    /// Durability: the retained ops are written to a sibling temp file,
231    /// fsync'd, and atomically renamed over the journal, so a crash at any
232    /// point leaves either the old complete journal or the new complete
233    /// tail — never a partial rewrite. **Crash-ordering invariant (callers
234    /// MUST honor it; [`crate::compact::compact_and_truncate`] does by
235    /// construction): the covering checkpoint is durable BEFORE this runs.**
236    /// Truncation makes the dropped ops unrecoverable from the journal; the
237    /// checkpoint named by the marker is what still accounts for them.
238    pub fn truncate_to(&mut self, ops: &[OpRecord], checkpoint_hash: &str) -> std::io::Result<()> {
239        let tmp_path = {
240            let mut s = self.path.as_os_str().to_owned();
241            s.push(".compact.tmp");
242            PathBuf::from(s)
243        };
244        {
245            let mut tmp = BufWriter::new(File::create(&tmp_path)?);
246            let marker = serde_json::to_string(&MarkerLine {
247                truncation_marker: TruncationMarker {
248                    checkpoint_hash: checkpoint_hash.to_string(),
249                },
250            })
251            .map_err(std::io::Error::other)?;
252            tmp.write_all(marker.as_bytes())?;
253            tmp.write_all(b"\n")?;
254            for op in ops {
255                let line = serde_json::to_string(op).map_err(std::io::Error::other)?;
256                tmp.write_all(line.as_bytes())?;
257                tmp.write_all(b"\n")?;
258            }
259            tmp.flush()?;
260            tmp.get_ref().sync_all()?;
261        }
262        fs::rename(&tmp_path, &self.path)?;
263        // The old writer handle points at the renamed-over inode; reopen on
264        // the new file so subsequent appends land in the truncated journal.
265        let file = OpenOptions::new()
266            .create(true)
267            .append(true)
268            .open(&self.path)?;
269        self.writer = BufWriter::new(file);
270        // Make the rename durable where the platform allows it.
271        #[cfg(unix)]
272        if let Some(parent) = self.path.parent() {
273            if !parent.as_os_str().is_empty() {
274                let _ = File::open(parent).and_then(|d| d.sync_all());
275            }
276        }
277        Ok(())
278    }
279
280    /// Load a **full** (never-truncated) journal: every parseable op, in
281    /// file order. Blank and unparseable (torn) lines are skipped; a
282    /// missing file is an empty log (a fresh device bootstraps from
283    /// nothing).
284    ///
285    /// A journal carrying a [`TruncationMarker`] is **refused** with a
286    /// runtime error: its ops are only the retained tail, and treating them
287    /// as the whole log silently re-mints truncated seqs on
288    /// `DeviceLog::resume` — the permanent chain fork. Use
289    /// [`OplogJournal::load_with_marker`] + the named checkpoint +
290    /// [`crate::checkpoint::resume_anchored`] instead.
291    pub fn load(path: &Path) -> std::io::Result<Vec<OpRecord>> {
292        let (marker, ops) = Self::load_with_marker(path)?;
293        if let Some(marker) = marker {
294            return Err(std::io::Error::new(
295                std::io::ErrorKind::InvalidData,
296                format!(
297                    "journal {} was truncated below checkpoint {} — its ops are a retained \
298                     tail, not the full log; load_with_marker + resume_anchored required",
299                    path.display(),
300                    marker.checkpoint_hash
301                ),
302            ));
303        }
304        Ok(ops)
305    }
306
307    /// Load a journal that may have been truncated: the
308    /// [`TruncationMarker`] (if any) plus every parseable op, in file
309    /// order. Blank and unparseable (torn) lines are skipped; a missing
310    /// file is `(None, [])`. When the marker is `Some`, the ops are a
311    /// retained tail — anchor them on the named checkpoint
312    /// ([`crate::checkpoint::verify_anchored`]) and resume via
313    /// [`crate::checkpoint::resume_anchored`], never `DeviceLog::resume`.
314    pub fn load_with_marker(
315        path: &Path,
316    ) -> std::io::Result<(Option<TruncationMarker>, Vec<OpRecord>)> {
317        let file = match File::open(path) {
318            Ok(f) => f,
319            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((None, Vec::new())),
320            Err(e) => return Err(e),
321        };
322        let reader = BufReader::new(file);
323        let mut marker = None;
324        let mut ops = Vec::new();
325        for line in reader.lines() {
326            let line = line?;
327            let line = line.trim();
328            if line.is_empty() {
329                continue;
330            }
331            if let Ok(op) = serde_json::from_str::<OpRecord>(line) {
332                ops.push(op);
333            } else if let Ok(found) = serde_json::from_str::<MarkerLine>(line) {
334                marker.get_or_insert(found.truncation_marker);
335            }
336        }
337        Ok((marker, ops))
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::fold::{fold, state_hash};
345    use crate::oplog::{verify_log, DeviceLog, Scope, Surface};
346    use serde_json::json;
347
348    fn sample_ops(n: usize) -> Vec<OpRecord> {
349        let mut log = DeviceLog::new("d1");
350        (0..n)
351            .map(|i| {
352                log.append(
353                    Scope::Personal,
354                    Surface::Knowledge,
355                    json!({"id": format!("f{i}"), "n": i}),
356                )
357            })
358            .collect()
359    }
360
361    #[test]
362    fn append_then_load_round_trips() {
363        let dir = tempfile::tempdir().unwrap();
364        let path = dir.path().join("nested").join("oplog.jsonl");
365        let ops = sample_ops(3);
366        {
367            let mut journal = OplogJournal::open(&path).unwrap();
368            for op in &ops {
369                journal.append(op).unwrap();
370            }
371        }
372        let loaded = OplogJournal::load(&path).unwrap();
373        assert_eq!(loaded, ops);
374        verify_log(&loaded).unwrap();
375    }
376
377    #[test]
378    fn reopen_appends_without_truncating() {
379        let dir = tempfile::tempdir().unwrap();
380        let path = dir.path().join("oplog.jsonl");
381        let ops = sample_ops(4);
382        {
383            let mut journal = OplogJournal::open(&path).unwrap();
384            journal.append(&ops[0]).unwrap();
385            journal.append(&ops[1]).unwrap();
386        }
387        {
388            let mut journal = OplogJournal::open(&path).unwrap();
389            journal.append(&ops[2]).unwrap();
390            journal.append(&ops[3]).unwrap();
391        }
392        assert_eq!(OplogJournal::load(&path).unwrap(), ops);
393    }
394
395    #[test]
396    fn torn_tail_and_blank_lines_are_tolerated() {
397        let dir = tempfile::tempdir().unwrap();
398        let path = dir.path().join("oplog.jsonl");
399        let ops = sample_ops(3);
400        {
401            let mut journal = OplogJournal::open(&path).unwrap();
402            for op in &ops {
403                journal.append(op).unwrap();
404            }
405        }
406        // Simulate a crash mid-append: a torn, unterminated final line,
407        // plus a stray blank line.
408        let mut raw = fs::read_to_string(&path).unwrap();
409        raw.push('\n');
410        raw.push_str(r#"{"op_id":"op-torn","hlc":{"wall_ms":9,"count"#);
411        fs::write(&path, raw).unwrap();
412
413        let loaded = OplogJournal::load(&path).unwrap();
414        assert_eq!(loaded, ops, "torn tail is skipped, intact records survive");
415        verify_log(&loaded).unwrap();
416        assert_eq!(state_hash(&fold(&loaded)), state_hash(&fold(&ops)));
417
418        // The device resumes its chain from the loaded log and re-appends
419        // the lost write — convergence is over the op-set, nothing breaks.
420        let mut resumed = DeviceLog::resume("d1", &loaded).unwrap();
421        let recovered = resumed.append(Scope::Personal, Surface::Knowledge, json!({"id": "f-re"}));
422        {
423            let mut journal = OplogJournal::open(&path).unwrap();
424            journal.append(&recovered).unwrap();
425        }
426        let reloaded = OplogJournal::load(&path).unwrap();
427        assert_eq!(reloaded.len(), 4);
428        verify_log(&reloaded).unwrap();
429    }
430
431    #[test]
432    fn second_writer_on_same_path_is_rejected_until_first_drops() {
433        let dir = tempfile::tempdir().unwrap();
434        let path = dir.path().join("oplog.jsonl");
435        let first = OplogJournal::open(&path).unwrap();
436        let second = OplogJournal::open(&path);
437        assert!(second.is_err(), "advisory lock must reject a second writer");
438        assert_eq!(second.unwrap_err().kind(), std::io::ErrorKind::WouldBlock);
439        drop(first);
440        // Lock released on drop — reopening succeeds.
441        OplogJournal::open(&path).unwrap();
442    }
443
444    #[test]
445    fn truncate_to_rewrites_atomically_marks_and_fences_resume() {
446        use crate::checkpoint::{resume_anchored, Checkpoint};
447
448        let dir = tempfile::tempdir().unwrap();
449        let path = dir.path().join("oplog.jsonl");
450        let ops = sample_ops(5);
451        let mut journal = OplogJournal::open(&path).unwrap();
452        for op in &ops {
453            journal.append(op).unwrap();
454        }
455        // Truncate to the tail (ops 3..5) while the journal stays open,
456        // stamping the covering checkpoint's address into the marker.
457        let ckpt = Checkpoint::from_ops(&ops[..3]).unwrap();
458        journal
459            .truncate_to(&ops[3..], &ckpt.checkpoint_hash)
460            .unwrap();
461        assert!(
462            !dir.path().join("oplog.jsonl.compact.tmp").exists(),
463            "no temp residue"
464        );
465
466        // The fences (kernel-review item): a truncated journal is a RUNTIME
467        // error on the naive path, not a documentation footnote.
468        let err = OplogJournal::load(&path).unwrap_err();
469        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
470        let (marker, tail) = OplogJournal::load_with_marker(&path).unwrap();
471        assert_eq!(marker.unwrap().checkpoint_hash, ckpt.checkpoint_hash);
472        assert_eq!(tail, ops[3..].to_vec());
473        // …and DeviceLog::resume refuses the tail even if handed the ops
474        // directly (own chain starts past seq 0).
475        assert!(matches!(
476            DeviceLog::resume("d1", &tail),
477            Err(crate::oplog::ChainError::TruncatedChain { first_seq: 3, .. })
478        ));
479
480        // The sanctioned path: resume anchored on the checkpoint. Appends
481        // after truncation land in the NEW file (writer reopened) and the
482        // marker survives them.
483        let mut resumed = resume_anchored("d1", &ckpt, &tail).unwrap();
484        let next = resumed.append(Scope::Personal, Surface::Knowledge, json!({"id": "f-post"}));
485        journal.append(&next).unwrap();
486        let (marker, reloaded) = OplogJournal::load_with_marker(&path).unwrap();
487        assert!(marker.is_some(), "marker survives post-truncation appends");
488        assert_eq!(reloaded.len(), 3);
489        assert_eq!(reloaded[2], next);
490        verify_log(&reloaded).expect("truncated chain + new append verifies (non-zero seq start)");
491    }
492
493    #[test]
494    fn missing_file_loads_empty() {
495        let dir = tempfile::tempdir().unwrap();
496        let loaded = OplogJournal::load(&dir.path().join("absent.jsonl")).unwrap();
497        assert!(loaded.is_empty());
498    }
499
500    #[test]
501    fn interleaved_multi_device_appends_fold_identically_to_memory() {
502        let dir = tempfile::tempdir().unwrap();
503        let path = dir.path().join("oplog.jsonl");
504        let mut a = DeviceLog::new("a");
505        let mut b = DeviceLog::new("b");
506        let mut journal = OplogJournal::open(&path).unwrap();
507        let mut ops = Vec::new();
508        for i in 0..3 {
509            let oa = a.append(
510                Scope::Personal,
511                Surface::Knowledge,
512                json!({"id": format!("a{i}")}),
513            );
514            b.observe(&oa.hlc);
515            let ob = b.append(
516                Scope::Personal,
517                Surface::Declagent,
518                json!({"id": "shared", "turn": i}),
519            );
520            a.observe(&ob.hlc);
521            journal.append(&oa).unwrap();
522            journal.append(&ob).unwrap();
523            ops.push(oa);
524            ops.push(ob);
525        }
526        let loaded = OplogJournal::load(&path).unwrap();
527        verify_log(&loaded).unwrap();
528        assert_eq!(fold(&loaded), fold(&ops));
529        // The LWW registry converged to the last turn.
530        let state = fold(&loaded);
531        assert_eq!(
532            state.registries[&Surface::Declagent.tag()]["id:shared"].payload["turn"],
533            json!(2)
534        );
535    }
536}