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}