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}