use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::sync::mpsc;
use crate::{Log, LogError, is_heartbeat};
const ASYNC_LOG_CHANNEL_CAPACITY: usize = 1024;
#[derive(Debug, Clone, Copy, Default)]
pub struct RetentionPolicy {
pub generations: Option<u32>,
pub roll_interval: Option<Duration>,
}
#[derive(Debug, Clone, Copy)]
pub struct FileLogOptions {
pub include_heartbeats: bool,
pub include_timestamp: bool,
pub include_milliseconds: bool,
pub max_size_bytes: Option<u64>,
pub retention: Option<RetentionPolicy>,
}
impl Default for FileLogOptions {
fn default() -> Self {
Self {
include_heartbeats: true,
include_timestamp: false,
include_milliseconds: false,
max_size_bytes: None,
retention: None,
}
}
}
pub(crate) fn format_timestamp_prefix(include_millis: bool) -> String {
let now = time::OffsetDateTime::now_utc();
if include_millis {
format!(
"{:04}{:02}{:02}-{:02}:{:02}:{:02}.{:03} ",
now.year(),
u8::from(now.month()),
now.day(),
now.hour(),
now.minute(),
now.second(),
now.millisecond()
)
} else {
format!(
"{:04}{:02}{:02}-{:02}:{:02}:{:02} ",
now.year(),
u8::from(now.month()),
now.day(),
now.hour(),
now.minute(),
now.second()
)
}
}
fn format_date_stamp(at: time::OffsetDateTime) -> String {
format!(
"{:04}{:02}{:02}-{:02}{:02}{:02}",
at.year(),
u8::from(at.month()),
at.day(),
at.hour(),
at.minute(),
at.second()
)
}
fn interval_key(interval: Duration, at: time::OffsetDateTime) -> i128 {
let interval_ns = interval.as_nanos().max(1);
at.unix_timestamp_nanos().div_euclid(interval_ns as i128)
}
fn interval_start(interval: Duration, key: i128) -> time::OffsetDateTime {
let interval_ns = interval.as_nanos().max(1) as i128;
time::OffsetDateTime::from_unix_timestamp_nanos(key.saturating_mul(interval_ns))
.unwrap_or(time::OffsetDateTime::UNIX_EPOCH)
}
fn suffixed(path: &Path, suffix: &str) -> PathBuf {
let mut s = path.to_path_buf().into_os_string();
s.push(".");
s.push(suffix);
PathBuf::from(s)
}
enum Entry {
Message(String),
Event(String),
}
pub struct FileLog {
tx: std::sync::Mutex<Option<mpsc::Sender<Entry>>>,
task: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
options: FileLogOptions,
}
impl FileLog {
pub async fn open(dir: &Path) -> Result<Self, LogError> {
Self::open_with_options(dir, FileLogOptions::default()).await
}
pub async fn open_with_options(dir: &Path, options: FileLogOptions) -> Result<Self, LogError> {
if let Some(retention) = options.retention {
if retention.generations == Some(0) {
return Err(LogError::Io(
"RetentionPolicy::generations must be at least 1 when set (0 is \
indistinguishable from no policy)"
.to_owned(),
));
}
if retention.roll_interval == Some(Duration::ZERO) {
return Err(LogError::Io(
"RetentionPolicy::roll_interval must be greater than zero when set".to_owned(),
));
}
}
std::fs::create_dir_all(dir).map_err(|e| LogError::Io(e.to_string()))?;
let messages = RotatingFile::open(
dir.join("messages.log"),
options.max_size_bytes,
options.retention,
)?;
let events = RotatingFile::open(
dir.join("event.log"),
options.max_size_bytes,
options.retention,
)?;
let (tx, mut rx) = mpsc::channel::<Entry>(ASYNC_LOG_CHANNEL_CAPACITY);
let task = tokio::spawn(async move {
let mut messages = messages;
let mut events = events;
while let Some(entry) = rx.recv().await {
let outcome =
tokio::task::spawn_blocking(move || write_entry(messages, events, entry)).await;
let Ok((m, e, result)) = outcome else {
report_write_failure("file log background writer task panicked");
break;
};
messages = m;
events = e;
if let Err(err) = result {
report_write_failure(&err.to_string());
}
}
});
Ok(Self {
tx: std::sync::Mutex::new(Some(tx)),
task: std::sync::Mutex::new(Some(task)),
options,
})
}
fn timestamp_prefix(&self) -> String {
if self.options.include_timestamp {
format_timestamp_prefix(self.options.include_milliseconds)
} else {
String::new()
}
}
fn send(&self, entry: Entry) {
if let Ok(guard) = self.tx.lock()
&& let Some(tx) = guard.as_ref()
{
let _ = tx.try_send(entry);
}
}
}
fn report_write_failure(detail: &str) {
tracing::error!(target: "truefix_log", error = %detail, "failed to write log line");
metrics::counter!("truefix_log_write_failures_total").increment(1);
}
fn write_entry(
mut messages: RotatingFile,
mut events: RotatingFile,
entry: Entry,
) -> (RotatingFile, RotatingFile, std::io::Result<()>) {
let result = match &entry {
Entry::Message(line) => messages.write_line(line),
Entry::Event(line) => events.write_line(line),
};
(messages, events, result)
}
fn open_append(path: &Path) -> Result<File, LogError> {
OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| LogError::Io(e.to_string()))
}
struct RotatingFile {
path: PathBuf,
file: File,
size: u64,
max_size_bytes: Option<u64>,
retention: Option<RetentionPolicy>,
current_interval_key: Option<i128>,
}
impl RotatingFile {
fn open(
path: PathBuf,
max_size_bytes: Option<u64>,
retention: Option<RetentionPolicy>,
) -> Result<Self, LogError> {
let file = open_append(&path)?;
let metadata = file.metadata().ok();
let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
let current_interval_key = retention.and_then(|r| r.roll_interval).map(|interval| {
let at = if size > 0 {
metadata
.and_then(|m| m.modified().ok())
.map(time::OffsetDateTime::from)
.unwrap_or_else(time::OffsetDateTime::now_utc)
} else {
time::OffsetDateTime::now_utc()
};
interval_key(interval, at)
});
Ok(Self {
path,
file,
size,
max_size_bytes,
retention,
current_interval_key,
})
}
fn write_line(&mut self, line: &str) -> std::io::Result<()> {
if let Some(interval) = self.retention.and_then(|r| r.roll_interval) {
let now_key = interval_key(interval, time::OffsetDateTime::now_utc());
if self.current_interval_key.is_some_and(|k| k != now_key) {
self.rotate()?;
}
self.current_interval_key = Some(now_key);
}
if let Some(max) = self.max_size_bytes
&& self.size >= max
{
self.rotate()?;
}
writeln!(self.file, "{line}")?;
self.size += line.len() as u64 + 1;
Ok(())
}
fn rotate(&mut self) -> std::io::Result<()> {
match self.retention {
Some(RetentionPolicy {
roll_interval: Some(interval),
generations,
}) => {
let key = self
.current_interval_key
.unwrap_or_else(|| interval_key(interval, time::OffsetDateTime::now_utc()));
let stamp = format_date_stamp(interval_start(interval, key));
let backup = suffixed(&self.path, &stamp);
let _ = std::fs::rename(&self.path, &backup);
if let Some(n) = generations {
self.prune_dated_backups(n);
}
}
Some(RetentionPolicy {
roll_interval: None,
generations: Some(n),
}) => {
self.shift_numeric_backups(n);
let _ = std::fs::rename(&self.path, suffixed(&self.path, "1"));
}
_ => {
let _ = std::fs::rename(&self.path, suffixed(&self.path, "1"));
}
}
self.file = open_append(&self.path).map_err(|e| match e {
LogError::Io(msg) => std::io::Error::other(msg),
})?;
self.size = 0;
Ok(())
}
fn shift_numeric_backups(&self, n: u32) {
if n == 0 {
return;
}
let oldest = suffixed(&self.path, &n.to_string());
let _ = std::fs::remove_file(&oldest);
for generation in (1..n).rev() {
let from = suffixed(&self.path, &generation.to_string());
let to = suffixed(&self.path, &(generation + 1).to_string());
let _ = std::fs::rename(&from, &to);
}
}
fn prune_dated_backups(&self, keep: u32) {
let Some(parent) = self.path.parent() else {
return;
};
let Some(file_name) = self.path.file_name().and_then(|f| f.to_str()) else {
return;
};
let prefix = format!("{file_name}.");
let Ok(entries) = std::fs::read_dir(parent) else {
return;
};
let mut dated: Vec<(String, PathBuf)> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_str()?.to_owned();
let suffix = name.strip_prefix(&prefix)?.to_owned();
let looks_dated = suffix.len() == "YYYYMMDD-HHMMSS".len()
&& suffix
.chars()
.enumerate()
.all(|(i, c)| if i == 8 { c == '-' } else { c.is_ascii_digit() });
looks_dated.then_some((suffix, e.path()))
})
.collect();
dated.sort_by(|a, b| b.0.cmp(&a.0));
for (_, path) in dated.into_iter().skip(keep as usize) {
let _ = std::fs::remove_file(path);
}
}
}
#[async_trait::async_trait]
impl Log for FileLog {
fn on_incoming(&self, message: &str) {
if is_heartbeat(message) && !self.options.include_heartbeats {
return;
}
self.send(Entry::Message(format!(
"{}I {message}",
self.timestamp_prefix()
)));
}
fn on_outgoing(&self, message: &str) {
if is_heartbeat(message) && !self.options.include_heartbeats {
return;
}
self.send(Entry::Message(format!(
"{}O {message}",
self.timestamp_prefix()
)));
}
fn on_event(&self, text: &str) {
self.send(Entry::Event(format!(
"{}{text}",
format_timestamp_prefix(self.options.include_milliseconds)
)));
}
async fn shutdown(&self) {
let tx = self.tx.lock().ok().and_then(|mut guard| guard.take());
drop(tx);
let task = self.task.lock().ok().and_then(|mut guard| guard.take());
if let Some(task) = task {
let _ = task.await;
}
}
}