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}