Skip to main content

saddle_observability/
calendar_writer.rs

1use std::{
2    fs::{self, File, OpenOptions},
3    io::{self, Write},
4    path::{Path, PathBuf},
5    time::{SystemTime, UNIX_EPOCH},
6};
7
8use rustix::fs::{CWD, FlockOperation, RenameFlags, flock, renameat_with};
9use time::{OffsetDateTime, UtcOffset};
10
11const ACTIVE_NAME: &str = "saddle.log";
12const LOCK_NAME: &str = ".saddle.log.lock";
13
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
15pub enum Rotation {
16    #[default]
17    Daily,
18    Hourly,
19}
20
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct FileLoggingConfig {
23    directory: PathBuf,
24    rotation: Rotation,
25    context: crate::ContextLoggingConfig,
26    active_name: String,
27}
28
29impl FileLoggingConfig {
30    pub fn new(directory: impl Into<PathBuf>, rotation: Rotation) -> Self {
31        Self {
32            directory: directory.into(),
33            rotation,
34            context: crate::ContextLoggingConfig::default(),
35            active_name: ACTIVE_NAME.into(),
36        }
37    }
38
39    pub fn with_context(mut self, context: crate::ContextLoggingConfig) -> Result<Self, crate::ContextLoggingError> {
40        context.validate()?;
41        self.context = context;
42        Ok(self)
43    }
44    pub fn context(&self) -> &crate::ContextLoggingConfig { &self.context }
45
46    pub(super) fn for_summary(&self, name: &str) -> Self {
47        let mut config = Self::new(self.directory.clone(), self.rotation);
48        config.active_name = format!("{name}.summary.log");
49        config
50    }
51
52    pub fn directory(&self) -> &Path {
53        &self.directory
54    }
55
56    pub const fn rotation(&self) -> Rotation {
57        self.rotation
58    }
59}
60
61impl Default for FileLoggingConfig {
62    fn default() -> Self {
63        Self::new(PathBuf::from("./logs"), Rotation::Daily)
64    }
65}
66
67#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
68struct Period {
69    year: i32,
70    month: u8,
71    day: u8,
72    hour: u8,
73}
74
75impl Period {
76    fn archive_name(self, rotation: Rotation) -> String { self.archive_name_for(rotation, ACTIVE_NAME) }
77    fn archive_name_for(self, rotation: Rotation, active_name: &str) -> String {
78        match rotation {
79            Rotation::Daily => format!(
80                "{}.{:04}-{:02}-{:02}",
81                active_name, self.year, self.month, self.day
82            ),
83            Rotation::Hourly => format!(
84                "{}.{:04}-{:02}-{:02}-{:02}",
85                active_name, self.year, self.month, self.day, self.hour
86            ),
87        }
88    }
89}
90
91pub(super) struct CalendarFileWriter {
92    directory: PathBuf,
93    active_path: PathBuf,
94    active_name: String,
95    rotation: Rotation,
96    period: Period,
97    file: File,
98    directory_handle: File,
99    _lock: File,
100    clock: Box<dyn Fn() -> io::Result<Period> + Send>,
101}
102
103impl CalendarFileWriter {
104    pub(super) fn open(config: FileLoggingConfig) -> io::Result<Self> {
105        let rotation = config.rotation;
106        Self::open_with_clock(config, Box::new(move || system_period(rotation)))
107    }
108
109    fn open_with_clock(
110        config: FileLoggingConfig,
111        clock: Box<dyn Fn() -> io::Result<Period> + Send>,
112    ) -> io::Result<Self> {
113        fs::create_dir_all(&config.directory)?;
114        let directory_handle = File::open(&config.directory)?;
115        reject_non_regular_if_present(&config.directory.join(if config.active_name == ACTIVE_NAME { LOCK_NAME.into() } else { format!(".{}.lock", config.active_name) }))?;
116        let lock = OpenOptions::new()
117            .read(true)
118            .write(true)
119            .create(true)
120            .truncate(false)
121            .open(config.directory.join(if config.active_name == ACTIVE_NAME { LOCK_NAME.into() } else { format!(".{}.lock", config.active_name) }))?;
122        flock(&lock, FlockOperation::NonBlockingLockExclusive)
123            .map_err(|error| io::Error::from_raw_os_error(error.raw_os_error()))?;
124
125        let active_path = config.directory.join(&config.active_name);
126        reject_non_regular_if_present(&active_path)?;
127        let current = clock()?;
128        if active_path.exists() {
129            let active_period =
130                period_at(fs::metadata(&active_path)?.modified()?, config.rotation)?;
131            if active_period > current {
132                return Err(io::Error::other(
133                    "saddle.log belongs to a future calendar period",
134                ));
135            }
136            if active_period < current {
137                archive_no_replace(
138                    &active_path,
139                    &config
140                        .directory
141                        .join(active_period.archive_name_for(config.rotation, &config.active_name)),
142                )?;
143                directory_handle.sync_all()?;
144            }
145        }
146        let file = open_active(&active_path)?;
147        directory_handle.sync_all()?;
148        Ok(Self {
149            directory: config.directory,
150            active_path,
151            active_name: config.active_name,
152            rotation: config.rotation,
153            period: current,
154            file,
155            directory_handle,
156            _lock: lock,
157            clock,
158        })
159    }
160
161    fn rotate_if_needed(&mut self) -> io::Result<()> {
162        let current = (self.clock)()?;
163        if current < self.period {
164            return Err(io::Error::other("system calendar moved backwards"));
165        }
166        if current == self.period {
167            return Ok(());
168        }
169
170        self.file.flush()?;
171        self.file.sync_data()?;
172        let archive = self.directory.join(self.period.archive_name_for(self.rotation, &self.active_name));
173        archive_no_replace(&self.active_path, &archive)?;
174        self.directory_handle.sync_all()?;
175        self.file = open_active(&self.active_path)?;
176        self.directory_handle.sync_all()?;
177        self.period = current;
178        Ok(())
179    }
180}
181
182impl Write for CalendarFileWriter {
183    fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
184        self.rotate_if_needed()?;
185        self.file.write(buffer)
186    }
187
188    fn flush(&mut self) -> io::Result<()> {
189        self.file.flush()
190    }
191}
192
193fn open_active(path: &Path) -> io::Result<File> {
194    OpenOptions::new().create(true).append(true).open(path)
195}
196
197fn reject_non_regular_if_present(path: &Path) -> io::Result<()> {
198    match fs::symlink_metadata(path) {
199        Ok(metadata) if !metadata.file_type().is_file() => {
200            Err(io::Error::other("log path is not a regular file"))
201        }
202        Ok(_) => Ok(()),
203        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
204        Err(error) => Err(error),
205    }
206}
207
208fn archive_no_replace(active: &Path, archive: &Path) -> io::Result<()> {
209    renameat_with(CWD, active, CWD, archive, RenameFlags::NOREPLACE)
210        .map_err(|error| io::Error::from_raw_os_error(error.raw_os_error()))
211}
212
213fn system_period(rotation: Rotation) -> io::Result<Period> {
214    period_at(SystemTime::now(), rotation)
215}
216
217fn period_at(time: SystemTime, rotation: Rotation) -> io::Result<Period> {
218    let seconds = time
219        .duration_since(UNIX_EPOCH)
220        .map_err(|_| io::Error::other("calendar time predates the Unix epoch"))?
221        .as_secs();
222    let seconds = i64::try_from(seconds).map_err(|_| io::Error::other("calendar time overflow"))?;
223    let utc = OffsetDateTime::from_unix_timestamp(seconds)
224        .map_err(|_| io::Error::other("calendar time is out of range"))?;
225    let offset = UtcOffset::local_offset_at(utc)
226        .map_err(|_| io::Error::other("operating-system timezone is unavailable"))?;
227    let local = utc.to_offset(offset);
228    Ok(Period {
229        year: local.year(),
230        month: u8::from(local.month()),
231        day: local.day(),
232        hour: match rotation {
233            Rotation::Daily => 0,
234            Rotation::Hourly => local.hour(),
235        },
236    })
237}
238
239#[cfg(test)]
240mod tests {
241    use std::{
242        fs::FileTimes,
243        sync::{Arc, Mutex},
244        time::Duration,
245    };
246
247    use super::*;
248
249    fn directory(name: &str) -> PathBuf {
250        let path = std::env::temp_dir().join(format!(
251            "saddle-calendar-writer-{name}-{}-{}",
252            std::process::id(),
253            SystemTime::now()
254                .duration_since(UNIX_EPOCH)
255                .unwrap()
256                .as_nanos()
257        ));
258        fs::create_dir_all(&path).unwrap();
259        path
260    }
261
262    fn period(day: u8, hour: u8) -> Period {
263        Period {
264            year: 2026,
265            month: 8,
266            day,
267            hour,
268        }
269    }
270
271    #[test]
272    fn rotates_daily_without_replacing_archive() {
273        let path = directory("daily");
274        let now = Arc::new(Mutex::new(period(28, 23)));
275        let clock_now = Arc::clone(&now);
276        let mut writer = CalendarFileWriter::open_with_clock(
277            FileLoggingConfig::new(&path, Rotation::Daily),
278            Box::new(move || Ok(*clock_now.lock().unwrap())),
279        )
280        .unwrap();
281        writer.write_all(b"before\n").unwrap();
282        *now.lock().unwrap() = period(29, 0);
283        writer.write_all(b"after\n").unwrap();
284        writer.flush().unwrap();
285
286        assert_eq!(
287            fs::read(path.join("saddle.log.2026-08-28")).unwrap(),
288            b"before\n"
289        );
290        assert_eq!(fs::read(path.join(ACTIVE_NAME)).unwrap(), b"after\n");
291        fs::remove_dir_all(path).unwrap();
292    }
293
294    #[test]
295    fn startup_appends_same_period_and_rotates_stale_active() {
296        let path = directory("restart");
297        let active = path.join(ACTIVE_NAME);
298        fs::write(&active, b"old\n").unwrap();
299        let old = UNIX_EPOCH + Duration::from_secs(1_777_000_000);
300        File::options()
301            .write(true)
302            .open(&active)
303            .unwrap()
304            .set_times(FileTimes::new().set_modified(old))
305            .unwrap();
306        let old_period = period_at(old, Rotation::Daily).unwrap();
307        let current = Period {
308            day: old_period.day.saturating_add(1),
309            ..old_period
310        };
311        let mut writer = CalendarFileWriter::open_with_clock(
312            FileLoggingConfig::new(&path, Rotation::Daily),
313            Box::new(move || Ok(current)),
314        )
315        .unwrap();
316        writer.write_all(b"new\n").unwrap();
317        writer.flush().unwrap();
318        assert_eq!(
319            fs::read(path.join(old_period.archive_name(Rotation::Daily))).unwrap(),
320            b"old\n"
321        );
322        assert_eq!(fs::read(&active).unwrap(), b"new\n");
323        fs::remove_dir_all(path).unwrap();
324    }
325
326    #[test]
327    fn lock_and_archive_collision_fail_closed() {
328        let path = directory("failure");
329        let now = Arc::new(Mutex::new(period(28, 15)));
330        let first_now = Arc::clone(&now);
331        let mut first = CalendarFileWriter::open_with_clock(
332            FileLoggingConfig::new(&path, Rotation::Hourly),
333            Box::new(move || Ok(*first_now.lock().unwrap())),
334        )
335        .unwrap();
336        assert!(
337            CalendarFileWriter::open_with_clock(
338                FileLoggingConfig::new(&path, Rotation::Hourly),
339                Box::new(|| Ok(period(28, 15))),
340            )
341            .is_err()
342        );
343
344        first.write_all(b"record\n").unwrap();
345        fs::write(path.join("saddle.log.2026-08-28-15"), b"collision\n").unwrap();
346        *now.lock().unwrap() = period(28, 16);
347        assert!(first.write_all(b"must-fail\n").is_err());
348        assert_eq!(
349            fs::read(path.join("saddle.log.2026-08-28-15")).unwrap(),
350            b"collision\n"
351        );
352        drop(first);
353        fs::remove_dir_all(path).unwrap();
354    }
355
356    #[test]
357    fn clock_rollback_fails_closed() {
358        let path = directory("rollback");
359        let now = Arc::new(Mutex::new(period(28, 16)));
360        let clock_now = Arc::clone(&now);
361        let mut writer = CalendarFileWriter::open_with_clock(
362            FileLoggingConfig::new(&path, Rotation::Hourly),
363            Box::new(move || Ok(*clock_now.lock().unwrap())),
364        )
365        .unwrap();
366        *now.lock().unwrap() = period(28, 15);
367        assert!(writer.write_all(b"must-fail\n").is_err());
368        assert_eq!(fs::read(path.join(ACTIVE_NAME)).unwrap(), b"");
369        drop(writer);
370        fs::remove_dir_all(path).unwrap();
371    }
372}