use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::path::Path;
use std::sync::Arc;
use crate::core::cloud::offset::{
advance_past_oldest, count_entries_from, read_offset, write_offset, AdvanceStats,
};
use crate::core::envelope::EventEnvelope;
use crate::daemon::handlers;
use crate::daemon::AppState;
const FALLBACK_FILENAME: &str = "fallback.jsonl";
const OFFSET_FILENAME: &str = "fallback.jsonl.offset";
pub async fn run(state: Arc<AppState>) {
let Some(cloud_state) = state.cloud_state.clone() else {
return;
};
let notify = cloud_state.drain_notify.clone();
replay_once(&state).await;
loop {
notify.notified().await;
replay_once(&state).await;
}
}
pub async fn replay_once(state: &Arc<AppState>) {
if let Some(cs) = state.cloud_state.as_ref() {
if cs.is_emergency_mode() {
tracing::debug!("fallback replay: skipping pass — cloud channel in emergency mode");
return;
}
}
let log_dir = crate::config::openlatch_dir().join("logs");
let path = log_dir.join(FALLBACK_FILENAME);
let offset_path = log_dir.join(OFFSET_FILENAME);
if !path.exists() {
let _ = std::fs::remove_file(&offset_path);
return;
}
let max_bytes = state.config.cloud.fallback_max_bytes;
if max_bytes > 0 {
enforce_fallback_size_cap(&path, &offset_path, max_bytes);
}
let start_offset = read_offset(&offset_path);
let file = match std::fs::File::open(&path) {
Ok(f) => f,
Err(e) => {
tracing::warn!(
code = "OL-1205",
error = %e,
path = %path.display(),
"fallback replay: could not open fallback.jsonl"
);
return;
}
};
let total_len = file.metadata().map(|m| m.len()).unwrap_or(0);
if start_offset >= total_len {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&offset_path);
return;
}
let mut reader = BufReader::new(file);
if reader.seek(SeekFrom::Start(start_offset)).is_err() {
tracing::warn!(
code = "OL-1205",
path = %path.display(),
"fallback replay: seek failed — discarding offset and restarting from 0"
);
let _ = std::fs::remove_file(&offset_path);
return;
}
let mut replayed: u64 = 0;
let mut corrupt: u64 = 0;
let mut consumed_offset = start_offset;
let mut halted = false;
let pending_hint = count_entries_from(&path, start_offset);
tracing::info!(
start_offset,
total_bytes = total_len,
pending = pending_hint,
"fallback replay: starting"
);
crate::telemetry::capture_global(crate::telemetry::Event::fallback_replay_started(
pending_hint,
));
for line in reader.lines() {
let Ok(raw) = line else {
halted = true;
break;
};
let line_len = raw.len() as u64 + 1; if raw.trim().is_empty() {
consumed_offset += line_len;
continue;
}
let envelope: EventEnvelope = match serde_json::from_str(&raw) {
Ok(e) => e,
Err(e) => {
tracing::debug!(
error = %e,
"fallback replay: discarding unparsable line"
);
corrupt += 1;
consumed_offset += line_len;
continue;
}
};
if let Some(tx) = state.cloud_tx.as_ref() {
if tx.is_closed() {
tracing::info!(
"fallback replay: cloud channel closed — halting, will resume from offset {consumed_offset} on next start"
);
halted = true;
break;
}
}
let started = std::time::Instant::now();
let _ = handlers::process_envelope_for_replay(state.clone(), envelope, started).await;
replayed += 1;
consumed_offset += line_len;
}
if halted {
tracing::warn!(
code = "OL-1205",
replayed,
corrupt,
"fallback replay: halted on read error — remainder will retry next cycle"
);
write_offset(&offset_path, consumed_offset);
return;
}
if consumed_offset >= total_len {
if let Err(e) = std::fs::remove_file(&path) {
tracing::warn!(
code = "OL-1205",
error = %e,
"fallback replay: could not remove drained fallback.jsonl"
);
}
let _ = std::fs::remove_file(&offset_path);
} else {
write_offset(&offset_path, consumed_offset);
}
if replayed > 0 || corrupt > 0 {
let remaining = count_entries_from(&path, consumed_offset);
tracing::info!(
replayed,
corrupt,
remaining,
consumed_bytes = consumed_offset - start_offset,
"fallback replay: completed"
);
crate::telemetry::capture_global(crate::telemetry::Event::fallback_replay_completed(
replayed, corrupt, remaining,
));
}
}
fn enforce_fallback_size_cap(path: &Path, offset_path: &Path, max_bytes: u64) {
let Ok(metadata) = std::fs::metadata(path) else {
return;
};
let total_len = metadata.len();
let start_offset = read_offset(offset_path);
if start_offset >= total_len {
return;
}
let unread = total_len - start_offset;
if unread <= max_bytes {
return;
}
let excess = unread - max_bytes;
let Some(AdvanceStats {
new_offset,
dropped,
}) = advance_past_oldest(path, start_offset, excess)
else {
return;
};
tracing::warn!(
code = "OL-1205",
dropped,
size_before = unread,
max_bytes,
new_offset,
"fallback overflow: advancing offset past oldest entries to stay under size cap"
);
crate::telemetry::capture_global(crate::telemetry::Event::fallback_overflow(
dropped, unread, max_bytes,
));
write_offset(offset_path, new_offset);
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_line(file: &mut std::fs::File, line: &str) -> u64 {
use std::io::Write;
let bytes = format!("{line}\n");
file.write_all(bytes.as_bytes()).unwrap();
bytes.len() as u64
}
#[test]
fn enforce_size_cap_is_noop_when_unread_within_budget() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(FALLBACK_FILENAME);
let offset_path = tmp.path().join(OFFSET_FILENAME);
let mut f = std::fs::File::create(&path).unwrap();
for i in 0..10 {
write_line(&mut f, &format!("{{\"id\":\"evt_{i}\"}}"));
}
drop(f);
enforce_fallback_size_cap(&path, &offset_path, 10_000_000);
assert!(!offset_path.exists(), "no eviction → no offset file");
}
#[test]
fn enforce_size_cap_advances_offset_past_oldest_when_over_budget() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(FALLBACK_FILENAME);
let offset_path = tmp.path().join(OFFSET_FILENAME);
let mut f = std::fs::File::create(&path).unwrap();
let mut sizes: Vec<u64> = Vec::new();
for i in 0..20 {
sizes.push(write_line(&mut f, &format!("{{\"id\":\"evt_{i:04}\"}}")));
}
drop(f);
let total: u64 = sizes.iter().sum();
let cap = total / 2;
enforce_fallback_size_cap(&path, &offset_path, cap);
let new_offset = read_offset(&offset_path);
assert!(new_offset > 0, "offset must advance past oldest entries");
let unread = total - new_offset;
assert!(
unread <= cap,
"unread tail ({unread}) must be ≤ cap ({cap}) after eviction"
);
}
#[test]
fn enforce_size_cap_handles_missing_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(FALLBACK_FILENAME);
let offset_path = tmp.path().join(OFFSET_FILENAME);
enforce_fallback_size_cap(&path, &offset_path, 1000);
assert!(!offset_path.exists());
}
#[test]
fn enforce_size_cap_zero_disables_cap() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(FALLBACK_FILENAME);
let offset_path = tmp.path().join(OFFSET_FILENAME);
let mut f = std::fs::File::create(&path).unwrap();
write_line(&mut f, "{\"id\":\"evt_0\"}");
drop(f);
enforce_fallback_size_cap(&path, &offset_path, 0);
let _ = read_offset(&offset_path);
}
}