diff --git a/crates/xy-supervisor/src/lib.rs b/crates/xy-supervisor/src/lib.rs index be9d9e6..1aa7124 100644 --- a/crates/xy-supervisor/src/lib.rs +++ b/crates/xy-supervisor/src/lib.rs @@ -2,10 +2,12 @@ pub mod backoff; pub mod child; +pub mod logs; pub mod policy; pub mod retry_window; pub use backoff::Backoff; pub use child::{ChildHandle, MockChild, MockChildController}; +pub use logs::RotatingLogWriter; pub use policy::{RestartDecision, decide}; pub use retry_window::RetryWindow; diff --git a/crates/xy-supervisor/src/logs.rs b/crates/xy-supervisor/src/logs.rs new file mode 100644 index 0000000..0c66204 --- /dev/null +++ b/crates/xy-supervisor/src/logs.rs @@ -0,0 +1,104 @@ +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +pub struct RotatingLogWriter { + base: PathBuf, + max_bytes: u64, + keep: usize, + file: File, + written: u64, +} + +impl RotatingLogWriter { + pub fn open(base: &Path, max_bytes: u64, keep: usize) -> std::io::Result { + if let Some(parent) = base.parent() { + std::fs::create_dir_all(parent)?; + } + let file = OpenOptions::new().create(true).append(true).open(base)?; + let written = file.metadata()?.len(); + Ok(Self { + base: base.to_path_buf(), + max_bytes, + keep, + file, + written, + }) + } + + pub fn write_line(&mut self, tag: &str, line: &str) -> std::io::Result<()> { + let bytes = format!("{tag} {line}\n"); + self.file.write_all(bytes.as_bytes())?; + self.written += bytes.len() as u64; + if self.written >= self.max_bytes { + self.rotate()?; + } + Ok(()) + } + + fn rotate(&mut self) -> std::io::Result<()> { + // Drop the current handle by replacing with /dev/null briefly. + self.file = OpenOptions::new().read(true).open("/dev/null")?; + for i in (1..self.keep).rev() { + let src = self.gen_path(i); + let dst = self.gen_path(i + 1); + if src.exists() { + let _ = std::fs::rename(&src, &dst); + } + } + if self.base.exists() { + let _ = std::fs::rename(&self.base, self.gen_path(1)); + } + self.file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.base)?; + self.written = 0; + Ok(()) + } + + fn gen_path(&self, n: usize) -> PathBuf { + let mut s = self.base.as_os_str().to_os_string(); + s.push(format!(".{n}")); + PathBuf::from(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + use std::io::Read; + + #[test] + fn writes_lines_with_tags() { + let dir = tempdir().unwrap(); + let base = dir.path().join("x.log"); + let mut w = RotatingLogWriter::open(&base, 1024, 3).unwrap(); + w.write_line("[out]", "hello").unwrap(); + w.write_line("[err]", "boom").unwrap(); + let mut s = String::new(); + File::open(&base) + .unwrap() + .read_to_string(&mut s) + .unwrap(); + assert_eq!(s, "[out] hello\n[err] boom\n"); + } + + #[test] + fn rotates_at_threshold() { + let dir = tempdir().unwrap(); + let base = dir.path().join("x.log"); + let mut w = RotatingLogWriter::open(&base, 20, 3).unwrap(); + for _ in 0..5 { + w.write_line("[out]", "0123456789").unwrap(); + } + assert!(base.exists()); + let rotated = dir.path().join("x.log.1"); + assert!( + rotated.exists(), + "expected rotated file at {}", + rotated.display() + ); + } +}