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