use chrono::{DateTime, Utc};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Condvar, Mutex};
use std::time::{Duration, Instant};
use crossbeam_queue::SegQueue;
const WRITE_BUFFER_MAX_ENTRIES: usize = 1024;
const WRITE_BUFFER_MAX_AGE_MS: u128 = 100;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WalStatus {
Reserved,
Executed,
Earned,
Committed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WalEntry {
pub request_id: String,
pub user_id: String,
pub reservation_id: String,
pub estimated_cost: f64,
pub actual_cost: Option<f64>,
pub worker_id: String,
pub duration_ms: Option<f64>,
pub timestamp: DateTime<Utc>,
pub status: WalStatus,
}
pub struct Wal {
path: PathBuf,
file: Mutex<File>,
entries_index: DashMap<String, WalEntry>,
pending: SegQueue<String>,
pending_count: AtomicUsize,
flush_signal: Mutex<bool>,
flush_cv: Condvar,
start: Instant,
last_flush_nanos: AtomicU64,
index_populated: AtomicBool,
}
#[cfg(test)]
pub fn read_all_from_disk(path: &std::path::Path) -> std::io::Result<Vec<WalEntry>> {
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::new(file);
let mut entries = Vec::new();
for line in reader.lines() {
let line = line?;
if !line.trim().is_empty() {
if let Ok(e) = serde_json::from_str::<WalEntry>(&line) {
entries.push(e);
}
}
}
Ok(entries)
}
impl Wal {
pub fn open<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let file = OpenOptions::new().create(true).append(true).open(&path)?;
let wal = Self {
path,
file: Mutex::new(file),
entries_index: DashMap::new(),
pending: SegQueue::new(),
pending_count: AtomicUsize::new(0),
flush_signal: Mutex::new(false),
flush_cv: Condvar::new(),
start: Instant::now(),
last_flush_nanos: AtomicU64::new(0),
index_populated: AtomicBool::new(false),
};
wal.populate_index();
Ok(wal)
}
fn populate_index(&self) {
if let Ok(entries) = self.read_all() {
for entry in entries {
self.entries_index.insert(entry.request_id.clone(), entry);
}
}
self.index_populated.store(true, Ordering::Release);
}
pub fn append(&self, entry: &WalEntry) -> std::io::Result<()> {
let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
self.entries_index
.insert(entry.request_id.clone(), entry.clone());
self.pending.push(line);
let n = self.pending_count.fetch_add(1, Ordering::Relaxed) + 1;
if n >= WRITE_BUFFER_MAX_ENTRIES {
if let Ok(mut signaled) = self.flush_signal.try_lock() {
*signaled = true;
self.flush_cv.notify_one();
}
}
Ok(())
}
pub fn run_flush_worker(&self, running: &AtomicBool) {
let age = Duration::from_millis(WRITE_BUFFER_MAX_AGE_MS as u64);
while running.load(Ordering::Relaxed) {
{
let guard = self.flush_signal.lock().unwrap_or_else(|e| e.into_inner());
let (mut guard, _timeout) = self
.flush_cv
.wait_timeout(guard, age)
.unwrap_or_else(|e| e.into_inner());
*guard = false; }
if !running.load(Ordering::Relaxed) {
break;
}
if self.pending_count.load(Ordering::Relaxed) > 0 {
let _ = self.flush_buffer();
}
}
let _ = self.flush_buffer();
}
pub fn update_status(
&self,
request_id: &str,
status: WalStatus,
actual_cost: Option<f64>,
duration_ms: Option<f64>,
) -> std::io::Result<()> {
let original = self.entries_index.get(request_id).map(|e| e.clone());
if let Some(orig) = original {
let updated = WalEntry {
request_id: orig.request_id.clone(),
user_id: orig.user_id.clone(),
reservation_id: orig.reservation_id.clone(),
estimated_cost: orig.estimated_cost,
actual_cost: actual_cost.or(orig.actual_cost),
worker_id: orig.worker_id.clone(),
duration_ms: duration_ms.or(orig.duration_ms),
timestamp: Utc::now(),
status,
};
self.append(&updated)?;
}
Ok(())
}
pub fn flush_buffer(&self) -> std::io::Result<()> {
let mut file = self
.file
.lock()
.map_err(|_| std::io::Error::other("WAL lock poisoned"))?;
let mut drained = 0usize;
let mut wrote_any = false;
while let Some(line) = self.pending.pop() {
writeln!(file, "{}", line)?;
drained += 1;
wrote_any = true;
}
if !wrote_any {
return Ok(());
}
file.flush()?;
file.sync_data()?;
self.pending_count.fetch_sub(
drained.min(self.pending_count.load(Ordering::Relaxed)),
Ordering::Relaxed,
);
self.last_flush_nanos
.store(self.start.elapsed().as_nanos() as u64, Ordering::Relaxed);
Ok(())
}
fn read_all(&self) -> std::io::Result<Vec<WalEntry>> {
let file = File::open(&self.path)?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<WalEntry>(&line) {
entries.push(entry);
}
}
Ok(entries)
}
pub fn read_uncommitted(&self) -> std::io::Result<Vec<WalEntry>> {
let _ = self.flush_buffer();
if self.index_populated.load(Ordering::Acquire) {
Ok(self
.entries_index
.iter()
.filter(|e| e.status != WalStatus::Committed && e.status != WalStatus::Failed)
.map(|e| e.value().clone())
.collect())
} else {
let entries = self.read_all()?;
let mut latest: std::collections::HashMap<String, WalEntry> =
std::collections::HashMap::new();
for entry in entries {
latest.insert(entry.request_id.clone(), entry);
}
Ok(latest
.into_values()
.filter(|e| e.status != WalStatus::Committed && e.status != WalStatus::Failed)
.collect())
}
}
#[cfg(test)]
pub fn path(&self) -> &std::path::Path {
&self.path
}
pub fn compact(&self) -> std::io::Result<()> {
self.flush_buffer()?;
let uncommitted = self.read_uncommitted()?;
let tmp_path = self.path.with_extension("tmp");
{
let mut tmp = File::create(&tmp_path)?;
for entry in &uncommitted {
let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
writeln!(tmp, "{}", line)?;
}
tmp.sync_all()?;
}
fs::rename(&tmp_path, &self.path)?;
let new_file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
let mut file = self
.file
.lock()
.map_err(|_| std::io::Error::other("WAL lock poisoned"))?;
*file = new_file;
let to_remove: Vec<String> = self
.entries_index
.iter()
.filter(|e| e.status == WalStatus::Committed || e.status == WalStatus::Failed)
.map(|e| e.key().clone())
.collect();
for key in to_remove {
self.entries_index.remove(&key);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tmp_wal(name: &str) -> std::path::PathBuf {
std::path::PathBuf::from(format!("/tmp/zc_wal_test_{}.jsonl", name))
}
fn cleanup(p: &std::path::Path) {
let _ = fs::remove_file(p);
let _ = fs::remove_file(p.with_extension("tmp"));
}
fn entry(request_id: &str, status: WalStatus) -> WalEntry {
WalEntry {
request_id: request_id.to_string(),
user_id: "alice".to_string(),
reservation_id: format!("res-{}", request_id),
estimated_cost: 0.05,
actual_cost: None,
worker_id: "worker-1".to_string(),
duration_ms: None,
timestamp: Utc::now(),
status,
}
}
#[test]
fn test_open_creates_file() {
let path = tmp_wal("open");
cleanup(&path);
let _wal = Wal::open(&path).unwrap();
assert!(path.exists());
cleanup(&path);
}
#[test]
fn test_append_then_flush_persists_to_disk() {
let path = tmp_wal("append");
cleanup(&path);
let wal = Wal::open(&path).unwrap();
wal.append(&entry("req-1", WalStatus::Reserved)).unwrap();
wal.flush_buffer().unwrap();
let disk = read_all_from_disk(&path).unwrap();
assert_eq!(disk.len(), 1);
assert_eq!(disk[0].request_id, "req-1");
assert_eq!(disk[0].status, WalStatus::Reserved);
cleanup(&path);
}
#[test]
fn test_update_status_appends_new_line() {
let path = tmp_wal("update");
cleanup(&path);
let wal = Wal::open(&path).unwrap();
wal.append(&entry("req-2", WalStatus::Reserved)).unwrap();
wal.update_status("req-2", WalStatus::Committed, Some(0.03), Some(100.0))
.unwrap();
wal.flush_buffer().unwrap();
let disk = read_all_from_disk(&path).unwrap();
assert_eq!(disk.len(), 2);
assert_eq!(disk[1].status, WalStatus::Committed);
assert_eq!(disk[1].actual_cost, Some(0.03));
cleanup(&path);
}
#[test]
fn test_update_status_unknown_request_is_noop() {
let path = tmp_wal("update_noop");
cleanup(&path);
let wal = Wal::open(&path).unwrap();
wal.update_status("nonexistent", WalStatus::Committed, None, None)
.unwrap();
wal.flush_buffer().unwrap();
let disk = read_all_from_disk(&path).unwrap();
assert!(disk.is_empty());
cleanup(&path);
}
#[test]
fn test_read_uncommitted_excludes_committed_and_failed() {
let path = tmp_wal("uncommitted");
cleanup(&path);
let wal = Wal::open(&path).unwrap();
wal.append(&entry("req-a", WalStatus::Reserved)).unwrap();
wal.append(&entry("req-b", WalStatus::Reserved)).unwrap();
wal.append(&entry("req-c", WalStatus::Reserved)).unwrap();
wal.update_status("req-b", WalStatus::Committed, Some(0.01), Some(10.0))
.unwrap();
wal.update_status("req-c", WalStatus::Failed, None, None)
.unwrap();
wal.flush_buffer().unwrap();
let uncommitted = wal.read_uncommitted().unwrap();
let ids: Vec<&str> = uncommitted.iter().map(|e| e.request_id.as_str()).collect();
assert!(ids.contains(&"req-a"), "req-a should be uncommitted");
assert!(
!ids.contains(&"req-b"),
"req-b (committed) should be excluded"
);
assert!(!ids.contains(&"req-c"), "req-c (failed) should be excluded");
cleanup(&path);
}
#[test]
fn test_compact_removes_committed_entries_from_disk() {
let path = tmp_wal("compact");
cleanup(&path);
let wal = Wal::open(&path).unwrap();
wal.append(&entry("req-keep", WalStatus::Reserved)).unwrap();
wal.append(&entry("req-done", WalStatus::Reserved)).unwrap();
wal.update_status("req-done", WalStatus::Committed, Some(0.01), Some(5.0))
.unwrap();
wal.flush_buffer().unwrap();
wal.compact().unwrap();
let disk = read_all_from_disk(&path).unwrap();
let ids: Vec<&str> = disk.iter().map(|e| e.request_id.as_str()).collect();
assert!(
ids.contains(&"req-keep"),
"uncommitted entry should survive compaction"
);
assert!(
!ids.contains(&"req-done"),
"committed entry should be removed"
);
cleanup(&path);
}
#[test]
fn test_compact_also_clears_failed_entries() {
let path = tmp_wal("compact_failed");
cleanup(&path);
let wal = Wal::open(&path).unwrap();
wal.append(&entry("req-fail", WalStatus::Reserved)).unwrap();
wal.update_status("req-fail", WalStatus::Failed, None, None)
.unwrap();
wal.flush_buffer().unwrap();
wal.compact().unwrap();
let disk = read_all_from_disk(&path).unwrap();
assert!(disk.is_empty());
cleanup(&path);
}
#[test]
fn test_reload_populates_index_from_disk() {
let path = tmp_wal("reload");
cleanup(&path);
{
let wal = Wal::open(&path).unwrap();
wal.append(&entry("req-persist", WalStatus::Reserved))
.unwrap();
wal.flush_buffer().unwrap();
}
{
let wal = Wal::open(&path).unwrap();
let uncommitted = wal.read_uncommitted().unwrap();
assert!(
uncommitted.iter().any(|e| e.request_id == "req-persist"),
"Reloaded WAL must surface persisted reserved entry"
);
}
cleanup(&path);
}
#[test]
fn test_flush_worker_persists_without_manual_flush() {
use std::sync::Arc;
use std::time::Duration;
let path = tmp_wal("flush_worker");
cleanup(&path);
let wal = Arc::new(Wal::open(&path).unwrap());
let running = Arc::new(AtomicBool::new(true));
let w = wal.clone();
let r = running.clone();
let handle = std::thread::spawn(move || w.run_flush_worker(&r));
wal.append(&entry("req-bg", WalStatus::Reserved)).unwrap();
std::thread::sleep(Duration::from_millis(WRITE_BUFFER_MAX_AGE_MS as u64 * 4));
let disk = read_all_from_disk(&path).unwrap();
assert_eq!(
disk.len(),
1,
"worker should have persisted the appended entry"
);
assert_eq!(disk[0].request_id, "req-bg");
running.store(false, Ordering::Release);
handle.join().unwrap();
cleanup(&path);
}
#[test]
fn test_flush_worker_drains_on_shutdown() {
use std::sync::Arc;
let path = tmp_wal("flush_worker_shutdown");
cleanup(&path);
let wal = Arc::new(Wal::open(&path).unwrap());
let running = Arc::new(AtomicBool::new(true));
let w = wal.clone();
let r = running.clone();
let handle = std::thread::spawn(move || w.run_flush_worker(&r));
wal.append(&entry("req-drain", WalStatus::Reserved))
.unwrap();
running.store(false, Ordering::Release);
handle.join().unwrap();
let disk = read_all_from_disk(&path).unwrap();
assert!(
disk.iter().any(|e| e.request_id == "req-drain"),
"worker must flush pending entries on shutdown"
);
cleanup(&path);
}
#[test]
fn test_earned_status_is_uncommitted_until_marked_committed() {
let path = tmp_wal("earned_uncommitted");
cleanup(&path);
let wal = Wal::open(&path).unwrap();
let mut e = entry("earn-req-1", WalStatus::Earned);
e.actual_cost = Some(0.02);
wal.append(&e).unwrap();
wal.flush_buffer().unwrap();
let uncommitted = wal.read_uncommitted().unwrap();
assert!(
uncommitted.iter().any(|e| e.request_id == "earn-req-1"),
"Earned entries must be replay-eligible until marked Committed"
);
wal.compact().unwrap();
let disk = read_all_from_disk(&path).unwrap();
assert!(
disk.iter().any(|e| e.request_id == "earn-req-1"),
"Earned entries must survive compaction until Committed"
);
wal.update_status("earn-req-1", WalStatus::Committed, Some(0.02), None)
.unwrap();
wal.flush_buffer().unwrap();
let uncommitted = wal.read_uncommitted().unwrap();
assert!(!uncommitted.iter().any(|e| e.request_id == "earn-req-1"));
cleanup(&path);
}
#[test]
fn test_full_lifecycle_reserved_executed_committed() {
let path = tmp_wal("lifecycle");
cleanup(&path);
let wal = Wal::open(&path).unwrap();
let e = entry("req-life", WalStatus::Reserved);
wal.append(&e).unwrap();
wal.update_status("req-life", WalStatus::Executed, None, Some(250.0))
.unwrap();
wal.update_status("req-life", WalStatus::Committed, Some(0.04), Some(250.0))
.unwrap();
wal.flush_buffer().unwrap();
let uncommitted = wal.read_uncommitted().unwrap();
assert!(!uncommitted.iter().any(|e| e.request_id == "req-life"));
cleanup(&path);
}
}