Skip to main content

dig_logging/
janitor.rs

1//! The byte-cap janitor (SPEC §4).
2//!
3//! `tracing-appender`'s daily rotation + `max_log_files` bounds the file COUNT, but a runaway-error
4//! day can still balloon a single file past any reasonable disk budget. The janitor adds a
5//! total-bytes cap per service dir: while the directory's `.jsonl.*` files exceed the cap, the
6//! oldest (by modified time) is deleted. The current day's file is protected so live logging never
7//! loses its active file. Runs once at init and hourly thereafter.
8
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12
13/// The env var overriding the retention day count (`tracing-appender` `max_log_files`), default 7.
14pub const ENV_RETENTION_DAYS: &str = "DIG_LOG_RETENTION_DAYS";
15
16/// The env var overriding the per-service-dir byte cap, default 50 MiB.
17pub const ENV_MAX_BYTES: &str = "DIG_LOG_MAX_BYTES";
18
19/// Default retained daily files (SPEC §4).
20pub const DEFAULT_RETENTION_DAYS: usize = 7;
21
22/// Default per-service byte cap: 50 MiB (SPEC §4).
23pub const DEFAULT_MAX_BYTES: u64 = 50 * 1024 * 1024;
24
25/// Read a `usize` env override via an injected getter, falling back to `default` when unset/invalid.
26pub fn retention_days<G: Fn(&str) -> Option<String>>(get: G) -> usize {
27    get(ENV_RETENTION_DAYS)
28        .and_then(|value| value.trim().parse::<usize>().ok())
29        .filter(|days| *days > 0)
30        .unwrap_or(DEFAULT_RETENTION_DAYS)
31}
32
33/// Read the byte cap env override via an injected getter, falling back to [`DEFAULT_MAX_BYTES`].
34pub fn max_bytes<G: Fn(&str) -> Option<String>>(get: G) -> u64 {
35    get(ENV_MAX_BYTES)
36        .and_then(|value| value.trim().parse::<u64>().ok())
37        .filter(|bytes| *bytes > 0)
38        .unwrap_or(DEFAULT_MAX_BYTES)
39}
40
41/// A log file with the facts the janitor sorts + prunes on.
42struct Entry {
43    path: PathBuf,
44    bytes: u64,
45    modified: SystemTime,
46}
47
48/// List the service's rotated log files (`<service>.jsonl.*`), newest excluded from deletion by the
49/// caller. Unreadable entries are skipped (best-effort cleanup must never fail logging).
50fn list_log_files(dir: &Path, service: &str) -> Vec<Entry> {
51    let prefix = format!("{service}.jsonl");
52    let mut entries: Vec<Entry> = fs::read_dir(dir)
53        .into_iter()
54        .flatten()
55        .flatten()
56        .filter_map(|dirent| {
57            let path = dirent.path();
58            let name = path.file_name()?.to_string_lossy().into_owned();
59            if !name.starts_with(&prefix) {
60                return None;
61            }
62            let meta = dirent.metadata().ok()?;
63            Some(Entry {
64                path,
65                bytes: meta.len(),
66                modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
67            })
68        })
69        .collect();
70    // Oldest first, so deletion walks from the front.
71    entries.sort_by_key(|entry| entry.modified);
72    entries
73}
74
75/// Enforce the byte cap on `dir` for `service`: delete oldest files while the total exceeds
76/// `max_bytes`, never deleting the newest (the live) file. Returns the number of files deleted.
77/// Pure with respect to its inputs (a real directory) — table-testable against a temp dir.
78pub fn enforce_byte_cap(dir: &Path, service: &str, max_bytes: u64) -> usize {
79    let entries = list_log_files(dir, service);
80    let mut total: u64 = entries.iter().map(|entry| entry.bytes).sum();
81    let mut deleted = 0;
82    // Protect the newest file: iterate all-but-last, oldest first.
83    let prunable = entries.len().saturating_sub(1);
84    for entry in entries.iter().take(prunable) {
85        if total <= max_bytes {
86            break;
87        }
88        if fs::remove_file(&entry.path).is_ok() {
89            total -= entry.bytes;
90            deleted += 1;
91        }
92    }
93    deleted
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use std::io::Write;
100    use std::thread::sleep;
101    use std::time::Duration;
102
103    fn write_file(dir: &Path, name: &str, bytes: usize) {
104        let mut file = fs::File::create(dir.join(name)).unwrap();
105        file.write_all(&vec![b'x'; bytes]).unwrap();
106    }
107
108    #[test]
109    fn env_overrides_parse_or_fall_back() {
110        assert_eq!(retention_days(|_| Some("14".into())), 14);
111        assert_eq!(
112            retention_days(|_| Some("junk".into())),
113            DEFAULT_RETENTION_DAYS
114        );
115        assert_eq!(retention_days(|_| None), DEFAULT_RETENTION_DAYS);
116        assert_eq!(max_bytes(|_| Some("1024".into())), 1024);
117        assert_eq!(max_bytes(|_| None), DEFAULT_MAX_BYTES);
118    }
119
120    #[test]
121    fn prunes_oldest_until_under_cap_and_keeps_newest() {
122        let dir = tempfile::tempdir().unwrap();
123        // Three 1 KiB files, distinct mtimes; cap at 1.5 KiB should leave the newest only.
124        for (i, name) in [
125            "dig-node.jsonl.2026-07-14",
126            "dig-node.jsonl.2026-07-15",
127            "dig-node.jsonl.2026-07-16",
128        ]
129        .iter()
130        .enumerate()
131        {
132            write_file(dir.path(), name, 1024);
133            if i < 2 {
134                sleep(Duration::from_millis(20));
135            }
136        }
137        let deleted = enforce_byte_cap(dir.path(), "dig-node", 1536);
138        assert_eq!(deleted, 2);
139        assert!(
140            dir.path().join("dig-node.jsonl.2026-07-16").exists(),
141            "newest is protected"
142        );
143        assert!(!dir.path().join("dig-node.jsonl.2026-07-14").exists());
144    }
145
146    #[test]
147    fn under_cap_deletes_nothing() {
148        let dir = tempfile::tempdir().unwrap();
149        write_file(dir.path(), "dig-dns.jsonl.2026-07-16", 100);
150        assert_eq!(
151            enforce_byte_cap(dir.path(), "dig-dns", DEFAULT_MAX_BYTES),
152            0
153        );
154    }
155
156    #[test]
157    fn ignores_foreign_files() {
158        let dir = tempfile::tempdir().unwrap();
159        write_file(dir.path(), "unrelated.txt", 10_000);
160        write_file(dir.path(), "dig-node.jsonl.2026-07-16", 100);
161        // Even a tiny cap must not touch a non-matching file.
162        enforce_byte_cap(dir.path(), "dig-node", 1);
163        assert!(dir.path().join("unrelated.txt").exists());
164    }
165}