use crate::prelude::*;
use crate::output::Line;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
const FORMAT: &str = "wsb-op-log/1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Header {
pub log: String,
pub format: String,
pub id: uuid::Uuid,
pub kind: String,
pub label: String,
pub argv: Vec<String>,
pub started_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Footer {
pub log: String,
pub status: Status,
pub ended_at: chrono::DateTime<chrono::Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub lines: u64,
}
pub struct OpLogWriter {
path: PathBuf,
file: Mutex<Option<std::fs::File>>,
lines: std::sync::atomic::AtomicU64,
}
impl OpLogWriter {
pub fn create(
logs_root: &Path,
id: uuid::Uuid,
kind: &str,
label: &str,
argv: &[String],
started_at: chrono::DateTime<chrono::Utc>,
) -> Result<Self, String> {
let path = super::op_log_path(logs_root, kind, &id, started_at);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
}
let mut file = std::fs::File::create(&path)
.map_err(|e| format!("cannot create {}: {e}", path.display()))?;
let header = Header {
log: "header".to_string(),
format: FORMAT.to_string(),
id,
kind: kind.to_string(),
label: label.to_string(),
argv: argv.to_vec(),
started_at,
};
writeln!(
file,
"{}",
serde_json::to_string(&header).map_err(|e| e.to_string())?
)
.map_err(|e| format!("cannot write to {}: {e}", path.display()))?;
Ok(Self {
path,
file: Mutex::new(Some(file)),
lines: std::sync::atomic::AtomicU64::new(0),
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn write_line(&self, line: &Line) {
let Ok(encoded) = serde_json::to_string(line) else {
return;
};
let mut guard = match self.file.lock() {
Ok(guard) => guard,
Err(_) => return,
};
let Some(file) = guard.as_mut() else {
return;
};
if let Err(error) = writeln!(file, "{encoded}") {
tracing::warn!(path = %self.path.display(), %error, "an operation's log could not be written");
*guard = None;
return;
}
let _ = file.flush();
self.lines
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
pub fn finish(&self, status: Status, error: Option<String>) {
let footer = Footer {
log: "footer".to_string(),
status,
ended_at: chrono::Utc::now(),
error,
lines: self.lines.load(std::sync::atomic::Ordering::Relaxed),
};
let mut guard = match self.file.lock() {
Ok(guard) => guard,
Err(_) => return,
};
let Some(file) = guard.as_mut() else {
return;
};
if let Ok(encoded) = serde_json::to_string(&footer) {
let _ = writeln!(file, "{encoded}");
let _ = file.flush();
}
*guard = None;
}
}
#[derive(Debug, Clone)]
pub struct ReadLog {
pub header: Header,
pub footer: Option<Footer>,
pub lines: Vec<Line>,
pub unreadable: u64,
pub path: PathBuf,
}
pub fn read(path: &Path) -> Result<ReadLog, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
let mut header: Option<Header> = None;
let mut footer: Option<Footer> = None;
let mut lines = Vec::new();
let mut unreadable = 0u64;
for raw in content.lines() {
if raw.trim().is_empty() {
continue;
}
match serde_json::from_str::<serde_json::Value>(raw) {
Ok(value) => match value.get("log").and_then(serde_json::Value::as_str) {
Some("header") => header = serde_json::from_value(value).ok(),
Some("footer") => footer = serde_json::from_value(value).ok(),
_ => match serde_json::from_value::<Line>(value) {
Ok(line) => lines.push(line),
Err(_) => unreadable += 1,
},
},
Err(_) => unreadable += 1,
}
}
let header = header.ok_or_else(|| {
format!(
"{} has no header line, so nothing can say what it is a log of",
path.display()
)
})?;
if header.format != FORMAT {
return Err(format!(
"{} is in format {}, which this daemon does not read (it writes {FORMAT})",
path.display(),
header.format
));
}
Ok(ReadLog {
header,
footer,
lines,
unreadable,
path: path.to_path_buf(),
})
}
pub fn read_envelope(path: &Path) -> Result<(Header, Option<Footer>), String> {
use std::io::{BufRead, BufReader, Seek, SeekFrom};
let file = std::fs::File::open(path)
.map_err(|e| format!("cannot open {}: {e}", path.display()))?;
let size = file
.metadata()
.map_err(|e| format!("cannot stat {}: {e}", path.display()))?
.len();
let mut reader = BufReader::new(file);
let mut first = String::new();
reader
.read_line(&mut first)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
let header: Header = serde_json::from_str(first.trim()).map_err(|_| {
format!(
"{} does not begin with a log header",
path.display()
)
})?;
const TAIL: u64 = 8 * 1024;
let from = size.saturating_sub(TAIL);
let mut file = reader.into_inner();
let footer = file
.seek(SeekFrom::Start(from))
.ok()
.and_then(|_| {
let mut tail = String::new();
use std::io::Read;
file.read_to_string(&mut tail).ok().map(|_| tail)
})
.and_then(|tail| {
tail.lines()
.rev()
.find_map(|line| serde_json::from_str::<Footer>(line).ok())
})
.filter(|f: &Footer| f.log == "footer");
Ok((header, footer))
}
pub fn recent_logs(logs_root: &Path, limit: usize) -> Vec<PathBuf> {
let ops_dir = super::stream_dir(logs_root, super::Stream::Ops);
let mut days: Vec<PathBuf> = std::fs::read_dir(&ops_dir)
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect();
days.sort();
let mut found = Vec::new();
for day in days.into_iter().rev() {
let mut files: Vec<PathBuf> = std::fs::read_dir(&day)
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "jsonl"))
.collect();
files.sort();
for path in files.into_iter().rev() {
found.push(path);
if found.len() >= limit {
return found;
}
}
}
found
}
#[cfg(test)]
#[path = "op_log_tests.rs"]
mod tests;