Skip to main content

cortexkit_log/
segment.rs

1//! Date-stamped log segments: `<dir>/<module_id>.<YYYY-MM-DD>.log`.
2//!
3//! The file is named by the UTC calendar day of each write and is NEVER
4//! renamed. Every process that logs for a module — the supervised process and
5//! every harness-hosted plugin — computes the same name from the same clock
6//! and opens it `O_APPEND`, so any number of writers share one segment with
7//! no lock and no coordinator: the kernel lands each `write(2)` at a line
8//! boundary, and midnight rolls the name for everyone at once.
9//!
10//! This is what makes one-file-per-module safe. The r1 design split plugins
11//! into per-harness files because rename-based rotation cannot be shared: a
12//! writer still holding the old descriptor keeps appending into `.log.1`,
13//! silently, because its writes succeed. Removing the rename removes the race.
14//!
15//! What it costs, accepted deliberately: no size cap within a day. A segment
16//! that crosses `alarm_segment_mb` is reported once, because a module writing
17//! that much in a day has a defect worth surfacing and truncating it would
18//! hide the defect.
19
20use std::fs::{self, File, OpenOptions};
21use std::io::{self, Write};
22use std::path::{Path, PathBuf};
23use std::time::SystemTime;
24
25use chrono::{DateTime, NaiveDate, Utc};
26
27/// Retention for date segments: an age window pruned by the writer, and a
28/// per-segment size at which the writer raises an alarm rather than rotating.
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub struct SegmentRetention {
31    /// Segments strictly older than `today - max_age_days` are unlinked at
32    /// process start and at each day roll. `0` keeps today only.
33    pub max_age_days: u32,
34    /// Size in mebibytes at which today's segment is reported as oversized,
35    /// once per process. Never truncates.
36    pub alarm_segment_mb: u32,
37}
38
39impl Default for SegmentRetention {
40    fn default() -> Self {
41        Self {
42            max_age_days: 14,
43            alarm_segment_mb: 256,
44        }
45    }
46}
47
48/// The filename for `module_id`'s segment on the UTC day containing `at`.
49pub fn segment_name(module_id: &str, at: SystemTime) -> String {
50    format!("{module_id}.{}.log", utc_day(at))
51}
52
53/// Parses `<module_id>.<YYYY-MM-DD>.log` back into its day, or `None` for any
54/// name that is not exactly that shape — including the r1 pid-suffixed files
55/// and a malformed date, which are left alone rather than guessed at.
56pub fn segment_day(module_id: &str, file_name: &str) -> Option<NaiveDate> {
57    let rest = file_name.strip_prefix(module_id)?.strip_prefix('.')?;
58    let day = rest.strip_suffix(".log")?;
59    if day.len() != 10 {
60        return None;
61    }
62    NaiveDate::parse_from_str(day, "%Y-%m-%d").ok()
63}
64
65/// Segments older than the window, decided by filename alone. Names that are
66/// not this module's segments are never returned.
67pub fn prune_candidates<'a>(
68    module_id: &str,
69    today: NaiveDate,
70    retention: SegmentRetention,
71    present: impl IntoIterator<Item = &'a str>,
72) -> Vec<&'a str> {
73    let boundary = today
74        .checked_sub_days(chrono::Days::new(u64::from(retention.max_age_days)))
75        .unwrap_or(today);
76    present
77        .into_iter()
78        .filter(|name| segment_day(module_id, name).is_some_and(|day| day < boundary))
79        .collect()
80}
81
82fn utc_day(at: SystemTime) -> NaiveDate {
83    DateTime::<Utc>::from(at).date_naive()
84}
85
86pub(crate) struct SegmentDestination {
87    dir: PathBuf,
88    module_id: String,
89    retention: SegmentRetention,
90    open: Option<(NaiveDate, File)>,
91    alarmed: bool,
92}
93
94/// What a write reported beyond success: the caller decides how to surface it.
95pub(crate) enum SegmentNotice {
96    /// Today's segment crossed `alarm_segment_mb`. Reported at most once.
97    Oversized { path: PathBuf, bytes: u64 },
98    /// A day roll or first open pruned aged segments.
99    Pruned { removed: usize, kept: usize },
100}
101
102impl SegmentDestination {
103    pub(crate) fn open(
104        dir: &Path,
105        module_id: &str,
106        retention: SegmentRetention,
107        now: SystemTime,
108        enforce_directory_mode: bool,
109    ) -> io::Result<(Self, Option<SegmentNotice>)> {
110        prepare_dir(dir, enforce_directory_mode)?;
111        let mut destination = Self {
112            dir: dir.to_owned(),
113            module_id: module_id.to_owned(),
114            retention,
115            open: None,
116            alarmed: false,
117        };
118        let notice = destination.roll_to(utc_day(now))?;
119        Ok((destination, notice))
120    }
121
122    /// Writes one framed line to the segment for `now`, reopening on a day
123    /// roll. Returns any notice the write produced.
124    pub(crate) fn write(
125        &mut self,
126        bytes: &[u8],
127        now: SystemTime,
128    ) -> io::Result<Option<SegmentNotice>> {
129        let today = utc_day(now);
130        let mut notice = None;
131        if self.open.as_ref().map(|(day, _)| *day) != Some(today) {
132            notice = self.roll_to(today)?;
133        }
134        let (_, file) = self
135            .open
136            .as_mut()
137            .ok_or_else(|| io::Error::other("log segment is not open"))?;
138        file.write_all(bytes)?;
139
140        if !self.alarmed {
141            let len = file.metadata()?.len();
142            let cap = u64::from(self.retention.alarm_segment_mb) * 1024 * 1024;
143            if len > cap {
144                self.alarmed = true;
145                notice = Some(SegmentNotice::Oversized {
146                    path: self.dir.join(segment_name(&self.module_id, now)),
147                    bytes: len,
148                });
149            }
150        }
151        Ok(notice)
152    }
153
154    // Opening a segment also prunes: the writer is the only party that runs
155    // unconditionally whenever the module runs, so it is the only party that
156    // can bound the set without a daemon. Pruning decides by filename, never
157    // by stat, and a lost race with another writer's unlink is ENOENT, which
158    // is the correct outcome.
159    fn roll_to(&mut self, day: NaiveDate) -> io::Result<Option<SegmentNotice>> {
160        let (removed, kept) = self.prune(day)?;
161        let path = self.dir.join(format!("{}.{day}.log", self.module_id));
162        let file = open_append(&path)?;
163        self.open = Some((day, file));
164        self.alarmed = false;
165        Ok((removed > 0).then_some(SegmentNotice::Pruned { removed, kept }))
166    }
167
168    fn prune(&self, today: NaiveDate) -> io::Result<(usize, usize)> {
169        let mut names = Vec::new();
170        for entry in fs::read_dir(&self.dir)? {
171            if let Some(name) = entry?.file_name().to_str() {
172                names.push(name.to_owned());
173            }
174        }
175        let owned: Vec<&str> = names
176            .iter()
177            .map(String::as_str)
178            .filter(|name| segment_day(&self.module_id, name).is_some())
179            .collect();
180        let doomed = prune_candidates(
181            &self.module_id,
182            today,
183            self.retention,
184            owned.iter().copied(),
185        );
186        for name in &doomed {
187            match fs::remove_file(self.dir.join(name)) {
188                Ok(()) => {}
189                // Another writer for this module pruned it first. That is the
190                // design working, not a failure.
191                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
192                Err(error) => return Err(error),
193            }
194        }
195        Ok((doomed.len(), owned.len() - doomed.len()))
196    }
197}
198
199fn prepare_dir(dir: &Path, enforce_directory_mode: bool) -> io::Result<()> {
200    let existed = dir.exists();
201    fs::create_dir_all(dir)?;
202    #[cfg(unix)]
203    if enforce_directory_mode || !existed {
204        use std::os::unix::fs::PermissionsExt;
205        fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
206    }
207    #[cfg(not(unix))]
208    let _ = (enforce_directory_mode, existed);
209    Ok(())
210}
211
212fn open_append(path: &Path) -> io::Result<File> {
213    let mut options = OpenOptions::new();
214    options.create(true).append(true);
215    #[cfg(unix)]
216    {
217        use std::os::unix::fs::OpenOptionsExt;
218        options.mode(0o600);
219    }
220    let file = options.open(path)?;
221    #[cfg(unix)]
222    if file.metadata()?.file_type().is_file() {
223        use std::os::unix::fs::PermissionsExt;
224        file.set_permissions(fs::Permissions::from_mode(0o600))?;
225    }
226    Ok(file)
227}