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