Skip to main content

truefix_log/
file.rs

1//! File log: separate `messages.log` and `event.log` streams (FR-H2).
2//!
3//! NEW-156 (feature 012): writes are queued onto a bounded `mpsc` channel and persisted by a
4//! background task, mirroring `SqlLog`/`MssqlLog`/`MongoLog`/`RedbLog`'s existing channel +
5//! background-writer shape — `on_incoming`/`on_outgoing`/`on_event` never touch the filesystem
6//! directly, so they never block the calling async task on disk I/O. Each blocking file write
7//! inside the background task is further wrapped in `tokio::task::spawn_blocking`, matching
8//! `RedbLog`'s treatment of its own blocking backend API. A background write/flush failure is
9//! surfaced via a structured `tracing::error!` event and a `truefix_log_write_failures_total`
10//! metrics counter (FR-009) instead of being silently dropped.
11//!
12//! NEW-157 (feature 012): [`RetentionPolicy`] adds a composable generation-count and/or
13//! time-based rolling policy on top of the pre-existing `MaxFileLogSize` size trigger, opt-in
14//! only (`FileLogOptions::retention: None` preserves today's single-backup-overwrite behavior
15//! exactly). See [`RotatingFile::rotate`] for the naming/pruning scheme.
16
17use std::fs::{File, OpenOptions};
18use std::io::Write;
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21
22use tokio::sync::mpsc;
23
24use crate::{Log, LogError, is_heartbeat};
25
26const ASYNC_LOG_CHANNEL_CAPACITY: usize = 1024;
27
28/// Composable retention policy for [`FileLog`]'s rotated backups (NEW-157). Both fields are
29/// independently optional and may be set together (e.g. "roll daily, keep the last 30 dated
30/// files").
31#[derive(Debug, Clone, Copy, Default)]
32pub struct RetentionPolicy {
33    /// Keep at most this many rotated backups, pruning the oldest once exceeded. `Some(0)` is
34    /// rejected at [`FileLog::open_with_options`] time (indistinguishable from "no policy", so
35    /// treated as a config error rather than silently behaving like `None`).
36    pub generations: Option<u32>,
37    /// Roll onto a new dated backup once this much wall-clock time has elapsed since the active
38    /// file's current interval began, in addition to (not instead of) the existing
39    /// `MaxFileLogSize` size trigger. A raw [`Duration`] (rather than a fixed `Daily`/`Hourly`
40    /// enum) so operators can pick any interval; `Some(Duration::ZERO)` is rejected as invalid.
41    pub roll_interval: Option<Duration>,
42}
43
44/// Output switches for [`FileLog`] (FR-026): `FileLogHeartbeats`/`FileIncludeMilliseconds`/
45/// `FileIncludeTimeStampForMessages`.
46#[derive(Debug, Clone, Copy)]
47pub struct FileLogOptions {
48    /// `FileLogHeartbeats`: whether Heartbeat (`35=0`) messages are written to `messages.log`.
49    pub include_heartbeats: bool,
50    /// `FileIncludeTimeStampForMessages`: whether each line is prefixed with a timestamp.
51    pub include_timestamp: bool,
52    /// `FileIncludeMilliseconds`: whether that timestamp includes milliseconds.
53    pub include_milliseconds: bool,
54    /// `MaxFileLogSize` (NEW-108, audit 006): rotate `messages.log`/`event.log` once either
55    /// exceeds this many bytes -- the current file is renamed to `<name>.1` (replacing any
56    /// previous backup) and a fresh file is opened. `None` (the default) preserves the previous
57    /// unbounded-growth behavior; this is log rotation only, distinct from message-store body
58    /// retention (which must preserve resend/recovery semantics and is handled separately).
59    pub max_size_bytes: Option<u64>,
60    /// NEW-157 (feature 012): composable generation-count and/or time-based retention beyond the
61    /// single-backup-overwrite default. `None` (the default) preserves today's exact behavior.
62    pub retention: Option<RetentionPolicy>,
63}
64
65impl Default for FileLogOptions {
66    fn default() -> Self {
67        Self {
68            include_heartbeats: true,
69            include_timestamp: false,
70            include_milliseconds: false,
71            max_size_bytes: None,
72            retention: None,
73        }
74    }
75}
76
77/// `YYYYMMDD-HH:MM:SS[.mmm] ` (trailing space), or empty when timestamps are disabled.
78pub(crate) fn format_timestamp_prefix(include_millis: bool) -> String {
79    let now = time::OffsetDateTime::now_utc();
80    if include_millis {
81        format!(
82            "{:04}{:02}{:02}-{:02}:{:02}:{:02}.{:03} ",
83            now.year(),
84            u8::from(now.month()),
85            now.day(),
86            now.hour(),
87            now.minute(),
88            now.second(),
89            now.millisecond()
90        )
91    } else {
92        format!(
93            "{:04}{:02}{:02}-{:02}:{:02}:{:02} ",
94            now.year(),
95            u8::from(now.month()),
96            now.day(),
97            now.hour(),
98            now.minute(),
99            now.second()
100        )
101    }
102}
103
104/// `YYYYMMDD-HHMMSS`, used as a rotated-backup filename suffix when a `roll_interval` is
105/// configured -- fine enough granularity to stay unique regardless of the configured interval.
106fn format_date_stamp(at: time::OffsetDateTime) -> String {
107    format!(
108        "{:04}{:02}{:02}-{:02}{:02}{:02}",
109        at.year(),
110        u8::from(at.month()),
111        at.day(),
112        at.hour(),
113        at.minute(),
114        at.second()
115    )
116}
117
118/// Which whole `interval`-sized bucket (since the Unix epoch) `at` falls into -- two timestamps
119/// map to the same key iff no `interval` boundary separates them. Nanosecond precision (not
120/// whole seconds) so sub-second intervals -- used by tests to exercise a roll boundary without
121/// waiting real hours/days -- behave correctly too.
122fn interval_key(interval: Duration, at: time::OffsetDateTime) -> i128 {
123    let interval_ns = interval.as_nanos().max(1);
124    at.unix_timestamp_nanos().div_euclid(interval_ns as i128)
125}
126
127/// The representative start-of-interval timestamp for `key`, used to format a rotated backup's
128/// date-stamp from the *closing* interval (not "now", which may already be in the next one).
129fn interval_start(interval: Duration, key: i128) -> time::OffsetDateTime {
130    let interval_ns = interval.as_nanos().max(1) as i128;
131    time::OffsetDateTime::from_unix_timestamp_nanos(key.saturating_mul(interval_ns))
132        .unwrap_or(time::OffsetDateTime::UNIX_EPOCH)
133}
134
135fn suffixed(path: &Path, suffix: &str) -> PathBuf {
136    let mut s = path.to_path_buf().into_os_string();
137    s.push(".");
138    s.push(suffix);
139    PathBuf::from(s)
140}
141
142/// A fully-formatted line destined for either stream, queued onto the background writer's
143/// channel. Formatting (timestamp prefix, heartbeat filtering) happens synchronously in the
144/// caller so timestamps reflect when the message was actually seen, not when the background task
145/// gets around to persisting it.
146enum Entry {
147    Message(String),
148    Event(String),
149}
150
151/// Logs inbound/outbound messages to `messages.log` and events to `event.log` via a bounded
152/// channel and background writer task (NEW-156).
153pub struct FileLog {
154    tx: std::sync::Mutex<Option<mpsc::Sender<Entry>>>,
155    task: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
156    options: FileLogOptions,
157}
158
159impl FileLog {
160    /// Open (creating if needed) the log files in `dir` with default (unfiltered) output switches.
161    pub async fn open(dir: &Path) -> Result<Self, LogError> {
162        Self::open_with_options(dir, FileLogOptions::default()).await
163    }
164
165    /// Open (creating if needed) the log files in `dir`, honoring `options` (FR-026), and spawn
166    /// the background writer task (NEW-156). `async` (unlike the pre-NEW-156 synchronous
167    /// constructor) purely because spawning that task requires an active Tokio runtime, matching
168    /// `RedbLog::connect`/`SqlLog::connect`'s existing async constructors -- opening the files
169    /// themselves is still a cheap, synchronous `std::fs` call, not offloaded to `spawn_blocking`.
170    pub async fn open_with_options(dir: &Path, options: FileLogOptions) -> Result<Self, LogError> {
171        if let Some(retention) = options.retention {
172            if retention.generations == Some(0) {
173                return Err(LogError::Io(
174                    "RetentionPolicy::generations must be at least 1 when set (0 is \
175                     indistinguishable from no policy)"
176                        .to_owned(),
177                ));
178            }
179            if retention.roll_interval == Some(Duration::ZERO) {
180                return Err(LogError::Io(
181                    "RetentionPolicy::roll_interval must be greater than zero when set".to_owned(),
182                ));
183            }
184        }
185        std::fs::create_dir_all(dir).map_err(|e| LogError::Io(e.to_string()))?;
186        let messages = RotatingFile::open(
187            dir.join("messages.log"),
188            options.max_size_bytes,
189            options.retention,
190        )?;
191        let events = RotatingFile::open(
192            dir.join("event.log"),
193            options.max_size_bytes,
194            options.retention,
195        )?;
196
197        let (tx, mut rx) = mpsc::channel::<Entry>(ASYNC_LOG_CHANNEL_CAPACITY);
198        let task = tokio::spawn(async move {
199            let mut messages = messages;
200            let mut events = events;
201            while let Some(entry) = rx.recv().await {
202                let outcome =
203                    tokio::task::spawn_blocking(move || write_entry(messages, events, entry)).await;
204                let Ok((m, e, result)) = outcome else {
205                    // The blocking closure panicked (a bug elsewhere, not a disk failure) -- we no
206                    // longer own `messages`/`events`, so this task cannot continue safely.
207                    report_write_failure("file log background writer task panicked");
208                    break;
209                };
210                messages = m;
211                events = e;
212                if let Err(err) = result {
213                    report_write_failure(&err.to_string());
214                }
215            }
216        });
217
218        Ok(Self {
219            tx: std::sync::Mutex::new(Some(tx)),
220            task: std::sync::Mutex::new(Some(task)),
221            options,
222        })
223    }
224
225    fn timestamp_prefix(&self) -> String {
226        if self.options.include_timestamp {
227            format_timestamp_prefix(self.options.include_milliseconds)
228        } else {
229            String::new()
230        }
231    }
232
233    fn send(&self, entry: Entry) {
234        if let Ok(guard) = self.tx.lock()
235            && let Some(tx) = guard.as_ref()
236        {
237            let _ = tx.try_send(entry);
238        }
239    }
240}
241
242/// FR-009: surface a background write/flush failure via structured `tracing` + a `metrics`
243/// counter, instead of the pre-NEW-156 `eprintln!`-only fallback (NEW-124, audit 006).
244fn report_write_failure(detail: &str) {
245    tracing::error!(target: "truefix_log", error = %detail, "failed to write log line");
246    metrics::counter!("truefix_log_write_failures_total").increment(1);
247}
248
249/// Writes `entry` to whichever of `messages`/`events` it targets, returning both streams back to
250/// the caller regardless of which one changed -- keeps the background task the sole owner of both
251/// `RotatingFile`s across `spawn_blocking` hops (which require `'static` owned captures).
252fn write_entry(
253    mut messages: RotatingFile,
254    mut events: RotatingFile,
255    entry: Entry,
256) -> (RotatingFile, RotatingFile, std::io::Result<()>) {
257    let result = match &entry {
258        Entry::Message(line) => messages.write_line(line),
259        Entry::Event(line) => events.write_line(line),
260    };
261    (messages, events, result)
262}
263
264fn open_append(path: &Path) -> Result<File, LogError> {
265    OpenOptions::new()
266        .create(true)
267        .append(true)
268        .open(path)
269        .map_err(|e| LogError::Io(e.to_string()))
270}
271
272/// A single log stream file with size-bounded rotation (`MaxFileLogSize`, NEW-108) and/or a
273/// composable [`RetentionPolicy`] (NEW-157, feature 012).
274struct RotatingFile {
275    path: PathBuf,
276    file: File,
277    size: u64,
278    max_size_bytes: Option<u64>,
279    retention: Option<RetentionPolicy>,
280    /// The whole-interval bucket the *active* file's content currently belongs to, when
281    /// `retention.roll_interval` is set; re-derived on every `open()` (FR-011/FR-013) rather than
282    /// persisted, so a crash or a stopped process never leaves stale in-memory state.
283    current_interval_key: Option<i128>,
284}
285
286impl RotatingFile {
287    fn open(
288        path: PathBuf,
289        max_size_bytes: Option<u64>,
290        retention: Option<RetentionPolicy>,
291    ) -> Result<Self, LogError> {
292        let file = open_append(&path)?;
293        let metadata = file.metadata().ok();
294        let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
295        // FR-011/FR-013: re-derive which interval the pre-existing content (if any) belongs to
296        // from the file's own mtime, rather than assuming "now" -- this is what makes a stale
297        // interval (elapsed while the process was stopped) roll exactly once on the first write
298        // after restart instead of being silently absorbed into "today".
299        let current_interval_key = retention.and_then(|r| r.roll_interval).map(|interval| {
300            let at = if size > 0 {
301                metadata
302                    .and_then(|m| m.modified().ok())
303                    .map(time::OffsetDateTime::from)
304                    .unwrap_or_else(time::OffsetDateTime::now_utc)
305            } else {
306                time::OffsetDateTime::now_utc()
307            };
308            interval_key(interval, at)
309        });
310        Ok(Self {
311            path,
312            file,
313            size,
314            max_size_bytes,
315            retention,
316            current_interval_key,
317        })
318    }
319
320    /// Write `line` (plus a trailing newline), rotating first if either the size bound or a
321    /// configured roll interval has been crossed -- so no single file ever grows past
322    /// `max_size_bytes`, and no file spans more than one configured interval.
323    fn write_line(&mut self, line: &str) -> std::io::Result<()> {
324        if let Some(interval) = self.retention.and_then(|r| r.roll_interval) {
325            let now_key = interval_key(interval, time::OffsetDateTime::now_utc());
326            if self.current_interval_key.is_some_and(|k| k != now_key) {
327                self.rotate()?;
328            }
329            self.current_interval_key = Some(now_key);
330        }
331        if let Some(max) = self.max_size_bytes
332            && self.size >= max
333        {
334            self.rotate()?;
335        }
336        writeln!(self.file, "{line}")?;
337        self.size += line.len() as u64 + 1;
338        Ok(())
339    }
340
341    /// Renames the current file out of the way and opens a fresh one, per the configured
342    /// [`RetentionPolicy`]:
343    /// - No policy: today's exact pre-NEW-157 behavior -- a single `<name>.1`, overwritten every
344    ///   time (`FR-004`'s default-unchanged requirement).
345    /// - `generations` only: numeric shifting `<name>.1`..`<name>.N`, oldest dropped.
346    /// - `roll_interval` set (with or without `generations`): a date-stamped
347    ///   `<name>.YYYYMMDD-HHMMSS` backup named after the *closing* interval; if `generations` is
348    ///   also set, dated backups beyond that count are pruned (oldest first).
349    ///
350    /// Every filesystem-mutating step here happens before `self.size`/`self.current_interval_key`
351    /// are updated by the caller, and `open()` always re-derives state from disk rather than
352    /// trusting in-memory assumptions -- together these make a crash at any point during this
353    /// sequence leave exactly one unambiguous active file on the next `open()` (FR-011).
354    fn rotate(&mut self) -> std::io::Result<()> {
355        match self.retention {
356            Some(RetentionPolicy {
357                roll_interval: Some(interval),
358                generations,
359            }) => {
360                let key = self
361                    .current_interval_key
362                    .unwrap_or_else(|| interval_key(interval, time::OffsetDateTime::now_utc()));
363                let stamp = format_date_stamp(interval_start(interval, key));
364                let backup = suffixed(&self.path, &stamp);
365                // Best-effort: a failure here should not prevent the log from continuing to
366                // accept writes to the existing file.
367                let _ = std::fs::rename(&self.path, &backup);
368                if let Some(n) = generations {
369                    self.prune_dated_backups(n);
370                }
371            }
372            Some(RetentionPolicy {
373                roll_interval: None,
374                generations: Some(n),
375            }) => {
376                self.shift_numeric_backups(n);
377                let _ = std::fs::rename(&self.path, suffixed(&self.path, "1"));
378            }
379            _ => {
380                // No policy (or a policy with both fields unset, which is the same as no policy):
381                // preserve the pre-NEW-157 single fixed `.1` backup, overwritten every rotation.
382                let _ = std::fs::rename(&self.path, suffixed(&self.path, "1"));
383            }
384        }
385        self.file = open_append(&self.path).map_err(|e| match e {
386            LogError::Io(msg) => std::io::Error::other(msg),
387        })?;
388        self.size = 0;
389        Ok(())
390    }
391
392    /// Shifts `<name>.1`..`<name>.{n-1}` up by one generation (dropping whatever was at
393    /// `<name>.n`), making room for the current file to become the new `<name>.1`. Each rename is
394    /// best-effort/no-op if its source doesn't exist yet (early in a file's life).
395    fn shift_numeric_backups(&self, n: u32) {
396        if n == 0 {
397            return;
398        }
399        let oldest = suffixed(&self.path, &n.to_string());
400        let _ = std::fs::remove_file(&oldest);
401        for generation in (1..n).rev() {
402            let from = suffixed(&self.path, &generation.to_string());
403            let to = suffixed(&self.path, &(generation + 1).to_string());
404            let _ = std::fs::rename(&from, &to);
405        }
406    }
407
408    /// Keeps only the `keep` most-recent `<name>.YYYYMMDD-HHMMSS` backups (lexicographic order
409    /// matches chronological order for this fixed-width format), deleting older ones. Best-effort
410    /// (directory listing/deletion failures are swallowed) since pruning is a retention nicety,
411    /// not a correctness requirement -- a few extra retained backups is not data loss.
412    fn prune_dated_backups(&self, keep: u32) {
413        let Some(parent) = self.path.parent() else {
414            return;
415        };
416        let Some(file_name) = self.path.file_name().and_then(|f| f.to_str()) else {
417            return;
418        };
419        let prefix = format!("{file_name}.");
420        let Ok(entries) = std::fs::read_dir(parent) else {
421            return;
422        };
423        let mut dated: Vec<(String, PathBuf)> = entries
424            .filter_map(|e| e.ok())
425            .filter_map(|e| {
426                let name = e.file_name().to_str()?.to_owned();
427                let suffix = name.strip_prefix(&prefix)?.to_owned();
428                let looks_dated = suffix.len() == "YYYYMMDD-HHMMSS".len()
429                    && suffix
430                        .chars()
431                        .enumerate()
432                        .all(|(i, c)| if i == 8 { c == '-' } else { c.is_ascii_digit() });
433                looks_dated.then_some((suffix, e.path()))
434            })
435            .collect();
436        dated.sort_by(|a, b| b.0.cmp(&a.0));
437        for (_, path) in dated.into_iter().skip(keep as usize) {
438            let _ = std::fs::remove_file(path);
439        }
440    }
441}
442
443#[async_trait::async_trait]
444impl Log for FileLog {
445    fn on_incoming(&self, message: &str) {
446        if is_heartbeat(message) && !self.options.include_heartbeats {
447            return;
448        }
449        self.send(Entry::Message(format!(
450            "{}I {message}",
451            self.timestamp_prefix()
452        )));
453    }
454    fn on_outgoing(&self, message: &str) {
455        if is_heartbeat(message) && !self.options.include_heartbeats {
456            return;
457        }
458        self.send(Entry::Message(format!(
459            "{}O {message}",
460            self.timestamp_prefix()
461        )));
462    }
463    fn on_event(&self, text: &str) {
464        // NEW-125 (audit 006): events are always timestamped, matching QFJ's `FileLog.onEvent()`
465        // (unconditional `forceTimestamp=true`) -- `include_timestamp` governs message logging
466        // only, not event logging.
467        self.send(Entry::Event(format!(
468            "{}{text}",
469            format_timestamp_prefix(self.options.include_milliseconds)
470        )));
471    }
472    async fn shutdown(&self) {
473        // NEW-91-style contract (see RedbLog/SqlLog's identical shutdown()): dropping the sender
474        // closes the channel once the sole clone is gone; the writer task's
475        // `while let Some(entry) = rx.recv().await` then drains whatever was already queued and
476        // returns, ending the loop (FR-002, SC-002).
477        let tx = self.tx.lock().ok().and_then(|mut guard| guard.take());
478        drop(tx);
479        let task = self.task.lock().ok().and_then(|mut guard| guard.take());
480        if let Some(task) = task {
481            let _ = task.await;
482        }
483    }
484}