use std::{
fs::{File, OpenOptions},
io::Write as _,
os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _},
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use serde_json::{Map, Value};
pub(crate) const RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);
const VERSION: u64 = 1;
#[derive(Debug)]
pub(crate) struct Journal {
id: String,
path: PathBuf,
file: File,
seq: u64,
finished: bool,
#[cfg(test)]
pub(crate) fail_from: Option<u64>,
}
impl Journal {
pub(crate) fn create(dir: &Path) -> std::io::Result<Self> {
if std::fs::symlink_metadata(dir).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
return Err(std::io::Error::other(format!(
"{} is a symlink",
dir.display()
)));
}
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)?;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
prune(dir, RETENTION, SystemTime::now());
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let id = format!(
"rev-{seconds}-{}",
&uuid::Uuid::new_v4().simple().to_string()[..6]
);
let path = dir.join(format!("{id}.jsonl"));
let file = OpenOptions::new()
.append(true)
.create_new(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(&path)?;
Ok(Self {
id,
path,
file,
seq: 0,
finished: false,
#[cfg(test)]
fail_from: None,
})
}
pub(crate) fn id(&self) -> &str {
&self.id
}
pub(crate) fn is_started(&self) -> bool {
self.seq > 0
}
pub(crate) fn is_finished(&self) -> bool {
self.finished
}
pub(crate) fn append(&mut self, event: &str, data: Value) -> std::io::Result<()> {
#[cfg(test)]
if self.fail_from.is_some_and(|from| self.seq + 1 >= from) {
return Err(std::io::Error::other("injected journal failure"));
}
let mut line = Map::new();
line.insert("v".into(), VERSION.into());
line.insert("seq".into(), (self.seq + 1).into());
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
line.insert(
"ts".into(),
u64::try_from(millis).unwrap_or(u64::MAX).into(),
);
line.insert("event".into(), event.into());
if let Value::Object(fields) = data {
for (key, value) in fields {
line.entry(key).or_insert(value);
}
}
let mut bytes = serde_json::to_vec(&Value::Object(line)).map_err(std::io::Error::other)?;
bytes.push(b'\n');
self.file.write_all(&bytes)?;
self.file.sync_data()?;
self.seq += 1;
if event == "review.finished" {
self.finished = true;
}
Ok(())
}
pub(crate) fn remove_empty(&self) {
if !self.is_started() {
let _ = std::fs::remove_file(&self.path);
}
}
}
pub(crate) fn prune(dir: &Path, older_than: Duration, now: SystemTime) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let Some(cutoff) = now.checked_sub(older_than) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if !(name.starts_with("rev-") && name.ends_with(".jsonl")) {
continue;
}
let Ok(metadata) = std::fs::symlink_metadata(entry.path()) else {
continue;
};
if metadata.is_file() && metadata.modified().is_ok_and(|modified| modified <= cutoff) {
let _ = std::fs::remove_file(entry.path());
}
}
}
#[cfg(test)]
mod tests;