Files
rustfs/crates/obs/src/telemetry/rolling.rs
T
Zhengchao An 7db3882777 chore(obs): adjudicate 19 bare dead_code allows (#6162)
backlog#1823 step 10, batch 2. Eighteen of the nineteen suppress nothing and are deleted; one was real and keeps an allow that now says why.

Rotation::Never is constructed only by the rolling-appender tests at rolling.rs:456, 477 and 498, so the lib target reports it as never constructed. Its allow is restored with that reason.

Finding it corrected the method used for batch 1. Removing all nineteen and running cargo check -p rustfs-obs --tests reported zero warnings even after touching every source file, while clippy --lib --tests -D warnings caught Rotation::Never. cargo's warning output is not a reliable completeness check — it does not re-emit for cached compilations, and touching the sources did not cover the lib target here. Later batches should treat clippy -D warnings as the gate; batch 1's six crates were re-checked under clippy and are clean.

Taken with #6086, which cleared this crate's 44 module-level blankets and left six real items, obs has now had 63 dead-code suppressions examined, of which seven were suppressing anything at all. The rest sat on items that are publicly reachable, where dead_code never applied — the same shape as the swift module and kms's dek.rs.

Verification: clippy --lib --tests -D warnings clean in the default, gpu and pyroscope lanes; cargo nextest run -p rustfs-obs 324 passed; make pre-commit exit 0.

Ref rustfs/backlog#1823 (step 10).
2026-08-17 11:32:23 +08:00

604 lines
24 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! A custom rolling file appender that supports both time-based and size-based rotation.
//!
//! This is a lightweight replacement for `tracing_appender::rolling::RollingFileAppender`
//! which only supports time-based rotation. This implementation ensures that active
//! log files do not grow indefinitely by rotating them when they exceed a configured size.
use crate::cleaner::types::FileMatchMode;
use crate::global::{
METRIC_LOG_CLEANER_ACTIVE_FILE_SIZE_BYTES, METRIC_LOG_CLEANER_ROTATION_DURATION_SECONDS,
METRIC_LOG_CLEANER_ROTATION_FAILURES_TOTAL, METRIC_LOG_CLEANER_ROTATION_TOTAL,
};
use chrono::{Local, TimeZone as _};
use jiff::Zoned;
use metrics::{counter, gauge, histogram};
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy)]
pub enum Rotation {
Minutely,
Hourly,
Daily,
#[allow(
dead_code,
reason = "constructed only by this file's rolling-appender tests; the lib target cannot see them (backlog#1823)"
)]
Never,
}
/// Global per-process counter used to disambiguate archive filenames that
/// may otherwise collide when multiple rotations occur within the same
/// timestamp tick.
static ROLL_UNIQUIFIER: AtomicU64 = AtomicU64::new(0);
const ROTATION_RETRY_COOLDOWN: Duration = Duration::from_secs(5);
impl Rotation {
fn check_should_roll(&self, last: i64, now: i64) -> bool {
match self {
Rotation::Minutely => now / 60 != last / 60,
Rotation::Hourly => now / 3600 != last / 3600,
Rotation::Daily => {
let last_day = Local.timestamp_opt(last, 0).single().map(|ts| ts.date_naive());
let now_day = Local.timestamp_opt(now, 0).single().map(|ts| ts.date_naive());
match (last_day, now_day) {
(Some(last_day), Some(now_day)) => last_day != now_day,
_ => now / 86400 != last / 86400,
}
}
Rotation::Never => false,
}
}
}
pub struct RollingAppender {
dir: PathBuf,
filename: String,
rotation: Rotation,
max_size_bytes: u64,
match_mode: FileMatchMode,
file: Option<File>,
size: u64,
// Store as seconds since Unix epoch
last_roll_ts: i64,
rotation_retry_cooldown_until: Option<Instant>,
}
impl RollingAppender {
/// Create and immediately validate a new `RollingAppender`.
///
/// The log directory is created if it does not already exist, and the
/// active log file is opened (or created) eagerly so that configuration
/// errors — e.g. an invalid filename — surface at initialisation time
/// rather than on the first write.
///
/// # Errors
/// Returns an [`io::Error`] if:
/// - `filename` is not a plain file name (absolute path, path separators,
/// or `..` components are rejected to prevent path traversal).
/// - The directory cannot be created.
/// - The active log file cannot be opened/created.
pub fn new(
dir: impl AsRef<Path>,
filename: String,
rotation: Rotation,
max_size_bytes: u64,
match_mode: FileMatchMode,
) -> io::Result<Self> {
// Validate that `filename` is a plain file name: not absolute and no
// directory components (separators or `..`). If `file_name()` equals
// the entire path, there can be no parent-directory traversal.
{
let p = Path::new(&filename);
let is_plain_name = !p.is_absolute() && p.file_name().map(|n| n == p.as_os_str()).unwrap_or(false);
if !is_plain_name {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("log filename must be a plain file name with no path components, got: {filename:?}"),
));
}
}
let mut appender = Self {
dir: dir.as_ref().to_path_buf(),
filename,
rotation,
max_size_bytes,
match_mode,
file: None,
size: 0,
last_roll_ts: Zoned::now().timestamp().as_second(),
rotation_retry_cooldown_until: None,
};
// Eagerly open the file to validate the path and capture accurate
// initial size / last-roll timestamp.
appender.open_file()?;
Ok(appender)
}
pub(crate) fn active_file_path(&self) -> PathBuf {
self.dir.join(&self.filename)
}
#[cfg(unix)]
pub(crate) fn active_file_identity(&self) -> io::Result<(u64, u64)> {
use std::os::unix::fs::MetadataExt;
let metadata = self
.file
.as_ref()
.ok_or_else(|| io::Error::other("rolling log file is not open"))?
.metadata()?;
Ok((metadata.dev(), metadata.ino()))
}
fn open_file(&mut self) -> io::Result<()> {
if self.file.is_some() {
return Ok(());
}
let path = self.active_file_path();
// Ensure directory exists
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
// Add retry logic to handle transient FS issues (e.g. anti-virus, indexing, or quick rename race)
const MAX_RETRIES: u32 = 3;
let mut last_error = None;
for i in 0..MAX_RETRIES {
// Open in append mode
match fs::OpenOptions::new().create(true).append(true).open(&path) {
Ok(file) => {
let meta = file.metadata()?;
self.size = meta.len();
// Seed `last_roll_ts` from the file's modification time so that a
// process restart correctly triggers time-based rotation if the active
// file belongs to a previous period.
if let Ok(modified) = meta.modified() {
// Convert SystemTime to jiff::Timestamp
if let Ok(ts) = jiff::Timestamp::try_from(modified) {
self.last_roll_ts = ts.as_second();
}
}
self.file = Some(file);
return Ok(());
}
Err(e) => {
last_error = Some(e);
// Exponential backoff: 10ms, 20ms, 40ms
thread::sleep(Duration::from_millis(10 * (1 << i)));
}
}
}
Err(last_error.unwrap_or_else(|| io::Error::other("Failed to open log file after retries")))
}
fn should_roll(&self, write_len: u64) -> bool {
if self
.rotation_retry_cooldown_until
.is_some_and(|deadline| deadline > Instant::now())
{
return false;
}
// 1. Size-based check (Cheap, check first)
// If max_size is set (non-zero) and writing would exceed it, roll immediately.
if self.max_size_bytes > 0 && (self.size + write_len) > self.max_size_bytes {
return true;
}
// 2. Time-based check
// We check this after size check to avoid unnecessary time calls if size forces a roll.
let now = Zoned::now().timestamp().as_second();
self.rotation.check_should_roll(self.last_roll_ts, now)
}
fn roll(&mut self) -> io::Result<()> {
let rotate_started = std::time::Instant::now();
// 1. Close current file first to ensure all buffers are flushed to OS (if any)
// and handle released.
if let Some(mut file) = self.file.take()
&& let Err(e) = file.flush()
{
eprintln!("Failed to flush log file before rotation: {}", e);
return Err(e);
}
let active_path = self.active_file_path();
if !active_path.exists() {
return Ok(());
}
// 2. Generate archive name.
// Format: YYYYMMDDHHMMSS.uuuuuu (Microsecond/Nanosecond precision)
// We use jiff's strftime. "%Y%m%d%H%M%S%.6f" gives microsecond precision.
let now = Zoned::now();
let timestamp_str = now.strftime("%Y%m%d%H%M%S%.6f").to_string();
// Add a unique counter to prevent collisions in high-concurrency/fast-rotation scenarios.
let counter = ROLL_UNIQUIFIER.fetch_add(1, Ordering::Relaxed);
// Final suffix/prefix part: timestamp + counter
// Example: 20231027103001.123456-0
let unique_part = format!("{}-{}", timestamp_str, counter);
// Match naming strategy with LogCleaner expectations.
let archive_name = match self.match_mode {
FileMatchMode::Suffix => {
// Suffix mode: timestamp BEFORE filename.
// e.g. rustfs.log -> 20231027103001.123456-0.rustfs.log
format!("{}.{}", unique_part, self.filename)
}
FileMatchMode::Prefix => {
// Prefix mode: timestamp AFTER filename.
// e.g. rustfs -> rustfs.20231027103001.123456-0
format!("{}.{}", self.filename, unique_part)
}
};
// 3. Rename the active file to the archive path.
let archive_path = self.dir.join(&archive_name);
// Robust Rename Strategy:
// On Windows, file locking (e.g. by AV software or indexers) can cause `rename` to fail
// spuriously with PermissionDenied. We implement a short retry loop with backoff.
const MAX_RETRIES: u32 = 3;
let mut last_error = None;
for i in 0..MAX_RETRIES {
match fs::rename(&active_path, &archive_path) {
Ok(_) => {
// Success!
// 4. Reset state
self.size = 0;
// 5. Re-open (creates new active file)
self.open_file()?;
// Explicitly update last_roll_ts to the rotation time.
// This overrides whatever open_file() derived from mtime, ensuring
// we stick to the logical rotation time.
self.last_roll_ts = now.timestamp().as_second();
self.rotation_retry_cooldown_until = None;
counter!(METRIC_LOG_CLEANER_ROTATION_TOTAL).increment(1);
histogram!(METRIC_LOG_CLEANER_ROTATION_DURATION_SECONDS).record(rotate_started.elapsed().as_secs_f64());
gauge!(METRIC_LOG_CLEANER_ACTIVE_FILE_SIZE_BYTES).set(self.size as f64);
return Ok(());
}
Err(e) => {
// Decide if we should retry based on error kind
let should_retry = match e.kind() {
// Windows often returns PermissionDenied for locked files
io::ErrorKind::PermissionDenied => true,
io::ErrorKind::Interrupted => true,
_ => false,
};
last_error = Some(e);
if !should_retry {
break;
}
// Exponential backoff: 10ms, 20ms, 40ms...
thread::sleep(Duration::from_millis(10 * (1 << i)));
}
}
}
// 6. Recovery Failure
// If we exhausted retries, we MUST NOT lose log data.
// We re-open the ACTIVE file (which is still there because rename failed).
// The file will grow beyond max_size, but availability > strict sizing.
eprintln!(
"RollingAppender: Failed to rotate log file after {} retries. Error: {:?}",
MAX_RETRIES, last_error
);
self.rotation_retry_cooldown_until = Some(Instant::now() + ROTATION_RETRY_COOLDOWN);
counter!(METRIC_LOG_CLEANER_ROTATION_FAILURES_TOTAL).increment(1);
histogram!(METRIC_LOG_CLEANER_ROTATION_DURATION_SECONDS).record(rotate_started.elapsed().as_secs_f64());
// Attempt to re-open existing active file to allow continued writing
self.open_file()?;
// Return the error so it can be logged/handled, even though we recovered the handle.
Err(last_error.unwrap_or_else(|| io::Error::other("Unknown rename error")))
}
}
impl Write for RollingAppender {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
// Ensure file is open
if self.file.is_none() {
self.open_file()?;
}
// Check rotation
if self.should_roll(buf.len() as u64)
&& let Err(e) = self.roll()
{
// If rotation fails, we log to stderr and try to continue writing to the active file
// to avoid losing logs if possible.
eprintln!("RollingAppender: failed to rotate log file: {}", e);
}
// Ensure file is open (in case roll closed it and failed to open new one, or open_file failed above)
if self.file.is_none() {
self.open_file()?;
}
if let Some(file) = &mut self.file {
let n = file.write(buf)?;
self.size += n as u64;
gauge!(METRIC_LOG_CLEANER_ACTIVE_FILE_SIZE_BYTES).set(self.size as f64);
Ok(n)
} else {
Err(io::Error::other("Failed to open log file"))
}
}
fn flush(&mut self) -> io::Result<()> {
if let Some(file) = &mut self.file {
file.flush()
} else {
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn count_files(dir: &Path) -> usize {
fs::read_dir(dir)
.unwrap()
.filter(|e| e.as_ref().unwrap().path().is_file())
.count()
}
// ── Construction ──────────────────────────────────────────────────────────
#[test]
fn test_new_creates_file_eagerly() {
let tmp = TempDir::new().unwrap();
let _appender = RollingAppender::new(tmp.path(), "test.log".to_string(), Rotation::Daily, 0, FileMatchMode::Suffix)
.expect("should create appender without error");
assert!(tmp.path().join("test.log").exists(), "active log file should be created on new()");
}
#[test]
fn test_new_invalid_filename_returns_error() {
let tmp = TempDir::new().unwrap();
// Null byte is invalid on both Unix and Windows.
let result = RollingAppender::new(tmp.path(), "invalid\0name.log".to_string(), Rotation::Daily, 0, FileMatchMode::Suffix);
assert!(result.is_err(), "null byte in filename must produce an error");
}
#[test]
fn test_new_rejects_path_with_separators() {
let tmp = TempDir::new().unwrap();
// A filename containing path separators could escape the log directory.
let result = RollingAppender::new(tmp.path(), "subdir/app.log".to_string(), Rotation::Daily, 0, FileMatchMode::Suffix);
assert!(result.is_err(), "filename with path separator must be rejected");
}
#[test]
fn test_new_rejects_parent_directory_traversal() {
let tmp = TempDir::new().unwrap();
// "../secret.log" would write outside the log directory.
let result = RollingAppender::new(tmp.path(), "../secret.log".to_string(), Rotation::Daily, 0, FileMatchMode::Suffix);
assert!(result.is_err(), "parent-directory traversal in filename must be rejected");
}
#[test]
fn test_new_rejects_absolute_path_as_filename() {
let tmp = TempDir::new().unwrap();
let result = RollingAppender::new(tmp.path(), "/etc/app.log".to_string(), Rotation::Daily, 0, FileMatchMode::Suffix);
assert!(result.is_err(), "absolute path as filename must be rejected");
}
/// On Windows, backslash is a path separator and must be rejected.
#[cfg(windows)]
#[test]
fn test_new_rejects_backslash_path_separator_on_windows() {
let tmp = TempDir::new().unwrap();
let result = RollingAppender::new(tmp.path(), "subdir\\app.log".to_string(), Rotation::Daily, 0, FileMatchMode::Suffix);
assert!(result.is_err(), "backslash path separator in filename must be rejected on Windows");
}
// ── Basic writes ──────────────────────────────────────────────────────────
#[test]
fn test_write_stores_content() {
let tmp = TempDir::new().unwrap();
let mut appender =
RollingAppender::new(tmp.path(), "test.log".to_string(), Rotation::Daily, 0, FileMatchMode::Suffix).unwrap();
appender.write_all(b"hello world\n").expect("write should succeed");
appender.flush().expect("flush should succeed");
let content = fs::read_to_string(tmp.path().join("test.log")).unwrap();
assert_eq!(content, "hello world\n");
}
// ── Size-based rotation ────────────────────────────────────────────────────
#[test]
fn test_size_rotation_creates_archive() {
let tmp = TempDir::new().unwrap();
// Allow only 5 bytes before rotating.
let mut appender =
RollingAppender::new(tmp.path(), "app.log".to_string(), Rotation::Never, 5, FileMatchMode::Suffix).unwrap();
// First write: 5 bytes exactly — no rotation yet.
appender.write_all(b"12345").expect("write should succeed");
// Second write: would push past the limit — rotation should occur first.
appender.write_all(b"abcde").expect("write after rotation should succeed");
appender.flush().unwrap();
// There should now be 2 files: the active log + 1 archive.
assert_eq!(count_files(tmp.path()), 2, "one rotation should have produced one archive");
// The active file should only contain the second write.
let content = fs::read_to_string(tmp.path().join("app.log")).unwrap();
assert_eq!(content, "abcde");
}
#[test]
fn test_multiple_size_rotations_produce_unique_archives() {
let tmp = TempDir::new().unwrap();
// Force a rotation on every write of 4+ bytes.
let mut appender =
RollingAppender::new(tmp.path(), "app.log".to_string(), Rotation::Never, 3, FileMatchMode::Suffix).unwrap();
for _ in 0..5 {
appender.write_all(b"abcd").expect("write should succeed");
}
appender.flush().unwrap();
let file_count = count_files(tmp.path());
// At least 5 archives (one per rotation) plus the active file.
assert!(
file_count >= 5,
"each burst write should produce a distinct archive; got {file_count} files"
);
}
// ── Archive filename format ────────────────────────────────────────────────
#[test]
fn test_suffix_mode_archive_name() {
let tmp = TempDir::new().unwrap();
let mut appender =
RollingAppender::new(tmp.path(), "app.log".to_string(), Rotation::Never, 3, FileMatchMode::Suffix).unwrap();
appender.write_all(b"1234").expect("write should succeed");
appender.flush().unwrap();
let archives: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n != "app.log")
.collect();
assert_eq!(archives.len(), 1);
// Suffix mode: "<timestamp>-<counter>.app.log"
// Since timestamp contains digits and we use high precision, checking strictly is hard,
// but it should definitely NOT be the old unix timestamp format (just digits).
// It should contain "-" before "app.log" due to our new format.
assert!(
archives[0].ends_with(".app.log"),
"archive should end with '.app.log' in Suffix mode; got '{}'",
archives[0]
);
// Check for new format chars (YMD)
// 20xx...
assert!(
archives[0].starts_with("20"),
"archive should start with year (20xx); got '{}'",
archives[0]
);
}
#[test]
fn test_prefix_mode_archive_name() {
let tmp = TempDir::new().unwrap();
let mut appender =
RollingAppender::new(tmp.path(), "app".to_string(), Rotation::Never, 3, FileMatchMode::Prefix).unwrap();
appender.write_all(b"1234").expect("write should succeed");
appender.flush().unwrap();
let archives: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n != "app")
.collect();
assert_eq!(archives.len(), 1);
// Prefix mode: "app.<timestamp>-<counter>"
assert!(
archives[0].starts_with("app.20"),
"archive should start with 'app.20' in Prefix mode; got '{}'",
archives[0]
);
}
// ── Restart with existing file ─────────────────────────────────────────────
#[test]
fn test_restart_with_existing_file_reads_size() {
let tmp = TempDir::new().unwrap();
let log_path = tmp.path().join("app.log");
fs::write(&log_path, b"existing content").unwrap();
let appender =
RollingAppender::new(tmp.path(), "app.log".to_string(), Rotation::Daily, 0, FileMatchMode::Suffix).unwrap();
assert_eq!(
appender.size,
b"existing content".len() as u64,
"size should reflect existing file content"
);
}
#[test]
fn test_daily_rotation_uses_calendar_day_boundaries() {
let now = Zoned::now();
let same_day_last = now.timestamp().as_second().saturating_sub(60);
assert!(!Rotation::Daily.check_should_roll(same_day_last, now.timestamp().as_second()));
let previous_day_last = now.timestamp().as_second().saturating_sub(86_400);
let last_day = Local.timestamp_opt(previous_day_last, 0).single().map(|ts| ts.date_naive());
let now_day = Local
.timestamp_opt(now.timestamp().as_second(), 0)
.single()
.map(|ts| ts.date_naive());
if last_day != now_day {
assert!(Rotation::Daily.check_should_roll(previous_day_last, now.timestamp().as_second()));
}
}
#[test]
fn test_rotation_cooldown_skips_immediate_retry() {
let tmp = TempDir::new().unwrap();
let mut appender =
RollingAppender::new(tmp.path(), "app.log".to_string(), Rotation::Never, 3, FileMatchMode::Suffix).unwrap();
appender.size = 4;
appender.rotation_retry_cooldown_until = Some(Instant::now() + Duration::from_secs(1));
assert!(!appender.should_roll(1));
}
}