use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::atomic::{open_events_append, write_atomic};
use crate::error::{Error, Result};
use crate::lock::{LockedRun, RunLock};
use crate::paths::RunPaths;
use crate::projections::{derive_counters, read_manifest_opt, write_manifest};
use crate::reducer::{commit_ops, reduce_event_to_ops};
use crate::schema::{Event, NodeId};
const SCAN_CHUNK: u64 = 64 * 1024;
pub fn recover_last_seq(events_path: &Path) -> Result<u64> {
let mut f = match std::fs::File::open(events_path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(Error::io(events_path, e)),
};
let len = f.metadata().map_err(|e| Error::io(events_path, e))?.len();
if len == 0 {
return Ok(0);
}
let mut tail_byte = [0u8; 1];
f.seek(SeekFrom::End(-1))
.map_err(|e| Error::io(events_path, e))?;
f.read_exact(&mut tail_byte)
.map_err(|e| Error::io(events_path, e))?;
let mut end = if tail_byte[0] == b'\n' {
len - 1
} else {
match find_prev_newline(&mut f, len, events_path)? {
Some(p) => p,
None => return Ok(0),
}
};
loop {
let line_start = match find_prev_newline(&mut f, end, events_path)? {
Some(p) => p + 1,
None => 0,
};
let line_len = end - line_start;
f.seek(SeekFrom::Start(line_start))
.map_err(|e| Error::io(events_path, e))?;
let mut line = vec![0u8; line_len as usize];
f.read_exact(&mut line)
.map_err(|e| Error::io(events_path, e))?;
if line.iter().any(|b| !b.is_ascii_whitespace()) {
return parse_seq(&line, events_path);
}
if line_start == 0 {
return Ok(0);
}
end = line_start - 1;
}
}
#[derive(Deserialize)]
#[allow(dead_code)] struct SeqLine {
seq: u64,
ts: chrono::DateTime<chrono::Utc>,
kind: String,
run_id: crate::schema::RunId,
#[serde(default)]
node_id: Option<NodeId>,
}
fn parse_seq(line: &[u8], events_path: &Path) -> Result<u64> {
let hdr: SeqLine = serde_json::from_slice(line).map_err(|e| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"last complete line is not a valid event: {} [{e}]",
excerpt(line)
),
})?;
Ok(hdr.seq)
}
fn find_prev_newline(
f: &mut std::fs::File,
before: u64,
events_path: &Path,
) -> Result<Option<u64>> {
if before == 0 {
return Ok(None);
}
let mut pos = before;
loop {
let start = pos.saturating_sub(SCAN_CHUNK);
let len = pos - start;
f.seek(SeekFrom::Start(start))
.map_err(|e| Error::io(events_path, e))?;
let mut buf = vec![0u8; len as usize];
f.read_exact(&mut buf)
.map_err(|e| Error::io(events_path, e))?;
if let Some(i) = buf.iter().rposition(|b| *b == b'\n') {
return Ok(Some(start + i as u64));
}
if start == 0 {
return Ok(None);
}
pos = start;
}
}
fn truncate_torn_tail(events_path: &Path) -> Result<()> {
let mut opts = std::fs::OpenOptions::new();
opts.read(true).write(true);
crate::paths::nofollow(&mut opts);
let mut f = match opts.open(events_path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(Error::io(events_path, e)),
};
let len = f.metadata().map_err(|e| Error::io(events_path, e))?.len();
if len == 0 {
return Ok(());
}
let mut tail = [0u8; 1];
f.seek(SeekFrom::End(-1))
.map_err(|e| Error::io(events_path, e))?;
f.read_exact(&mut tail)
.map_err(|e| Error::io(events_path, e))?;
if tail[0] == b'\n' {
return Ok(());
}
let keep = match find_prev_newline(&mut f, len, events_path)? {
Some(nl) => nl + 1,
None => 0,
};
f.set_len(keep).map_err(|e| Error::io(events_path, e))?;
f.sync_all().map_err(|e| Error::io(events_path, e))?;
tracing::warn!(
target: "octl_core::events",
path = %events_path.display(),
discarded_bytes = len - keep,
kept_bytes = keep,
"truncated crash-torn final line off events.jsonl before append"
);
Ok(())
}
#[cfg(test)]
pub(crate) fn append_event_with_seq(
_witness: &LockedRun<'_>,
paths: &RunPaths,
seq: u64,
kind: &str,
node_id: Option<&NodeId>,
idempotency_key: Option<&str>,
data: Value,
) -> Result<()> {
write_event_line(paths, seq, kind, node_id, idempotency_key, data)
}
#[cfg(test)]
fn write_event_line(
paths: &RunPaths,
seq: u64,
kind: &str,
node_id: Option<&NodeId>,
idempotency_key: Option<&str>,
data: Value,
) -> Result<()> {
let ev = Event {
ts: Utc::now(),
seq,
kind: kind.to_string(),
run_id: paths.run_id.clone(),
node_id: node_id.cloned(),
idempotency_key: idempotency_key.map(str::to_string),
data,
};
let events_path = paths.events();
let mut line = serde_json::to_vec(&ev).map_err(|e| Error::json(events_path.clone(), e))?;
line.push(b'\n');
let mut f = open_events_append(&events_path)?;
f.write_all(&line)
.map_err(|e| Error::io(events_path.clone(), e))?;
f.sync_all().map_err(|e| Error::io(events_path, e))?;
Ok(())
}
#[derive(Debug, Serialize)]
pub struct AppendResult {
pub seq: u64,
pub idempotent_replay: bool,
pub applied: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub prior: Option<PriorEvent>,
}
pub fn append_and_apply_event(
paths: &RunPaths,
kind: &str,
node_id: Option<&NodeId>,
idempotency_key: Option<&str>,
data: Value,
) -> Result<AppendResult> {
RunLock::with_lock(paths, |lock| {
let events_path = paths.checked_events()?;
truncate_torn_tail(&events_path)?;
replay_unapplied(paths, &events_path)?;
if let Some(key) = idempotency_key {
if let Some(prior) = find_prior_with_key(lock, paths, kind, key)? {
return Ok(AppendResult {
seq: prior.seq,
idempotent_replay: true,
applied: false,
prior: Some(prior),
});
}
}
let (seq, applied) =
append_and_apply_reporting(lock, paths, kind, node_id, idempotency_key, data)?;
Ok(AppendResult {
seq,
idempotent_replay: false,
applied,
prior: None,
})
})
}
pub fn append_and_apply_unlocked(
witness: &LockedRun<'_>,
paths: &RunPaths,
kind: &str,
node_id: Option<&NodeId>,
idempotency_key: Option<&str>,
data: Value,
) -> Result<u64> {
append_and_apply_reporting(witness, paths, kind, node_id, idempotency_key, data)
.map(|(seq, _)| seq)
}
fn append_and_apply_reporting(
_witness: &LockedRun<'_>,
paths: &RunPaths,
kind: &str,
node_id: Option<&NodeId>,
idempotency_key: Option<&str>,
data: Value,
) -> Result<(u64, bool)> {
let events_path = paths.checked_events()?;
truncate_torn_tail(&events_path)?;
replay_unapplied(paths, &events_path)?;
let last = recover_last_seq(&events_path)?;
let seq = last + 1;
let ev = Event {
ts: Utc::now(),
seq,
kind: kind.to_string(),
run_id: paths.run_id.clone(),
node_id: node_id.cloned(),
idempotency_key: idempotency_key.map(str::to_string),
data,
};
let ops = reduce_event_to_ops(paths, &ev)?;
let applied = !ops.is_empty();
let mut line = serde_json::to_vec(&ev).map_err(|e| Error::json(events_path.clone(), e))?;
line.push(b'\n');
let mut f = open_events_append(&events_path)?;
f.write_all(&line)
.map_err(|e| Error::io(events_path.clone(), e))?;
f.sync_all().map_err(|e| Error::io(events_path, e))?;
commit_ops(paths, ops)?;
advance_applied_seq(paths, seq)?;
Ok((seq, applied))
}
#[derive(Debug)]
pub enum AppendOutcome {
Appended {
seq: u64,
},
IdempotentReplay {
prior: PriorEvent,
},
Conflict {
prior: PriorEvent,
},
}
pub fn append_and_apply_idempotent<F>(
paths: &RunPaths,
witness: &LockedRun<'_>,
kind: &str,
node_id: Option<&NodeId>,
key: &str,
build: F,
) -> Result<AppendOutcome>
where
F: FnOnce(u64) -> Result<Value>,
{
if key.is_empty() {
return Err(Error::EmptyIdempotencyKey);
}
let events_path = paths.checked_events()?;
truncate_torn_tail(&events_path)?;
replay_unapplied(paths, &events_path)?;
let next_seq = recover_last_seq(&events_path)? + 1;
let data = build(next_seq)?;
if let Some(prior) = find_prior_with_key(witness, paths, kind, key)? {
let same_node = prior.node_id.as_deref() == node_id.map(NodeId::as_str);
if same_node && prior.data == data {
return Ok(AppendOutcome::IdempotentReplay { prior });
}
return Ok(AppendOutcome::Conflict { prior });
}
let seq = append_and_apply_unlocked(witness, paths, kind, node_id, Some(key), data)?;
Ok(AppendOutcome::Appended { seq })
}
fn replay_unapplied(paths: &RunPaths, events_path: &Path) -> Result<()> {
let applied = match read_manifest_opt(paths)? {
Some(m) => m.applied_seq,
None => return Ok(()),
};
if applied >= recover_last_seq(events_path)? {
return Ok(());
}
let f = match std::fs::File::open(events_path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(Error::io(events_path, e)),
};
let mut reader = PhysicalLineReader::new(BufReader::new(f));
while let Some(line) = reader.next_line().map_err(|e| Error::io(events_path, e))? {
if !line.complete {
break;
}
if line.content.is_empty() {
continue;
}
let ev: Event = match serde_json::from_slice(line.content) {
Ok(ev) => ev,
Err(_) => continue,
};
if ev.seq <= applied {
continue;
}
let ops = match reduce_event_to_ops(paths, &ev) {
Ok(ops) => ops,
Err(Error::CorruptEventLog { reason, .. }) => {
tracing::warn!(
target: "octl_core::events",
path = %events_path.display(),
seq = ev.seq,
kind = %ev.kind,
reason = %reason,
"skipping corrupt event during replay (unsafe id or malformed payload); projection not advanced for it"
);
continue;
}
Err(e) => return Err(e),
};
commit_ops(paths, ops)?;
advance_applied_seq(paths, ev.seq)?;
}
Ok(())
}
fn advance_applied_seq(paths: &RunPaths, seq: u64) -> Result<()> {
if let Some(mut m) = read_manifest_opt(paths)? {
if m.applied_seq < seq {
let counters = derive_counters(paths)?;
m.node_count = counters.node_count;
m.open_discussions = counters.open_discussions;
m.pending_spinoffs = counters.pending_spinoffs;
m.applied_seq = seq;
write_manifest(paths, &m)?;
}
}
Ok(())
}
struct PhysicalLine<'a> {
content: &'a [u8],
complete: bool,
lineno: u64,
}
struct PhysicalLineReader<R: BufRead> {
reader: R,
buf: Vec<u8>,
lineno: u64,
done: bool,
}
impl<R: BufRead> PhysicalLineReader<R> {
fn new(reader: R) -> Self {
Self {
reader,
buf: Vec::new(),
lineno: 0,
done: false,
}
}
fn next_line(&mut self) -> std::io::Result<Option<PhysicalLine<'_>>> {
if self.done {
return Ok(None);
}
self.buf.clear();
let n = self.reader.read_until(b'\n', &mut self.buf)?;
if n == 0 {
self.done = true;
return Ok(None);
}
self.lineno += 1;
let complete = self.buf.last() == Some(&b'\n');
if !complete {
self.done = true;
}
let len = trim_line_end(&self.buf).len();
Ok(Some(PhysicalLine {
content: &self.buf[..len],
complete,
lineno: self.lineno,
}))
}
}
pub(crate) fn for_each_event_probe<T, F>(events_path: &Path, mut visit: F) -> Result<()>
where
T: serde::de::DeserializeOwned,
F: FnMut(T, &[u8]) -> Result<()>,
{
let f = match std::fs::File::open(events_path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(Error::io(events_path, e)),
};
let mut reader = PhysicalLineReader::new(BufReader::new(f));
while let Some(line) = reader.next_line().map_err(|e| Error::io(events_path, e))? {
if !line.complete {
break;
}
if line.content.is_empty() {
continue;
}
let probe: T =
serde_json::from_slice(line.content).map_err(|e| Error::CorruptEventLog {
path: events_path.to_path_buf(),
reason: format!(
"line {} is not a valid event: {} [{e}]",
line.lineno,
excerpt(line.content)
),
})?;
visit(probe, line.content)?;
}
Ok(())
}
pub fn read_all_events(events_path: &Path) -> Result<Vec<Event>> {
let mut out = Vec::new();
for_each_event_probe::<Event, _>(events_path, |ev, _raw| {
out.push(ev);
Ok(())
})?;
Ok(out)
}
#[derive(Debug, Clone, Serialize)]
pub struct Quarantine {
pub backup_path: PathBuf,
pub removed_byte_offsets: Vec<u64>,
}
pub fn quarantine_corrupt_lines(paths: &RunPaths, backup_ts: &str) -> Result<Option<Quarantine>> {
RunLock::with_lock(paths, |lock| {
quarantine_corrupt_lines_unlocked(lock, paths, backup_ts)
})
}
pub fn quarantine_corrupt_lines_unlocked(
_witness: &LockedRun<'_>,
paths: &RunPaths,
backup_ts: &str,
) -> Result<Option<Quarantine>> {
let events_path = paths.checked_events()?;
let raw = match std::fs::read(&events_path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(Error::io(&events_path, e)),
};
let mut recovered: Vec<u8> = Vec::with_capacity(raw.len());
let mut removed_byte_offsets: Vec<u64> = Vec::new();
let mut offset: u64 = 0;
let mut i = 0usize;
while i < raw.len() {
let (line_end, complete) = match raw[i..].iter().position(|b| *b == b'\n') {
Some(p) => (i + p + 1, true), None => (raw.len(), false), };
let raw_line = &raw[i..line_end];
let content = trim_line_end(raw_line);
let corrupt =
complete && !content.is_empty() && serde_json::from_slice::<Event>(content).is_err();
if corrupt {
removed_byte_offsets.push(offset);
} else {
recovered.extend_from_slice(raw_line);
}
offset += raw_line.len() as u64;
i = line_end;
}
if removed_byte_offsets.is_empty() {
return Ok(None);
}
let backup_path = backup_path_for(&events_path, backup_ts);
std::fs::rename(&events_path, &backup_path).map_err(|e| Error::io(&backup_path, e))?;
write_atomic(&events_path, &recovered)?;
Ok(Some(Quarantine {
backup_path,
removed_byte_offsets,
}))
}
fn backup_path_for(events_path: &Path, ts: &str) -> PathBuf {
let mut name = events_path
.file_name()
.map(std::ffi::OsStr::to_os_string)
.unwrap_or_default();
name.push(format!(".corrupt-{ts}.bak"));
events_path.with_file_name(name)
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PriorEvent {
pub seq: u64,
pub node_id: Option<String>,
pub data: Value,
}
#[derive(Deserialize)]
struct ProbeFields {
#[serde(default)]
seq: Option<u64>,
kind: String,
idempotency_key: Option<String>,
}
#[derive(Deserialize)]
struct FullEventForReplay {
seq: u64,
node_id: Option<String>,
data: Value,
}
const CORRUPT_LINE_EXCERPT_BYTES: usize = 100;
pub fn find_prior_with_key(
_witness: &LockedRun<'_>,
paths: &RunPaths,
kind: &str,
idempotency_key: &str,
) -> Result<Option<PriorEvent>> {
let events_path = paths.checked_events()?;
let f = match std::fs::File::open(&events_path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(Error::io(&events_path, e)),
};
let mut reader = PhysicalLineReader::new(BufReader::new(f));
let mut last_good_seq: u64 = 0;
while let Some(line) = reader.next_line().map_err(|e| Error::io(&events_path, e))? {
if !line.complete {
break;
}
if line.content.is_empty() {
continue;
}
let probe: ProbeFields =
serde_json::from_slice(line.content).map_err(|e| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"line {} is not a valid event envelope (last good seq {last_good_seq}): \
{} [{e}]",
line.lineno,
excerpt(line.content),
),
})?;
if let Some(seq) = probe.seq {
last_good_seq = seq;
}
if probe.kind != kind || probe.idempotency_key.as_deref() != Some(idempotency_key) {
continue;
}
let full: FullEventForReplay =
serde_json::from_slice(line.content).map_err(|e| Error::CorruptEventLog {
path: events_path.clone(),
reason: format!(
"line {} matched idempotency key but is not a replayable event: {} [{e}]",
line.lineno,
excerpt(line.content),
),
})?;
return Ok(Some(PriorEvent {
seq: full.seq,
node_id: full.node_id,
data: full.data,
}));
}
Ok(None)
}
fn trim_line_end(buf: &[u8]) -> &[u8] {
let mut end = buf.len();
if end > 0 && buf[end - 1] == b'\n' {
end -= 1;
if end > 0 && buf[end - 1] == b'\r' {
end -= 1;
}
}
&buf[..end]
}
pub(crate) fn excerpt(line: &[u8]) -> String {
let shown = &line[..line.len().min(CORRUPT_LINE_EXCERPT_BYTES)];
let mut out: String = String::from_utf8_lossy(shown).escape_debug().to_string();
if line.len() > CORRUPT_LINE_EXCERPT_BYTES {
out.push('…');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::RunPaths;
use serde_json::json;
use tempfile::TempDir;
#[test]
fn envelope_run_id_comes_from_paths_not_directory_basename() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("not-a-ulid-basename");
std::fs::create_dir_all(&dir).unwrap();
let run_id = "01jxsnap000000000000000000";
let paths = RunPaths::new(dir, run_id).unwrap();
let r = append_and_apply_event(&paths, "run.status", None, None, serde_json::json!({}))
.unwrap();
assert_eq!(r.seq, 1);
let events = read_all_events(&paths.events()).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].run_id.as_str(), run_id);
}
#[cfg(unix)]
#[test]
fn append_rejects_a_symlinked_event_log() {
use crate::Error;
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let target = tmp.path().join("evil-events.jsonl");
symlink(&target, paths.events()).unwrap();
let err = append_and_apply_event(&paths, "run.status", None, None, json!({})).unwrap_err();
assert!(
matches!(err, Error::SymlinkStateFile { name: "events", .. }),
"got {err:?}"
);
assert!(!target.exists());
}
fn fresh_run(tmp: &TempDir) -> RunPaths {
let run_id = "01jxsnap000000000000000000";
let dir = tmp.path().join(run_id);
std::fs::create_dir_all(&dir).unwrap();
RunPaths::new(dir, run_id).unwrap()
}
fn nid(s: &str) -> NodeId {
NodeId::parse_str(s).unwrap()
}
fn bootstrap_live_node(paths: &RunPaths) {
append_and_apply_event(
paths,
"run.created",
None,
None,
serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "fix" }),
)
.unwrap();
append_and_apply_event(
paths,
"node.created",
Some(&nid("n-0001")),
None,
serde_json::json!({ "kind": "spinoff" }),
)
.unwrap();
}
#[test]
fn append_and_apply_event_success_path_appends_and_folds() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let r = append_and_apply_event(
&paths,
"run.created",
None,
None,
serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
)
.unwrap();
assert_eq!(r.seq, 1);
assert!(!r.idempotent_replay);
assert!(r.prior.is_none());
let m = crate::read_manifest(&paths).unwrap();
assert_eq!(m.run_id.as_str(), paths.run_id.as_str());
}
#[test]
fn append_and_apply_idempotent_appended_path_returns_fresh_seq() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
let before = read_all_events(&paths.events()).unwrap().len();
let data = json!({ "status": "running" });
let outcome = RunLock::with_lock(&paths, |lock| {
append_and_apply_idempotent(
&paths,
lock,
"node.status",
Some(&nid("n-0001")),
"k1",
|_seq| Ok(data.clone()),
)
})
.unwrap();
match outcome {
AppendOutcome::Appended { seq } => {
assert_eq!(seq, 3, "fresh append takes the next seq");
}
other => panic!("expected Appended, got {other:?}"),
}
assert_eq!(
read_all_events(&paths.events()).unwrap().len(),
before + 1,
"a fresh key appends exactly one event"
);
}
#[test]
fn append_and_apply_idempotent_replay_returns_prior_without_appending() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
let node = nid("n-0001");
let data = json!({ "status": "running" });
let first = RunLock::with_lock(&paths, |lock| {
append_and_apply_idempotent(&paths, lock, "node.status", Some(&node), "k1", |_seq| {
Ok(data.clone())
})
})
.unwrap();
let first_seq = match first {
AppendOutcome::Appended { seq } => seq,
other => panic!("expected Appended, got {other:?}"),
};
let after_first = read_all_events(&paths.events()).unwrap().len();
let replay = RunLock::with_lock(&paths, |lock| {
append_and_apply_idempotent(&paths, lock, "node.status", Some(&node), "k1", |_seq| {
Ok(data.clone())
})
})
.unwrap();
match replay {
AppendOutcome::IdempotentReplay { prior } => {
assert_eq!(prior.seq, first_seq);
assert_eq!(prior.node_id.as_deref(), Some("n-0001"));
assert_eq!(prior.data, data);
}
other => panic!("expected IdempotentReplay, got {other:?}"),
}
assert_eq!(
read_all_events(&paths.events()).unwrap().len(),
after_first,
"a replay must not append a new event"
);
}
#[test]
fn append_and_apply_idempotent_conflict_on_different_data() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
let node = nid("n-0001");
let first = RunLock::with_lock(&paths, |lock| {
append_and_apply_idempotent(&paths, lock, "node.status", Some(&node), "k1", |_seq| {
Ok(json!({ "status": "running" }))
})
})
.unwrap();
let first_seq = match first {
AppendOutcome::Appended { seq } => seq,
other => panic!("expected Appended, got {other:?}"),
};
let after_first = read_all_events(&paths.events()).unwrap().len();
let conflict = RunLock::with_lock(&paths, |lock| {
append_and_apply_idempotent(&paths, lock, "node.status", Some(&node), "k1", |_seq| {
Ok(json!({ "status": "done" }))
})
})
.unwrap();
match conflict {
AppendOutcome::Conflict { prior } => {
assert_eq!(prior.seq, first_seq);
assert_eq!(prior.data, json!({ "status": "running" }));
}
other => panic!("expected Conflict, got {other:?}"),
}
assert_eq!(
read_all_events(&paths.events()).unwrap().len(),
after_first,
"a conflict must not append a new event"
);
}
#[test]
fn append_and_apply_idempotent_conflict_on_different_node_id() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
append_and_apply_event(
&paths,
"node.created",
Some(&nid("n-0002")),
None,
json!({ "kind": "spinoff" }),
)
.unwrap();
let data = json!({ "status": "running" });
RunLock::with_lock(&paths, |lock| {
append_and_apply_idempotent(
&paths,
lock,
"node.status",
Some(&nid("n-0001")),
"k1",
|_seq| Ok(data.clone()),
)
})
.unwrap();
let conflict = RunLock::with_lock(&paths, |lock| {
append_and_apply_idempotent(
&paths,
lock,
"node.status",
Some(&nid("n-0002")),
"k1",
|_seq| Ok(data.clone()),
)
})
.unwrap();
assert!(
matches!(conflict, AppendOutcome::Conflict { prior } if prior.node_id.as_deref() == Some("n-0001")),
"a node-id mismatch under the same key is a conflict"
);
}
#[test]
fn append_and_apply_idempotent_rejects_empty_key() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
let err = RunLock::with_lock(&paths, |lock| {
append_and_apply_idempotent(
&paths,
lock,
"node.status",
Some(&nid("n-0001")),
"",
|_seq| Ok(json!({ "status": "running" })),
)
})
.unwrap_err();
assert!(matches!(err, Error::EmptyIdempotencyKey), "got {err:?}");
}
#[test]
fn append_and_apply_event_idempotent_replay_returns_prior_without_appending() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
let data = serde_json::json!({ "status": "running" });
let first = append_and_apply_event(
&paths,
"node.status",
Some(&nid("n-0001")),
Some("k1"),
data.clone(),
)
.unwrap();
assert!(!first.idempotent_replay);
let before = read_all_events(&paths.events()).unwrap().len();
let replay = append_and_apply_event(
&paths,
"node.status",
Some(&nid("n-0001")),
Some("k1"),
data.clone(),
)
.unwrap();
assert!(replay.idempotent_replay);
assert!(
!replay.applied,
"an idempotent replay applies nothing this call (applied: false)"
);
assert_eq!(replay.seq, first.seq);
let prior = replay.prior.expect("replay carries the prior event");
assert_eq!(prior.node_id.as_deref(), Some("n-0001"));
assert_eq!(prior.data, data);
assert_eq!(
read_all_events(&paths.events()).unwrap().len(),
before,
"replay must not append a new line"
);
}
#[test]
fn append_and_apply_event_reducer_noop_is_still_a_success() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
let n0001 = nid("n-0001");
let settle = append_and_apply_event(
&paths,
"node.report",
Some(&n0001),
None,
serde_json::json!({ "success": true }),
)
.unwrap();
assert!(
settle.applied,
"a report that terminalizes a live node applied a projection op"
);
assert_eq!(
crate::read_node(&paths, &n0001).unwrap().status,
crate::schema::Status::Done
);
let before = read_all_events(&paths.events()).unwrap().len();
let r = append_and_apply_event(
&paths,
"node.status",
Some(&n0001),
None,
serde_json::json!({ "status": "running" }),
)
.unwrap();
assert!(!r.idempotent_replay);
assert!(
!r.applied,
"a dead event dropped by the terminal guard reports applied: false"
);
assert_eq!(r.seq as usize, before + 1);
assert_eq!(
read_all_events(&paths.events()).unwrap().len(),
before + 1,
"the event is appended even when the reducer no-ops"
);
assert_eq!(
crate::read_node(&paths, &n0001).unwrap().status,
crate::schema::Status::Done,
"terminal status is frozen"
);
}
#[test]
fn bootstrap_advances_the_watermark_past_every_appended_event() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths); assert_eq!(
crate::read_manifest(&paths).unwrap().applied_seq,
2,
"watermark tracks the last appended event"
);
}
#[test]
fn append_replays_unapplied_tail_before_appending() {
use crate::schema::Status;
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths); let n0001 = nid("n-0001");
RunLock::with_lock(&paths, |lock| {
append_event_with_seq(
lock,
&paths,
3,
"node.status",
Some(&n0001),
None,
json!({ "status": "running" }),
)
})
.unwrap();
assert_eq!(
crate::read_node(&paths, &n0001).unwrap().status,
Status::Pending,
"the tail event's projection has not landed yet"
);
assert_eq!(crate::read_manifest(&paths).unwrap().applied_seq, 2);
let r = append_and_apply_event(
&paths,
"run.status",
None,
None,
json!({ "status": "running" }),
)
.unwrap();
assert_eq!(r.seq, 4, "the new event follows the replayed tail");
assert_eq!(
crate::read_node(&paths, &n0001).unwrap().status,
Status::Running,
"the previously-unapplied tail event is now folded"
);
assert_eq!(
crate::read_manifest(&paths).unwrap().applied_seq,
4,
"the watermark now covers the whole log"
);
}
#[test]
fn legacy_manifest_without_applied_seq_migrates_on_next_write() {
use crate::schema::Status;
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
let n0001 = nid("n-0001");
append_and_apply_event(
&paths,
"node.report",
Some(&n0001),
None,
json!({ "success": true }),
)
.unwrap();
let mut mv: serde_json::Value =
serde_json::from_slice(&std::fs::read(paths.manifest()).unwrap()).unwrap();
assert!(mv.as_object_mut().unwrap().remove("applied_seq").is_some());
std::fs::write(paths.manifest(), serde_json::to_vec_pretty(&mv).unwrap()).unwrap();
assert_eq!(
crate::read_manifest(&paths).unwrap().applied_seq,
0,
"a legacy manifest reads as applied_seq 0"
);
append_and_apply_event(
&paths,
"run.status",
None,
None,
json!({ "status": "running" }),
)
.unwrap(); let m = crate::read_manifest(&paths).unwrap();
assert_eq!(m.applied_seq, 4, "watermark caught up to the log");
assert_eq!(
m.node_count, 1,
"full replay did not double-count node_count"
);
assert_eq!(
crate::read_node(&paths, &n0001).unwrap().status,
Status::Done,
"replaying its history did not resurrect the terminal node"
);
}
#[test]
fn replay_skips_events_with_unsafe_ids_and_never_escapes_run_dir() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
RunLock::with_lock(&paths, |lock| {
append_event_with_seq(
lock,
&paths,
3,
"discussion.opened",
Some(&nid("n-0001")),
None,
json!({ "discussion_id": "../escape", "node_id": "n-0001", "topic": "evil" }),
)?;
append_event_with_seq(
lock,
&paths,
4,
"discussion.opened",
Some(&nid("n-0001")),
None,
json!({ "discussion_id": "", "node_id": "n-0001", "topic": "evil" }),
)?;
append_event_with_seq(
lock,
&paths,
5,
"discussion.opened",
Some(&nid("n-0001")),
None,
json!({ "discussion_id": "d-abcdefghij", "node_id": "n-0001", "topic": "ok" }),
)
})
.unwrap();
replay_unapplied(&paths, &paths.events()).expect("poison lines skipped, not fatal");
let good = crate::projections::read_discussion_opt(
&paths,
&crate::schema::DiscussionId::parse_str("d-abcdefghij").unwrap(),
)
.unwrap();
assert!(good.is_some(), "the valid discussion was applied");
assert!(
!paths.root.join("escape.json").exists(),
"traversal must not have written outside discussions/"
);
let entries: Vec<_> = std::fs::read_dir(paths.discussions_dir())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(
entries,
vec!["d-abcdefghij.json".to_string()],
"only the good discussion file exists; poison ids joined no path"
);
let m = crate::read_manifest(&paths).unwrap();
assert_eq!(
m.applied_seq, 5,
"watermark advanced past the skipped poison"
);
assert_eq!(m.open_discussions, 1, "only the good discussion is counted");
}
#[test]
fn node_count_desync_heals_on_replay() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
let mut m = crate::read_manifest(&paths).unwrap();
assert_eq!(m.node_count, 1, "precondition: bootstrap counted the node");
m.node_count = 0;
m.applied_seq = 1;
write_manifest(&paths, &m).unwrap();
append_and_apply_event(
&paths,
"run.status",
None,
None,
json!({ "status": "running" }),
)
.unwrap();
let healed = crate::read_manifest(&paths).unwrap();
assert_eq!(
healed.node_count, 1,
"node_count converged to the true projection count"
);
assert!(healed.applied_seq >= 2, "watermark caught up past the node");
}
#[test]
fn open_discussions_desync_heals_on_replay() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths); append_and_apply_event(
&paths,
"discussion.opened",
Some(&nid("n-0001")),
None,
json!({ "discussion_id": "d-fxtrdscssn", "node_id": "n-0001", "topic": "x" }),
)
.unwrap(); append_and_apply_event(
&paths,
"discussion.resolved",
Some(&nid("n-0001")),
None,
json!({ "discussion_id": "d-fxtrdscssn", "resolution": "drop" }),
)
.unwrap(); let mut m = crate::read_manifest(&paths).unwrap();
assert_eq!(m.open_discussions, 0, "precondition: resolve decremented");
m.open_discussions = 1;
m.applied_seq = 3;
write_manifest(&paths, &m).unwrap();
append_and_apply_event(
&paths,
"run.status",
None,
None,
json!({ "status": "running" }),
)
.unwrap();
assert_eq!(
crate::read_manifest(&paths).unwrap().open_discussions,
0,
"open_discussions converged after the resolved discussion was re-folded"
);
}
#[test]
fn full_replay_does_not_double_count_any_counter() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
append_and_apply_event(
&paths,
"node.created",
Some(&nid("n-0002")),
None,
json!({ "kind": "spinoff" }),
)
.unwrap();
append_and_apply_event(
&paths,
"discussion.opened",
Some(&nid("n-0001")),
None,
json!({ "discussion_id": "d-fxtrdscssn", "node_id": "n-0001", "topic": "x" }),
)
.unwrap();
append_and_apply_event(
&paths,
"spinoff.proposed",
Some(&nid("n-0001")),
None,
json!({
"proposal_id": "s-fxtrspnoff",
"proposed_title": "t",
"proposed_kind": "spinoff",
"node_id": "n-0001",
}),
)
.unwrap();
let before = crate::read_manifest(&paths).unwrap();
assert_eq!(
(
before.node_count,
before.open_discussions,
before.pending_spinoffs
),
(2, 1, 1),
"precondition: two nodes, one open discussion, one pending spinoff"
);
let mut m = before;
m.applied_seq = 0;
m.node_count = 99;
m.open_discussions = 99;
m.pending_spinoffs = 99;
write_manifest(&paths, &m).unwrap();
append_and_apply_event(
&paths,
"run.status",
None,
None,
json!({ "status": "running" }),
)
.unwrap();
let after = crate::read_manifest(&paths).unwrap();
assert_eq!(
(
after.node_count,
after.open_discussions,
after.pending_spinoffs
),
(2, 1, 1),
"counters re-derived to the true totals — no double-count across full replay"
);
}
#[test]
fn idempotent_replay_catches_up_projection_before_returning() {
use crate::projections::write_manifest;
use crate::schema::Status;
use crate::write_node;
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
let n0001 = nid("n-0001");
let first = append_and_apply_event(
&paths,
"node.status",
Some(&n0001),
Some("k1"),
json!({ "status": "running" }),
)
.unwrap(); assert!(!first.idempotent_replay);
let mut m = crate::read_manifest(&paths).unwrap();
m.applied_seq = 2;
write_manifest(&paths, &m).unwrap();
let mut n = crate::read_node(&paths, &n0001).unwrap();
n.status = Status::Pending;
write_node(&paths, &n).unwrap();
let replay = append_and_apply_event(
&paths,
"node.status",
Some(&n0001),
Some("k1"),
json!({ "status": "running" }),
)
.unwrap();
assert!(replay.idempotent_replay);
assert_eq!(replay.seq, first.seq);
assert!(
crate::read_manifest(&paths).unwrap().applied_seq >= first.seq,
"watermark caught up before the replay returned"
);
assert_eq!(
crate::read_node(&paths, &n0001).unwrap().status,
Status::Running,
"the prior event's projection is durable before returning"
);
}
fn paths_with_events(tmp: &TempDir, bytes: &[u8]) -> RunPaths {
let dir = tmp.path().join("run");
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, "01jxsnap000000000000000000").unwrap();
std::fs::write(paths.events(), bytes).unwrap();
paths
}
fn scan(paths: &RunPaths, kind: &str, key: &str) -> Result<Option<PriorEvent>> {
RunLock::with_lock(paths, |w| find_prior_with_key(w, paths, kind, key))
}
#[test]
fn find_prior_with_key_missing_log_is_none() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("run");
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, "01jxsnap000000000000000000").unwrap();
let got = scan(&paths, "node.report", "k1").unwrap();
assert!(got.is_none());
}
#[test]
fn find_prior_with_key_finds_the_matching_line() {
let tmp = TempDir::new().unwrap();
let log = concat!(
r#"{"seq":1,"kind":"node.status","idempotency_key":"k0","node_id":"n-1","data":{}}"#,
"\n",
r#"{"seq":2,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{"ok":true}}"#,
"\n",
);
let paths = paths_with_events(&tmp, log.as_bytes());
let got = scan(&paths, "node.report", "k1").unwrap().expect("match");
assert_eq!(got.seq, 2);
assert_eq!(got.node_id.as_deref(), Some("n-1"));
assert_eq!(got.data, serde_json::json!({"ok": true}));
}
#[test]
fn find_prior_with_key_no_match_is_none() {
let tmp = TempDir::new().unwrap();
let log = concat!(
r#"{"seq":1,"kind":"node.report","idempotency_key":"other","node_id":"n-1","data":{}}"#,
"\n",
);
let paths = paths_with_events(&tmp, log.as_bytes());
assert!(scan(&paths, "node.report", "k1").unwrap().is_none());
}
#[test]
fn find_prior_with_key_tolerates_torn_final_line() {
let tmp = TempDir::new().unwrap();
let mut log = String::new();
log.push_str(
r#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{"ok":true}}"#,
);
log.push('\n');
log.push_str(r#"{"seq":2,"kind":"node.rep"#); let paths = paths_with_events(&tmp, log.as_bytes());
let got = scan(&paths, "node.report", "k1")
.unwrap()
.expect("match before the torn tail");
assert_eq!(got.seq, 1);
let tmp2 = TempDir::new().unwrap();
let paths2 = paths_with_events(&tmp2, br#"{"seq":1,"kind":"node.rep"#);
assert!(scan(&paths2, "node.report", "k1").unwrap().is_none());
}
#[test]
fn find_prior_with_key_ignores_valid_json_final_line_without_newline() {
let tmp = TempDir::new().unwrap();
let line =
br#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{}}"#;
let paths = paths_with_events(&tmp, line);
assert_eq!(recover_last_seq(&paths.events()).unwrap(), 0);
assert!(
scan(&paths, "node.report", "k1").unwrap().is_none(),
"torn tail must be ignored even when it parses as valid JSON"
);
}
#[test]
fn find_prior_with_key_skips_nonmatching_line_missing_seq() {
let tmp = TempDir::new().unwrap();
let log = concat!(
r#"{"kind":"node.status","idempotency_key":"other","node_id":"n-1","data":{}}"#,
"\n",
r#"{"seq":2,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{"ok":true}}"#,
"\n",
);
let paths = paths_with_events(&tmp, log.as_bytes());
let got = scan(&paths, "node.report", "k1")
.unwrap()
.expect("match after a seq-less non-matching line");
assert_eq!(got.seq, 2);
assert_eq!(got.node_id.as_deref(), Some("n-1"));
}
#[test]
fn find_prior_with_key_matched_line_bad_payload_is_corrupt_log() {
let tmp = TempDir::new().unwrap();
let log = concat!(
r#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":42,"data":{}}"#,
"\n",
);
let paths = paths_with_events(&tmp, log.as_bytes());
let err = scan(&paths, "node.report", "k1").unwrap_err();
assert!(
matches!(err, Error::CorruptEventLog { .. }),
"expected CorruptEventLog, got {err:?}"
);
}
#[test]
fn find_prior_with_key_handles_crlf_line_endings() {
let tmp = TempDir::new().unwrap();
let log = concat!(
r#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{}}"#,
"\r\n",
);
let paths = paths_with_events(&tmp, log.as_bytes());
let got = scan(&paths, "node.report", "k1")
.unwrap()
.expect("CRLF-terminated match");
assert_eq!(got.seq, 1);
}
#[test]
fn find_prior_with_key_tolerates_partial_utf8_torn_tail() {
let tmp = TempDir::new().unwrap();
let mut log = Vec::new();
log.extend_from_slice(
br#"{"seq":1,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{}}"#,
);
log.push(b'\n');
log.extend_from_slice(&[0xF0, 0x9F]); let paths = paths_with_events(&tmp, &log);
let got = scan(&paths, "node.report", "k1")
.unwrap()
.expect("match before the partial-UTF8 tail");
assert_eq!(got.seq, 1);
}
#[test]
fn recover_last_seq_newline_terminated_garbage_is_corrupt_log() {
let tmp = TempDir::new().unwrap();
let paths = paths_with_events(&tmp, b"{not json at all\n");
let err = recover_last_seq(&paths.events()).unwrap_err();
assert!(
matches!(err, Error::CorruptEventLog { .. }),
"expected CorruptEventLog, got {err:?}"
);
}
#[test]
fn rejected_event_is_not_appended() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
let before = read_all_events(&paths.events()).unwrap().len();
let err =
append_and_apply_event(&paths, "node.report", Some(&nid("n-0001")), None, json!({}))
.unwrap_err();
assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
assert_eq!(
read_all_events(&paths.events()).unwrap().len(),
before,
"a rejected event must not be appended"
);
assert!(recover_last_seq(&paths.events()).is_ok());
let next = append_and_apply_event(
&paths,
"node.report",
Some(&nid("n-0001")),
None,
json!({ "success": true }),
)
.unwrap();
assert_eq!(
next.seq as usize,
before + 1,
"the next valid append reuses the seq the rejected event never consumed"
);
}
#[test]
fn validate_event_agrees_with_apply_event() {
use crate::reducer::{apply_event, validate_event};
fn ev(paths: &RunPaths, kind: &str, node_id: Option<&str>, data: Value) -> Event {
Event {
ts: Utc::now(),
seq: 999,
kind: kind.to_string(),
run_id: paths.run_id.clone(),
node_id: node_id.map(|s| crate::schema::NodeId::parse_str(s).unwrap()),
idempotency_key: None,
data,
}
}
fn agree(paths: &RunPaths, e: &Event, label: &str) {
let v = validate_event(paths, e).is_err();
let a = apply_event(paths, e).is_err();
assert_eq!(v, a, "{label}: validate_err={v} apply_err={a}");
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
agree(
&paths,
&ev(&paths, "node.report", Some("n-0001"), json!({})),
"report-bare",
);
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
agree(
&paths,
&ev(
&paths,
"node.report",
Some("n-0001"),
json!({ "success": true }),
),
"report-good",
);
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
agree(
&paths,
&ev(&paths, "node.report", None, json!({})),
"report-no-node-id",
);
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
agree(
&paths,
&ev(&paths, "node.status", Some("n-0001"), json!({})),
"status-missing",
);
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
append_and_apply_event(
&paths,
"node.report",
Some(&nid("n-0001")),
None,
json!({ "success": true }),
)
.unwrap();
agree(
&paths,
&ev(&paths, "node.report", Some("n-0001"), json!({})),
"report-bare-on-terminal",
);
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
agree(
&paths,
&ev(&paths, "node.status", Some("n-0001"), json!({})),
"status-missing-node",
);
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
agree(
&paths,
&ev(&paths, "run.status", None, json!({})),
"run-status-missing",
);
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
append_and_apply_event(
&paths,
"discussion.opened",
Some(&nid("n-0001")),
None,
json!({ "discussion_id": "d-abcdefghij", "topic": "t", "node_id": "n-0001" }),
)
.unwrap();
agree(
&paths,
&ev(
&paths,
"discussion.resolved",
None,
json!({ "discussion_id": "d-abcdefghij" }),
),
"resolve-missing-resolution",
);
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
agree(
&paths,
&ev(&paths, "node.created", Some("n-0002"), json!({})),
"node-created-missing-kind",
);
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
agree(
&paths,
&ev(&paths, "node.created", Some("n-0001"), json!({})),
"node-created-replay-bad-payload",
);
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
agree(
&paths,
&ev(
&paths,
"discussion.opened",
Some("n-0001"),
json!({ "discussion_id": "d-abcdefghij", "node_id": "n-0001" }),
),
"discussion-opened-missing-topic",
);
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_live_node(&paths);
agree(
&paths,
&ev(
&paths,
"spinoff.proposed",
Some("n-0001"),
json!({ "proposal_id": "p-abcdefghij", "proposed_kind": "spinoff", "node_id": "n-0001" }),
),
"spinoff-proposed-missing-title",
);
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
agree(
&paths,
&ev(
&paths,
"spinoff.approved",
None,
json!({ "proposal_id": "not a valid id" }),
),
"spinoff-approved-bad-id",
);
agree(
&paths,
&ev(
&paths,
"spinoff.rejected",
None,
json!({ "proposal_id": "not a valid id" }),
),
"spinoff-rejected-bad-id",
);
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
agree(
&paths,
&ev(&paths, "child.spawned", Some("n-0001"), json!({})),
"child-spawned-missing-child-run-id",
);
agree(
&paths,
&ev(
&paths,
"child.spawned",
Some("n-0001"),
json!({ "child_run_id": "bad" }),
),
"child-spawned-bad-child-run-id",
);
}
{
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let mut foreign = ev(&paths, "run.status", None, json!({ "status": "running" }));
foreign.run_id = crate::schema::RunId::parse_str("02jxsnap000000000000000000").unwrap();
agree(&paths, &foreign, "cross-run");
agree(
&paths,
&ev(&paths, "totally.unknown", None, json!({})),
"unknown-kind",
);
}
}
#[test]
fn read_all_events_drops_torn_final_line() {
let tmp = TempDir::new().unwrap();
let mut log = String::new();
log.push_str(
r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
);
log.push('\n');
log.push_str(
r#"{"ts":"2026-06-12T00:00:00Z","seq":2,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
);
let paths = paths_with_events(&tmp, log.as_bytes());
let events = read_all_events(&paths.events()).unwrap();
assert_eq!(
events.iter().map(|e| e.seq).collect::<Vec<_>>(),
vec![1],
"torn final line must be dropped, not parsed"
);
assert_eq!(recover_last_seq(&paths.events()).unwrap(), 1);
}
#[test]
fn recover_last_seq_rejects_seq_only_last_line() {
let tmp = TempDir::new().unwrap();
let paths = paths_with_events(&tmp, b"{\"seq\":99}\n");
let err = recover_last_seq(&paths.events()).unwrap_err();
assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
assert!(matches!(
read_all_events(&paths.events()).unwrap_err(),
Error::CorruptEventLog { .. }
));
}
#[test]
fn recover_last_seq_skips_multiple_trailing_blank_lines() {
let tmp = TempDir::new().unwrap();
let mut log = String::new();
log.push_str(
r#"{"ts":"2026-06-12T00:00:00Z","seq":7,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
);
log.push_str("\n\n\n\n");
let paths = paths_with_events(&tmp, log.as_bytes());
assert_eq!(recover_last_seq(&paths.events()).unwrap(), 7);
let events = read_all_events(&paths.events()).unwrap();
assert_eq!(events.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![7]);
}
#[test]
fn recover_last_seq_skips_trailing_whitespace_only_lines() {
let tmp = TempDir::new().unwrap();
let mut log = String::new();
log.push_str(
r#"{"ts":"2026-06-12T00:00:00Z","seq":5,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
);
log.push_str("\n \n\t\n \r\n");
let paths = paths_with_events(&tmp, log.as_bytes());
assert_eq!(recover_last_seq(&paths.events()).unwrap(), 5);
}
#[test]
fn recover_last_seq_all_whitespace_file_is_zero() {
let tmp = TempDir::new().unwrap();
let paths = paths_with_events(&tmp, b"\n \n\t\n \r\n");
assert_eq!(recover_last_seq(&paths.events()).unwrap(), 0);
}
#[test]
fn recover_last_seq_single_newline_terminated_record_is_regression_guard() {
let tmp = TempDir::new().unwrap();
let paths = paths_with_events(
&tmp,
concat!(
r#"{"ts":"2026-06-12T00:00:00Z","seq":5,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
"\n",
)
.as_bytes(),
);
assert_eq!(recover_last_seq(&paths.events()).unwrap(), 5);
}
#[test]
fn read_all_events_rejects_corrupt_middle_line() {
let tmp = TempDir::new().unwrap();
let log = concat!(
r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
"\n",
"{not valid json at all\n",
r#"{"ts":"2026-06-12T00:00:00Z","seq":3,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
"\n",
);
let paths = paths_with_events(&tmp, log.as_bytes());
let err = read_all_events(&paths.events()).unwrap_err();
match err {
Error::CorruptEventLog { reason, .. } => {
assert!(reason.contains("line 2"), "reason was: {reason}");
}
other => panic!("expected CorruptEventLog, got {other:?}"),
}
}
#[test]
fn append_truncates_torn_tail_before_writing() {
let tmp = TempDir::new().unwrap();
let mut bytes = Vec::new();
bytes.extend_from_slice(
br#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
);
bytes.push(b'\n');
bytes.extend_from_slice(br#"{"seq":2,"kind":"TORN_PARTIAL_NEVER_FLUSHED"#); let paths = paths_with_events(&tmp, &bytes);
assert_eq!(recover_last_seq(&paths.events()).unwrap(), 1);
let r = append_and_apply_event(&paths, "marker", None, None, serde_json::json!({"x": 1}))
.unwrap();
assert_eq!(r.seq, 2, "seq continues from the last complete record");
let raw = std::fs::read(paths.events()).unwrap();
assert!(
raw.ends_with(b"\n"),
"log must be newline-terminated after a clean append"
);
assert!(
!String::from_utf8_lossy(&raw).contains("TORN_PARTIAL_NEVER_FLUSHED"),
"the torn tail must be truncated away before the append"
);
let events = read_all_events(&paths.events()).unwrap();
assert_eq!(events.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2]);
}
#[test]
fn append_truncates_all_torn_file_to_empty_then_writes_seq_1() {
let tmp = TempDir::new().unwrap();
let paths = paths_with_events(&tmp, br#"{"seq":1,"kind":"marker"#);
assert_eq!(recover_last_seq(&paths.events()).unwrap(), 0);
let r = append_and_apply_event(&paths, "marker", None, None, json!({})).unwrap();
assert_eq!(r.seq, 1);
let events = read_all_events(&paths.events()).unwrap();
assert_eq!(events.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
}
#[test]
fn truncate_torn_tail_cuts_partial_line_at_last_newline() {
let tmp = TempDir::new().unwrap();
let complete = r#"{"ts":"2026-06-12T00:00:00Z","seq":5,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#;
let mut bytes = Vec::new();
bytes.extend_from_slice(complete.as_bytes());
bytes.push(b'\n');
let keep = bytes.len() as u64; bytes.extend_from_slice(br#"{"seq":6,"par"#); let paths = paths_with_events(&tmp, &bytes);
truncate_torn_tail(&paths.events()).unwrap();
let raw = std::fs::read(paths.events()).unwrap();
assert_eq!(
raw.len() as u64,
keep,
"file must end at the offset after seq-5's newline"
);
assert!(raw.ends_with(b"\n"), "file is newline-terminated after cut");
assert_eq!(recover_last_seq(&paths.events()).unwrap(), 5);
}
#[test]
fn truncate_torn_tail_clean_file_is_noop() {
let tmp = TempDir::new().unwrap();
let log = concat!(
r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
"\n",
);
let paths = paths_with_events(&tmp, log.as_bytes());
truncate_torn_tail(&paths.events()).unwrap();
assert_eq!(
std::fs::read(paths.events()).unwrap(),
log.as_bytes(),
"a clean, newline-terminated log must be left byte-for-byte intact"
);
}
#[test]
fn truncate_torn_tail_zero_length_file_is_noop() {
let tmp = TempDir::new().unwrap();
let paths = paths_with_events(&tmp, b"");
truncate_torn_tail(&paths.events()).unwrap();
assert_eq!(std::fs::read(paths.events()).unwrap(), b"");
}
#[test]
fn truncate_torn_tail_missing_file_is_noop() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("run");
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, "01jxsnap000000000000000000").unwrap();
truncate_torn_tail(&paths.events()).unwrap();
assert!(!paths.events().exists());
}
#[test]
fn truncate_torn_tail_single_complete_row_is_noop() {
let tmp = TempDir::new().unwrap();
let log = concat!(
r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
"\n",
);
let paths = paths_with_events(&tmp, log.as_bytes());
truncate_torn_tail(&paths.events()).unwrap();
assert_eq!(std::fs::read(paths.events()).unwrap(), log.as_bytes());
}
#[test]
fn truncate_torn_tail_single_partial_row_truncates_to_zero() {
let tmp = TempDir::new().unwrap();
let paths = paths_with_events(&tmp, br#"{"seq":1,"kind":"marker"#);
truncate_torn_tail(&paths.events()).unwrap();
assert_eq!(
std::fs::read(paths.events()).unwrap(),
b"",
"a file holding only a partial row must be cut to empty"
);
assert_eq!(recover_last_seq(&paths.events()).unwrap(), 0);
}
#[test]
fn quarantine_excises_corrupt_middle_line_and_recovers() {
let tmp = TempDir::new().unwrap();
let good1 = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#;
let bad = "{not valid json at all";
let good3 = r#"{"ts":"2026-06-12T00:00:00Z","seq":3,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#;
let log = format!("{good1}\n{bad}\n{good3}\n");
let paths = paths_with_events(&tmp, log.as_bytes());
assert!(matches!(
read_all_events(&paths.events()).unwrap_err(),
Error::CorruptEventLog { .. }
));
let q = quarantine_corrupt_lines(&paths, "20260612T000000Z")
.unwrap()
.expect("a corrupt line was excised");
assert_eq!(q.removed_byte_offsets, vec![(good1.len() + 1) as u64]);
assert_eq!(
q.backup_path.file_name().unwrap().to_str().unwrap(),
"events.jsonl.corrupt-20260612T000000Z.bak"
);
assert_eq!(std::fs::read(&q.backup_path).unwrap(), log.as_bytes());
let events = read_all_events(&paths.events()).unwrap();
assert_eq!(events.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 3]);
}
#[test]
fn quarantine_clean_log_is_noop() {
let tmp = TempDir::new().unwrap();
let log = concat!(
r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#,
"\n",
);
let paths = paths_with_events(&tmp, log.as_bytes());
assert!(quarantine_corrupt_lines(&paths, "20260612T000000Z")
.unwrap()
.is_none());
assert_eq!(std::fs::read(paths.events()).unwrap(), log.as_bytes());
let bak = paths
.events()
.with_file_name("events.jsonl.corrupt-20260612T000000Z.bak");
assert!(!bak.exists());
}
#[test]
fn quarantine_missing_log_is_none() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("run");
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, "01jxsnap000000000000000000").unwrap();
assert!(quarantine_corrupt_lines(&paths, "20260612T000000Z")
.unwrap()
.is_none());
}
#[test]
fn quarantine_preserves_torn_tail_and_excises_only_corruption() {
let tmp = TempDir::new().unwrap();
let good = r#"{"ts":"2026-06-12T00:00:00Z","seq":1,"kind":"marker","run_id":"01jxsnap000000000000000000","data":{}}"#;
let bad = "{garbage";
let torn = r#"{"seq":2,"kind":"node.rep"#; let mut log = Vec::new();
log.extend_from_slice(format!("{good}\n{bad}\n{torn}").as_bytes());
let paths = paths_with_events(&tmp, &log);
let q = quarantine_corrupt_lines(&paths, "20260612T000000Z")
.unwrap()
.expect("the corrupt middle line was excised");
assert_eq!(q.removed_byte_offsets, vec![(good.len() + 1) as u64]);
let recovered = std::fs::read(paths.events()).unwrap();
assert_eq!(recovered, format!("{good}\n{torn}").as_bytes());
assert_eq!(recover_last_seq(&paths.events()).unwrap(), 1);
}
#[test]
fn find_prior_with_key_rejects_torn_middle_line() {
let tmp = TempDir::new().unwrap();
let log = concat!(
r#"{"seq":1,"kind":"node.report","idempotency_key":"k0","node_id":"n-1","data":{}}"#,
"\n",
"{not valid json at all\n",
r#"{"seq":3,"kind":"node.report","idempotency_key":"k1","node_id":"n-1","data":{}}"#,
"\n",
);
let paths = paths_with_events(&tmp, log.as_bytes());
let err = scan(&paths, "node.report", "k1").unwrap_err();
match err {
Error::CorruptEventLog { reason, .. } => {
assert!(reason.contains("line 2"), "reason was: {reason}");
assert!(reason.contains("last good seq 1"), "reason was: {reason}");
}
other => panic!("expected CorruptEventLog, got {other:?}"),
}
}
}