use crate::oplog::OpRecord;
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions, TryLockError};
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TruncationMarker {
pub checkpoint_hash: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct MarkerLine {
truncation_marker: TruncationMarker,
}
#[derive(Debug)]
pub struct OplogJournal {
path: PathBuf,
writer: BufWriter<File>,
_lock: File,
#[cfg(test)]
pub(crate) fail_append_after: Option<usize>,
}
impl OplogJournal {
pub fn open(path: &Path) -> std::io::Result<Self> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)?;
}
}
let lock_path = {
let mut s = path.as_os_str().to_owned();
s.push(".lock");
PathBuf::from(s)
};
let lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)?;
match lock.try_lock() {
Ok(()) => {}
Err(TryLockError::WouldBlock) => {
return Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
format!(
"oplog journal already open by another writer (advisory lock held on {})",
lock_path.display()
),
));
}
Err(TryLockError::Error(e)) => return Err(e),
}
let needs_newline = match File::open(path) {
Ok(mut existing) => {
use std::io::{Read, Seek, SeekFrom};
if existing.metadata()?.len() == 0 {
false
} else {
existing.seek(SeekFrom::End(-1))?;
let mut last = [0u8; 1];
existing.read_exact(&mut last)?;
last[0] != b'\n'
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => return Err(e),
};
let file = OpenOptions::new().create(true).append(true).open(path)?;
let mut writer = BufWriter::new(file);
if needs_newline {
writer.write_all(b"\n")?;
writer.flush()?;
}
Ok(Self {
path: path.to_path_buf(),
writer,
_lock: lock,
#[cfg(test)]
fail_append_after: None,
})
}
pub fn append(&mut self, op: &OpRecord) -> std::io::Result<()> {
#[cfg(test)]
if let Some(n) = self.fail_append_after {
if n == 0 {
self.fail_append_after = None;
self.reopen_writer()?; return Err(std::io::Error::other("injected append failure"));
}
self.fail_append_after = Some(n - 1);
}
let line = serde_json::to_string(op).map_err(std::io::Error::other)?;
match self
.writer
.write_all(line.as_bytes())
.and_then(|()| self.writer.write_all(b"\n"))
.and_then(|()| self.writer.flush())
{
Ok(()) => Ok(()),
Err(e) => {
match self.reopen_writer() {
Ok(()) => Err(e),
Err(reopen_err) => Err(reopen_err),
}
}
}
}
pub fn sync(&mut self) -> std::io::Result<()> {
self.writer.flush()?;
self.writer.get_ref().sync_all()
}
fn reopen_writer(&mut self) -> std::io::Result<()> {
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
self.writer = BufWriter::new(file);
Ok(())
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn truncate_to(&mut self, ops: &[OpRecord], checkpoint_hash: &str) -> std::io::Result<()> {
let tmp_path = {
let mut s = self.path.as_os_str().to_owned();
s.push(".compact.tmp");
PathBuf::from(s)
};
{
let mut tmp = BufWriter::new(File::create(&tmp_path)?);
let marker = serde_json::to_string(&MarkerLine {
truncation_marker: TruncationMarker {
checkpoint_hash: checkpoint_hash.to_string(),
},
})
.map_err(std::io::Error::other)?;
tmp.write_all(marker.as_bytes())?;
tmp.write_all(b"\n")?;
for op in ops {
let line = serde_json::to_string(op).map_err(std::io::Error::other)?;
tmp.write_all(line.as_bytes())?;
tmp.write_all(b"\n")?;
}
tmp.flush()?;
tmp.get_ref().sync_all()?;
}
fs::rename(&tmp_path, &self.path)?;
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
self.writer = BufWriter::new(file);
#[cfg(unix)]
if let Some(parent) = self.path.parent() {
if !parent.as_os_str().is_empty() {
let _ = File::open(parent).and_then(|d| d.sync_all());
}
}
Ok(())
}
pub fn load(path: &Path) -> std::io::Result<Vec<OpRecord>> {
let (marker, ops) = Self::load_with_marker(path)?;
if let Some(marker) = marker {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"journal {} was truncated below checkpoint {} — its ops are a retained \
tail, not the full log; load_with_marker + resume_anchored required",
path.display(),
marker.checkpoint_hash
),
));
}
Ok(ops)
}
pub fn load_with_marker(
path: &Path,
) -> std::io::Result<(Option<TruncationMarker>, Vec<OpRecord>)> {
let file = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((None, Vec::new())),
Err(e) => return Err(e),
};
let reader = BufReader::new(file);
let mut marker = None;
let mut ops = Vec::new();
for line in reader.lines() {
let line = line?;
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(op) = serde_json::from_str::<OpRecord>(line) {
ops.push(op);
} else if let Ok(found) = serde_json::from_str::<MarkerLine>(line) {
marker.get_or_insert(found.truncation_marker);
}
}
Ok((marker, ops))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fold::{fold, state_hash};
use crate::oplog::{verify_log, DeviceLog, Scope, Surface};
use serde_json::json;
fn sample_ops(n: usize) -> Vec<OpRecord> {
let mut log = DeviceLog::new("d1");
(0..n)
.map(|i| {
log.append(
Scope::Personal,
Surface::Knowledge,
json!({"id": format!("f{i}"), "n": i}),
)
})
.collect()
}
#[test]
fn append_then_load_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("oplog.jsonl");
let ops = sample_ops(3);
{
let mut journal = OplogJournal::open(&path).unwrap();
for op in &ops {
journal.append(op).unwrap();
}
}
let loaded = OplogJournal::load(&path).unwrap();
assert_eq!(loaded, ops);
verify_log(&loaded).unwrap();
}
#[test]
fn reopen_appends_without_truncating() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("oplog.jsonl");
let ops = sample_ops(4);
{
let mut journal = OplogJournal::open(&path).unwrap();
journal.append(&ops[0]).unwrap();
journal.append(&ops[1]).unwrap();
}
{
let mut journal = OplogJournal::open(&path).unwrap();
journal.append(&ops[2]).unwrap();
journal.append(&ops[3]).unwrap();
}
assert_eq!(OplogJournal::load(&path).unwrap(), ops);
}
#[test]
fn torn_tail_and_blank_lines_are_tolerated() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("oplog.jsonl");
let ops = sample_ops(3);
{
let mut journal = OplogJournal::open(&path).unwrap();
for op in &ops {
journal.append(op).unwrap();
}
}
let mut raw = fs::read_to_string(&path).unwrap();
raw.push('\n');
raw.push_str(r#"{"op_id":"op-torn","hlc":{"wall_ms":9,"count"#);
fs::write(&path, raw).unwrap();
let loaded = OplogJournal::load(&path).unwrap();
assert_eq!(loaded, ops, "torn tail is skipped, intact records survive");
verify_log(&loaded).unwrap();
assert_eq!(state_hash(&fold(&loaded)), state_hash(&fold(&ops)));
let mut resumed = DeviceLog::resume("d1", &loaded).unwrap();
let recovered = resumed.append(Scope::Personal, Surface::Knowledge, json!({"id": "f-re"}));
{
let mut journal = OplogJournal::open(&path).unwrap();
journal.append(&recovered).unwrap();
}
let reloaded = OplogJournal::load(&path).unwrap();
assert_eq!(reloaded.len(), 4);
verify_log(&reloaded).unwrap();
}
#[test]
fn second_writer_on_same_path_is_rejected_until_first_drops() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("oplog.jsonl");
let first = OplogJournal::open(&path).unwrap();
let second = OplogJournal::open(&path);
assert!(second.is_err(), "advisory lock must reject a second writer");
assert_eq!(second.unwrap_err().kind(), std::io::ErrorKind::WouldBlock);
drop(first);
OplogJournal::open(&path).unwrap();
}
#[test]
fn truncate_to_rewrites_atomically_marks_and_fences_resume() {
use crate::checkpoint::{resume_anchored, Checkpoint};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("oplog.jsonl");
let ops = sample_ops(5);
let mut journal = OplogJournal::open(&path).unwrap();
for op in &ops {
journal.append(op).unwrap();
}
let ckpt = Checkpoint::from_ops(&ops[..3]).unwrap();
journal
.truncate_to(&ops[3..], &ckpt.checkpoint_hash)
.unwrap();
assert!(
!dir.path().join("oplog.jsonl.compact.tmp").exists(),
"no temp residue"
);
let err = OplogJournal::load(&path).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
let (marker, tail) = OplogJournal::load_with_marker(&path).unwrap();
assert_eq!(marker.unwrap().checkpoint_hash, ckpt.checkpoint_hash);
assert_eq!(tail, ops[3..].to_vec());
assert!(matches!(
DeviceLog::resume("d1", &tail),
Err(crate::oplog::ChainError::TruncatedChain { first_seq: 3, .. })
));
let mut resumed = resume_anchored("d1", &ckpt, &tail).unwrap();
let next = resumed.append(Scope::Personal, Surface::Knowledge, json!({"id": "f-post"}));
journal.append(&next).unwrap();
let (marker, reloaded) = OplogJournal::load_with_marker(&path).unwrap();
assert!(marker.is_some(), "marker survives post-truncation appends");
assert_eq!(reloaded.len(), 3);
assert_eq!(reloaded[2], next);
verify_log(&reloaded).expect("truncated chain + new append verifies (non-zero seq start)");
}
#[test]
fn missing_file_loads_empty() {
let dir = tempfile::tempdir().unwrap();
let loaded = OplogJournal::load(&dir.path().join("absent.jsonl")).unwrap();
assert!(loaded.is_empty());
}
#[test]
fn interleaved_multi_device_appends_fold_identically_to_memory() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("oplog.jsonl");
let mut a = DeviceLog::new("a");
let mut b = DeviceLog::new("b");
let mut journal = OplogJournal::open(&path).unwrap();
let mut ops = Vec::new();
for i in 0..3 {
let oa = a.append(
Scope::Personal,
Surface::Knowledge,
json!({"id": format!("a{i}")}),
);
b.observe(&oa.hlc);
let ob = b.append(
Scope::Personal,
Surface::Declagent,
json!({"id": "shared", "turn": i}),
);
a.observe(&ob.hlc);
journal.append(&oa).unwrap();
journal.append(&ob).unwrap();
ops.push(oa);
ops.push(ob);
}
let loaded = OplogJournal::load(&path).unwrap();
verify_log(&loaded).unwrap();
assert_eq!(fold(&loaded), fold(&ops));
let state = fold(&loaded);
assert_eq!(
state.registries[&Surface::Declagent.tag()]["id:shared"].payload["turn"],
json!(2)
);
}
}