use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use blake3::Hash;
use serde::{Deserialize, Serialize};
use super::heuristics::heuristics_should_preserve;
pub const L1_LARGE_FILE_BYTES: u64 = crate::constants::WAL_L1_LARGE_FILE_BYTES;
pub(crate) const JOURNAL_EXT: &str = ".atomwrite.journal.json";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "phase", rename_all = "snake_case")]
pub enum JournalEntry {
Started {
op_id: String,
op: JournalOp,
target: String,
checksum_before: Option<String>,
checksum_after: String,
pid: u32,
started_at_unix: u64,
},
Committed {
op_id: String,
committed_at_unix: u64,
},
Aborted {
op_id: String,
aborted_at_unix: u64,
reason: String,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum JournalOp {
Write,
Edit,
Replace,
Set,
}
pub fn journal_path(target: &Path) -> PathBuf {
let dir = target.parent().unwrap_or_else(|| Path::new("."));
let basename = target
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
dir.join(format!(".atomwrite.journal.{}{}", basename, JOURNAL_EXT))
}
pub fn generate_op_id() -> String {
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let input = format!("{}-{}", pid, nanos);
blake3::hash(input.as_bytes())
.to_hex()
.as_str()
.chars()
.take(16)
.collect()
}
pub fn journal_started(
target: &Path,
op: JournalOp,
checksum_before: Option<Hash>,
checksum_after: Hash,
) -> Result<String> {
let op_id = generate_op_id();
let started_at_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let entry = JournalEntry::Started {
op_id: op_id.clone(),
op,
target: target.display().to_string(),
checksum_before: checksum_before.map(|h| h.to_hex().to_string()),
checksum_after: checksum_after.to_hex().to_string(),
pid: std::process::id(),
started_at_unix,
};
append_entry(target, &entry)?;
Ok(op_id)
}
pub fn journal_committed(target: &Path, op_id: &str) -> Result<()> {
let committed_at_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let entry = JournalEntry::Committed {
op_id: op_id.to_owned(),
committed_at_unix,
};
append_entry(target, &entry)
}
pub fn journal_aborted(target: &Path, op_id: &str, reason: &str) -> Result<()> {
let aborted_at_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let entry = JournalEntry::Aborted {
op_id: op_id.to_owned(),
aborted_at_unix,
reason: reason.to_owned(),
};
append_entry(target, &entry)
}
pub(crate) fn append_entry(target: &Path, entry: &JournalEntry) -> Result<()> {
let path = journal_path(target);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create journal dir {}", parent.display()))?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("failed to open journal {}", path.display()))?;
serde_json::to_writer(&mut file, entry).with_context(|| {
format!("failed to serialize journal entry for {}", target.display())
})?;
file.write_all(b"\n")
.with_context(|| format!("failed to write journal entry to {}", path.display()))?;
file.sync_data()
.with_context(|| format!("failed to fsync journal {}", path.display()))?;
Ok(())
}
#[must_use = "JournalGuard controls sidecar lifetime on drop; call keep()/release()"]
#[derive(Debug)]
pub struct JournalGuard {
pub(crate) path: PathBuf,
pub(crate) keep_on_drop: bool,
pub(crate) op_id: Option<String>,
pub(crate) committed_at_unix: Option<u64>,
}
impl JournalGuard {
pub fn inert() -> Self {
Self {
path: PathBuf::new(),
keep_on_drop: true,
op_id: None,
committed_at_unix: None,
}
}
pub fn keep(&mut self) {
self.keep_on_drop = true;
}
pub fn release(&mut self) {
self.keep_on_drop = false;
self.committed_at_unix = Some(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
);
}
#[allow(dead_code)]
pub fn op_id(&self) -> Option<&str> {
self.op_id.as_deref()
}
}
impl Drop for JournalGuard {
fn drop(&mut self) {
if self.keep_on_drop {
return;
}
if self.path.as_os_str().is_empty() {
return;
}
let committed_at = self.committed_at_unix.unwrap_or(0);
if heuristics_should_preserve(&self.path, committed_at, u64::MAX, u64::MAX) {
tracing::debug!(
path = %self.path.display(),
"G119 L4: heuristics voted to preserve sidecar; skipping remove"
);
return;
}
if let Err(e) = fs::remove_file(&self.path) {
tracing::debug!(path = %self.path.display(), error = %e,
"journal guard: sidecar removal failed (will be reaped later)");
}
}
}
pub fn journal_started_with_guard(
target: &Path,
op: JournalOp,
checksum_before: Option<Hash>,
checksum_after: Hash,
) -> Result<(String, JournalGuard)> {
let op_id = journal_started(target, op, checksum_before, checksum_after)?;
let path = journal_path(target);
let guard = JournalGuard {
path,
keep_on_drop: true,
op_id: Some(op_id.clone()),
committed_at_unix: None,
};
Ok((op_id, guard))
}