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}