msr_core/fs/policy/
mod.rs

1use std::{
2    cmp::Ordering,
3    ffi::{OsStr, OsString},
4    fmt, fs,
5    io::{Cursor, ErrorKind as IoErrorKind, Result as IoResult},
6    ops::{Range, RangeInclusive},
7    path::PathBuf,
8    str::{from_utf8, FromStr},
9    time::SystemTime,
10};
11
12use thiserror::Error;
13use time::{format_description::FormatItem, macros::format_description, PrimitiveDateTime};
14
15use crate::time::{Interval, Timestamp};
16
17// The full precision of nanoseconds is required to prevent that
18// the time stamp in the file name of the next file could be less
19// or equal than the time stamp of the last entry in the previous
20// file!
21// Format: YYYYMMDDThhmmss.nnnnnnnnnZ
22const TIME_STAMP_FORMAT: &[FormatItem<'static>] = format_description!(
23    "[year repr:full][month repr:numerical][day]T[hour repr:24][minute][second].[subsecond digits:9]Z"
24);
25const TIME_STAMP_STRING_LEN: usize = 4 + 2 + 2 + 1 + 2 + 2 + 2 + 1 + 9 + 1;
26
27// 1 year, 1 file per day
28const PREALLOCATE_NUMBER_OF_DIR_ENTRIES: usize = 365;
29
30#[derive(Clone, Debug, Default, Eq, PartialEq)]
31pub struct RollingFileLimits {
32    pub max_bytes_written: Option<u64>,
33    pub max_records_written: Option<u64>,
34    pub max_nanoseconds_offset: Option<u64>,
35    pub interval: Option<Interval>,
36}
37
38impl RollingFileLimits {
39    #[must_use]
40    pub fn daily() -> Self {
41        Self {
42            interval: Some(Interval::Days(1)),
43            ..Default::default()
44        }
45    }
46
47    #[must_use]
48    pub fn weekly() -> Self {
49        Self {
50            interval: Some(Interval::Weeks(1)),
51            ..Default::default()
52        }
53    }
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct RollingFileStatus {
58    pub created_at: SystemTime,
59    pub bytes_written: Option<u64>,
60    pub records_written: Option<u64>,
61}
62
63impl RollingFileStatus {
64    #[must_use]
65    pub const fn new(created_at: SystemTime) -> Self {
66        Self {
67            created_at,
68            bytes_written: None,
69            records_written: None,
70        }
71    }
72
73    #[must_use]
74    pub fn should_roll(
75        &self,
76        now: SystemTime,
77        now_nanoseconds_offset: u64,
78        limits: &RollingFileLimits,
79    ) -> bool {
80        let Self {
81            created_at,
82            bytes_written,
83            records_written,
84        } = self;
85        let RollingFileLimits {
86            max_bytes_written,
87            max_records_written,
88            max_nanoseconds_offset,
89            interval,
90        } = limits;
91        if let Some(bytes_written) = bytes_written {
92            if let Some(max_bytes_written) = max_bytes_written {
93                if bytes_written >= max_bytes_written {
94                    return true;
95                }
96            }
97        }
98        if let Some(records_written) = records_written {
99            if let Some(max_records_written) = max_records_written {
100                if records_written >= max_records_written {
101                    return true;
102                }
103            }
104        }
105        if let Some(max_nanoseconds_offset) = max_nanoseconds_offset {
106            if now_nanoseconds_offset >= *max_nanoseconds_offset {
107                return true;
108            }
109        }
110        if let Some(interval) = interval {
111            let next_rollover = interval.system_time_after(*created_at);
112            if next_rollover <= now {
113                return true;
114            }
115        }
116        false
117    }
118}
119
120#[derive(Debug, Clone, Eq, PartialEq)]
121pub struct RollingFileNameTemplate {
122    pub prefix: String,
123    pub suffix: String,
124}
125
126#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
127pub struct FileNameTimeStamp(SystemTime);
128
129impl From<SystemTime> for FileNameTimeStamp {
130    fn from(from: SystemTime) -> Self {
131        Self(from)
132    }
133}
134
135impl From<FileNameTimeStamp> for SystemTime {
136    fn from(from: FileNameTimeStamp) -> Self {
137        from.0
138    }
139}
140
141impl FileNameTimeStamp {
142    #[must_use]
143    pub fn format(&self) -> String {
144        let formatted = Timestamp::from(self.0)
145            .to_utc()
146            .as_ref()
147            .format(TIME_STAMP_FORMAT)
148            .unwrap_or_default();
149        debug_assert_eq!(TIME_STAMP_STRING_LEN, formatted.len());
150        formatted
151    }
152
153    #[must_use]
154    pub fn format_into(&self, output: &mut [u8; TIME_STAMP_STRING_LEN]) -> usize {
155        let formatted_len = Timestamp::from(self.0)
156            .to_utc()
157            .as_ref()
158            .format_into(&mut Cursor::new(output.as_mut_slice()), TIME_STAMP_FORMAT)
159            .unwrap_or_default();
160        debug_assert_eq!(TIME_STAMP_STRING_LEN, formatted_len);
161        formatted_len
162    }
163}
164
165impl fmt::Display for FileNameTimeStamp {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        let mut output = [0u8; TIME_STAMP_STRING_LEN];
168        let formatted_len = self.format_into(&mut output);
169        f.write_str(from_utf8(&output.as_slice()[..formatted_len]).unwrap_or_default())
170    }
171}
172
173impl FromStr for FileNameTimeStamp {
174    type Err = time::error::Parse;
175
176    fn from_str(input: &str) -> Result<Self, Self::Err> {
177        let date_time = PrimitiveDateTime::parse(input, TIME_STAMP_FORMAT)?.assume_utc();
178        Ok(Self(date_time.into()))
179    }
180}
181
182impl RollingFileNameTemplate {
183    #[must_use]
184    pub fn format_os_string_with_time_stamp(&self, ts: FileNameTimeStamp) -> OsString {
185        let Self { prefix, suffix } = self;
186        // Reserve 2 bytes per character (Windows/UTF-16) for the time stamp infix
187        let infix_capacity = TIME_STAMP_STRING_LEN * 2;
188        let mut file_name = OsString::with_capacity(prefix.len() + infix_capacity + suffix.len());
189        file_name.push(prefix);
190        file_name.push(ts.to_string());
191        file_name.push(suffix);
192        debug_assert!(file_name.len() <= file_name.capacity());
193        file_name
194    }
195
196    pub fn parse_time_stamp_from_file_name(
197        &self,
198        file_name: &OsStr,
199    ) -> Result<FileNameTimeStamp, FileNameError> {
200        let Self { prefix, suffix } = self;
201        let file_name = file_name.to_str().ok_or(FileNameError::Pattern)?;
202        // TODO: Replace with strip_prefix/strip_suffix when available
203        if !file_name.starts_with(prefix) || !file_name.ends_with(suffix) {
204            return Err(FileNameError::Pattern);
205        }
206        let (_, without_prefix) = file_name.split_at(prefix.len());
207        let (ts, _) = without_prefix.split_at(without_prefix.len() - suffix.len());
208        if ts.len() != TIME_STAMP_STRING_LEN {
209            return Err(FileNameError::Pattern);
210        }
211        Ok(ts.parse()?)
212    }
213}
214
215#[derive(Debug, Clone, Eq, PartialEq)]
216pub struct RollingFileSystem {
217    pub base_path: PathBuf,
218    pub file_name_template: RollingFileNameTemplate,
219}
220
221#[derive(Clone, Debug, Eq, PartialEq)]
222pub struct RollingFileInfo {
223    pub path: PathBuf,
224    pub created_at: FileNameTimeStamp,
225}
226
227#[derive(Clone, Debug, Eq, PartialEq)]
228pub struct RollingFileInfoWithSize {
229    pub path: PathBuf,
230    pub created_at: FileNameTimeStamp,
231    pub size_in_bytes: u64,
232}
233
234impl RollingFileInfoWithSize {
235    #[must_use]
236    pub fn new(info: RollingFileInfo, size_in_bytes: u64) -> Self {
237        let RollingFileInfo { path, created_at } = info;
238        Self {
239            path,
240            created_at,
241            size_in_bytes,
242        }
243    }
244
245    fn cmp_created_at(&self, other: &Self) -> Ordering {
246        self.created_at.cmp(&other.created_at)
247    }
248}
249
250impl From<RollingFileInfoWithSize> for RollingFileInfo {
251    fn from(from: RollingFileInfoWithSize) -> Self {
252        let RollingFileInfoWithSize {
253            path,
254            created_at,
255            size_in_bytes: _,
256        } = from;
257        Self { path, created_at }
258    }
259}
260
261#[derive(Debug)]
262pub enum OpenRollingFile {
263    Opened(fs::File, RollingFileInfo),
264    AlreadyExists(PathBuf),
265}
266
267#[derive(Error, Debug)]
268pub enum FileNameError {
269    #[error("unrecognized file name pattern")]
270    Pattern,
271
272    #[error(transparent)]
273    Parse(#[from] time::error::Parse),
274}
275
276#[derive(Clone, Debug)]
277pub enum SystemTimeRange {
278    OnlyMostRecent,
279    ExclusiveUpperBound(Range<SystemTime>),
280    InclusiveUpperBound(RangeInclusive<SystemTime>),
281}
282
283#[derive(Clone, Debug, Default)]
284pub struct FileInfoFilter {
285    pub created_at: Option<SystemTimeRange>,
286}
287
288impl RollingFileSystem {
289    #[must_use]
290    pub fn new_file_path(&self, created_at: FileNameTimeStamp) -> PathBuf {
291        debug_assert!(PathBuf::from(self.file_name_template.prefix.clone()).is_relative());
292        let new_name = self
293            .file_name_template
294            .format_os_string_with_time_stamp(created_at);
295        debug_assert_eq!(
296            PathBuf::from(new_name.clone()).is_relative(),
297            PathBuf::from(self.file_name_template.prefix.clone()).is_relative()
298        );
299        debug_assert!(self.base_path.is_dir());
300        let mut new_file_path = self.base_path.clone();
301        new_file_path.push(new_name);
302        new_file_path
303    }
304
305    pub fn open_new_file_for_writing(
306        &self,
307        created_at: FileNameTimeStamp,
308    ) -> IoResult<OpenRollingFile> {
309        let mut open_options = fs::OpenOptions::new();
310        open_options.write(true).create_new(true);
311        let path = self.new_file_path(created_at);
312        match open_options.open(&path) {
313            Ok(file) => {
314                let info = RollingFileInfo { path, created_at };
315                Ok(OpenRollingFile::Opened(file, info))
316            }
317            Err(e) => {
318                if e.kind() == IoErrorKind::AlreadyExists {
319                    Ok(OpenRollingFile::AlreadyExists(path))
320                } else {
321                    Err(e)
322                }
323            }
324        }
325    }
326
327    /// Read all entries in the base path directory
328    ///
329    /// The matching entries are returned in no particular order.
330    pub fn read_all_dir_entries_filtered(
331        &self,
332        filter: &FileInfoFilter,
333    ) -> IoResult<Vec<RollingFileInfoWithSize>> {
334        let capacity = if let Some(SystemTimeRange::OnlyMostRecent) = filter.created_at {
335            1
336        } else {
337            PREALLOCATE_NUMBER_OF_DIR_ENTRIES
338        };
339        let mut entries = Vec::with_capacity(capacity);
340        let mut first_created_at_filtered = None; // only used for filtering
341        for entry in fs::read_dir(&self.base_path)? {
342            let entry = entry?;
343            let path = entry.path();
344            if path.is_file() {
345                if let Some(created_at) = path.file_name().and_then(|file_name| {
346                    self.file_name_template
347                        .parse_time_stamp_from_file_name(file_name)
348                        .ok()
349                }) {
350                    if let Some(filter_created_at) = &filter.created_at {
351                        let filter_created_at_start = match filter_created_at {
352                            SystemTimeRange::OnlyMostRecent => {
353                                if created_at.0 >= first_created_at_filtered.unwrap_or(created_at.0)
354                                {
355                                    entries.clear();
356                                }
357                                created_at.0
358                            }
359                            SystemTimeRange::ExclusiveUpperBound(filter_created_at) => {
360                                if created_at.0 >= filter_created_at.end {
361                                    continue;
362                                }
363                                filter_created_at.start
364                            }
365                            SystemTimeRange::InclusiveUpperBound(filter_created_at) => {
366                                if created_at.0 > *filter_created_at.end() {
367                                    continue;
368                                }
369                                *filter_created_at.start()
370                            }
371                        };
372                        if let Some(first_created_at) = first_created_at_filtered {
373                            debug_assert!(first_created_at <= filter_created_at_start);
374                            if created_at.0 < first_created_at {
375                                continue;
376                            }
377                        }
378                        if created_at.0 <= filter_created_at_start {
379                            first_created_at_filtered = Some(created_at.0);
380                        }
381                    }
382                    let size_in_bytes = path.metadata()?.len();
383                    entries.push(RollingFileInfoWithSize {
384                        path,
385                        created_at,
386                        size_in_bytes,
387                    });
388                    continue;
389                }
390            }
391            log::debug!("Ignoring directory entry {}", path.display());
392        }
393        if let Some(first_created_at_filtered) = first_created_at_filtered {
394            // Post-process filter
395            entries.retain(|file_info| file_info.created_at.0 >= first_created_at_filtered);
396        }
397        Ok(entries)
398    }
399
400    /// Read all entries in the base path directory, sorted by _created at_ in ascending order
401    pub fn read_all_dir_entries_filtered_chronologically(
402        &self,
403        filter: &FileInfoFilter,
404    ) -> IoResult<Vec<RollingFileInfoWithSize>> {
405        let mut entries = self.read_all_dir_entries_filtered(filter)?;
406        entries.sort_unstable_by(RollingFileInfoWithSize::cmp_created_at);
407        Ok(entries)
408    }
409
410    pub fn read_most_recent_dir_entry(&self) -> IoResult<Option<RollingFileInfoWithSize>> {
411        Ok(self
412            .read_all_dir_entries_filtered_chronologically(&FileInfoFilter {
413                created_at: Some(SystemTimeRange::OnlyMostRecent),
414            })?
415            .into_iter()
416            .last())
417    }
418}
419
420#[derive(Debug, Clone, Eq, PartialEq)]
421pub struct RollingFileConfig {
422    pub system: RollingFileSystem,
423    pub limits: RollingFileLimits,
424}
425
426#[cfg(test)]
427mod tests;