Files
rustfs/crates/obs/src/cleaner/core.rs
T
houseme 3b139e5267 fix(obs/cleaner): harden log cleaner durability, symlink safety, and retention (audit OLC-01..14) (#4776)
* fix(obs/cleaner): fsync archive and log dir before deleting source logs

OLC-01: the compression path flushed the BufWriter but never synced the
archive data or the parent directory before renaming, and the source was
then unlinked with no durability barrier. A crash after rename but before
the page cache reached disk could leave a truncated/zero-length archive
while the source was already gone — permanent log/audit data loss. Because
the archive is always renamed to a brand-new name (guaranteed by the
existing exists() guard), ext4 auto_da_alloc does not mask this.

Hand the underlying File back from the writer closure, sync_all() it before
rename, fsync the parent directory, and fsync the log directory after the
unlinks so a delete cannot be reordered ahead of the archive it justified.
Guard the temp file with an RAII cleanup so an early return or panic cannot
leak a *.tmp orphan.

Ref: rustfs/backlog#1194 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): create compression temp file with O_EXCL and O_NOFOLLOW

OLC-03: the temp archive was opened with File::create at a predictable
`<source>.gz.tmp` path with no O_EXCL/O_NOFOLLOW, so an actor with write
access to the log directory could pre-plant that path as a symlink and have
the compressor follow it — truncating and overwriting an arbitrary external
file, then chmod-ing it to the source log's mode. This mirrors the symlink
refusal already enforced on the deletion path (secure_delete).

Route temp creation through create_tmp_archive(), which uses create_new
(O_CREAT|O_EXCL) to refuse a pre-existing entry and, on Unix, O_NOFOLLOW to
refuse a symlink at the final path component.

Ref: rustfs/backlog#1196 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): create compression temp file with restrictive mode

OLC-06: File::create left the temp archive world-readable (0644 & ~umask)
for the entire duration of compressing a large log, exposing the full
plaintext of a possibly-0600 audit log on shared hosts until the mode was
copied only after the write completed. Pass the source mode into
create_tmp_archive and open the temp file with it (default 0600) so it is
restrictive from creation; the post-write chmod still tightens/matches the
source mode exactly.

Ref: rustfs/backlog#1199 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): validate existing archive before skipping recompression

OLC-02: the idempotency guard used Path::exists() (which follows symlinks)
and trusted whatever it found, then the caller deleted the source. A planted
`<archive>.gz` symlink, or a zero-length/truncated archive left by a crashed
run (OLC-01), would green-light deleting the source with no valid backup —
data loss / log destruction.

Replace exists() with symlink_metadata (no follow) and only treat the entry
as a completed prior result when it is a regular, non-empty file whose header
matches the codec magic (gzip 1f 8b / zstd 28 b5 2f fd). Anything else falls
through to recompression, whose atomic create_new+rename replaces the bad
entry (a symlink is replaced, never followed or deleted through).

Ref: rustfs/backlog#1195 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): stop dry-run overstating reclaimed bytes for compression

OLC-08: in dry-run, compress_with_writer returned output_bytes = 0, so
projected_freed_bytes = input and delete_files reported the full input as
freed. A real run keeps the archive on disk (freed = input - archive), so
dry-run overstated reclaim by the whole archive footprint. Estimate the
archive with a deliberately conservative ratio so the projection never
exceeds what a real run reclaims.

Ref: rustfs/backlog#1201 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): make freed-byte accounting resilient; document steal metric

OLC-12: input/output byte sizes were read via metadata().unwrap_or(0), which
silently reports 0 on failure and skews freed-byte metrics (input - 0 = full
input, overstating reclaim). Use the copy() byte count as the authoritative
input size and, when the archive metadata read fails, conservatively assume
no savings instead of 0. Also document that the steal_success_rate counts
only victim steals (batch = one success), so it reads as a relative
rebalancing signal, not absolute task acquisition.

Ref: rustfs/backlog#1205 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): preserve level-0 semantics, allow zstd 22, log effective levels

OLC-09: build() and the codec calls clamped gzip/zstd levels to [1,9]/[1,21],
silently rewriting gzip level 0 (store) and zstd level 0 (codec default) to 1
and blocking the legal zstd maximum of 22. Clamp to [0,9]/[0,22] so those
meanings survive, and echo the effective (post-clamp) levels in the startup
log via new effective_gzip_level()/effective_zstd_level() getters so the log
matches what actually runs.

Ref: rustfs/backlog#1202 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): bound compressed archives by byte cap; warn on retention=0

OLC-04: archive expiry was gated on compressed_file_retention_days > 0, so
retention=0 disabled it entirely while compression kept producing archives,
and max_total_size_bytes only ever bounded uncompressed logs — unbounded disk
growth. Replace select_expired_compressed with select_archives_to_delete,
which applies age expiry (when retention is on) and, regardless of retention,
trims the oldest archives until the set fits under max_total_size_bytes. Also
warn at startup when compression is on with retention=0 so the "keep forever"
semantics are not mistaken for "delete immediately".

Ref: rustfs/backlog#1197 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): warn on invalid exclude glob instead of dropping silently

OLC-05: build() dropped unparseable exclude globs via filter_map(...ok()), so
a typo (or a literal comma splitting a char-class in the config string) turned
"protect this file" into "delete this file" with no signal. Log a warning per
rejected pattern with the raw string and parse error so the misconfiguration
is visible.

Ref: rustfs/backlog#1198 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* perf(obs/cleaner): backoff idle workers, cap worker count, lower small-host floor

OLC-11: the work-stealing loop re-spun on Steal::Retry with no yield and used
yield_now on the empty path, burning CPU during redistribution windows;
worker_count had no upper bound so a mis-set parallel_workers over a directory
of thousands of logs could spawn thousands of threads; and
default_parallel_workers forced >=4 workers even on 1-2 vCPU hosts. Use
crossbeam_utils::Backoff (spin->yield, reset on work) on the idle paths, clamp
worker_count to MAX_PARALLEL_COMPRESS_WORKERS, and lower the default floor to 1.

Ref: rustfs/backlog#1204 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): warn when active-file guard is disabled by empty filename

OLC-13 (defense-in-depth): the scanner protects the live log purely by exact
filename equality against active_filename. An empty active_filename silently
disables that protection, so a non-empty file_pattern could make the live log
a deletion candidate via the public builder. Warn in build() when that unsafe
combination is configured. The audit's "never delete the newest match"
structural guard is intentionally not implemented: it would conflict with the
legitimate keep_files=0 semantics (purge all rotated logs). The naming
contract is instead locked by regression tests (OLC-14).

Ref: rustfs/backlog#1206 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): warn on unknown algorithm/match_mode, echo match_mode

OLC-07: from_config_str silently fell back to defaults for unrecognized
compression algorithm and match mode (any non-"prefix" value became Suffix),
hiding operator typos like "prefixx" that could make the cleaner match no
rotated logs. Warn on a non-empty unrecognized value in both parsers, and
echo the resolved match_mode in the startup log alongside the algorithm.

Ref: rustfs/backlog#1200 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* fix(obs/cleaner): derive orphan .tmp suffixes and exempt them from min age

OLC-10: orphan `*.gz.tmp`/`*.zst.tmp` cleanup was gated by min_file_age_seconds
(default 3600), so crash-left orphans lingered up to an hour, and the tmp
suffix list was hardcoded rather than derived from compressed_suffixes() — a
new codec would leave `*.<ext>.tmp` orphans the scanner never recognizes.
Derive the temp suffix from CompressionAlgorithm::compressed_suffixes(), and
gate orphan removal on a small fixed grace window instead of min_file_age
(orphans are never live-written after the rename that would promote them).

Ref: rustfs/backlog#1203 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* test(obs/cleaner): cover symlink, archive expiry, idempotency, and edge cases

OLC-14: add regression tests for the previously-untested safety/correctness
branches — symlink rejection (external target never deleted), archive age
expiry vs fresh retention, archive byte-cap trim with retention disabled,
gz/zst classification, max_single_file_size selection, min_age protecting a
fresh non-empty log, active-file exclusion when the active name also matches
the pattern, invalid exclude glob not aborting build, dry-run + compression
creating no archive, gzip round-trip validity, and the idempotent-archive
branch trusting a valid prior archive.

Ref: rustfs/backlog#1207 (audit rustfs/backlog#1193)

Co-Authored-By: heihutu <heihutu@gmail.com>

* style(obs/cleaner): apply rustfmt and collapse nested if (clippy)

Formatting-only cleanup over the audit fix series: rustfmt normalization of the
multi-line expressions introduced in compress.rs/core.rs, plus collapsing the
delete_files directory-fsync into a single let-chain to satisfy
clippy::collapsible_if. No behavior change.

Co-Authored-By: heihutu <heihutu@gmail.com>

* refactor(obs/cleaner): collapse redundant source stats in compression path

The per-issue fixes to compress_with_writer accumulated three metadata()
syscalls on the source in the real compression path: an input_bytes read that
was immediately shadowed by the copied byte count (a dead read), a source_mode
read (OLC-06), and the pre-OLC-06 post-write chmod re-reading the same mode.
Collapse to a single fd-based read — move the dry-run input_bytes read into
the dry-run branch, read source_mode from the already-open fd (no path stat,
no TOCTOU), and reuse it for the post-write chmod. Behavior is unchanged
(same inode's mode, written for input size); 3 source stats -> 1.

Co-Authored-By: heihutu <heihutu@gmail.com>

* chore(obs/cleaner): fix typo flagged by CI (mis-set -> misconfigured)

Co-Authored-By: heihutu <heihutu@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-12 12:01:52 +00:00

952 lines
39 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.
//! Core log-file cleanup orchestration.
//!
//! This module connects scanning, retention selection, compression, and safe
//! deletion into one reusable service object. The public surface is intentionally
//! small: callers configure a [`LogCleaner`] once and then trigger discrete
//! cleanup passes whenever log rotation or background maintenance requires it.
use super::compress::{CompressionOptions, compress_file};
use super::scanner::{LogScanResult, scan_log_directory};
use super::types::{CompressionAlgorithm, FileInfo, FileMatchMode, default_parallel_workers};
use crate::global::{
METRIC_LOG_CLEANER_COMPRESS_DURATION_SECONDS, METRIC_LOG_CLEANER_DELETED_FILES_TOTAL, METRIC_LOG_CLEANER_FREED_BYTES_TOTAL,
METRIC_LOG_CLEANER_STEAL_SUCCESS_RATE,
};
use crossbeam_channel::bounded;
use crossbeam_deque::{Injector, Steal, Stealer, Worker};
use crossbeam_utils::{Backoff, thread};
use metrics::{counter, gauge, histogram};
use rustfs_config::DEFAULT_LOG_KEEP_FILES;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime};
use tracing::{debug, error, info, warn};
const LOG_COMPONENT_OBS: &str = "obs";
const LOG_SUBSYSTEM_LOG_CLEANER: &str = "log_cleaner";
const EVENT_LOG_CLEANER_STATE: &str = "log_cleaner_state";
const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
/// Absolute ceiling on parallel compression workers, independent of config, so
/// a misconfigured `parallel_workers` on a directory with thousands of rotated logs
/// cannot spawn thousands of threads (each of which may also start codec
/// threads).
const MAX_PARALLEL_COMPRESS_WORKERS: usize = 64;
const MAX_RETENTION_DAYS_BEFORE_SATURATION: u64 = u64::MAX / SECONDS_PER_DAY;
fn compressed_file_retention_window(days: u64) -> Duration {
Duration::from_secs(days.saturating_mul(SECONDS_PER_DAY))
}
#[derive(Debug)]
struct CompressionTaskResult {
/// Original file metadata so successful workers can be deleted later.
file: FileInfo,
/// Whether compression completed successfully for this file.
compressed: bool,
}
/// Log-file lifecycle manager.
///
/// A cleaner instance is immutable after construction and therefore safe to
/// reuse across periodic background jobs. Each call to [`LogCleaner::cleanup`]
/// performs a fresh directory scan and applies the configured retention rules.
pub struct LogCleaner {
/// Directory containing the active and rotated log files.
pub(super) log_dir: PathBuf,
/// Pattern used to recognize relevant log generations.
pub(super) file_pattern: String,
/// The currently active log file that must never be touched.
pub(super) active_filename: String,
/// Whether `file_pattern` is interpreted as a prefix or suffix.
pub(super) match_mode: FileMatchMode,
/// Maximum number of newest regular log files to keep.
pub(super) keep_files: usize,
/// Optional cap for the cumulative size of regular logs.
pub(super) max_total_size_bytes: u64,
/// Optional cap for an individual regular log file.
pub(super) max_single_file_size_bytes: u64,
/// Whether selected regular logs should be compressed before deletion.
pub(super) compress_old_files: bool,
/// Gzip compression level used when gzip is selected or used as fallback.
pub(super) gzip_compression_level: u32,
/// Retention window for already compressed archives, expressed in days.
pub(super) compressed_file_retention_days: u64,
/// Glob patterns that are excluded before any cleanup decision is made.
pub(super) exclude_patterns: Vec<glob::Pattern>,
/// Whether zero-byte regular logs may be removed during scanning.
pub(super) delete_empty_files: bool,
/// Minimum age a regular log must reach before it becomes eligible.
pub(super) min_file_age_seconds: u64,
/// Dry-run mode reports intended actions without modifying files.
pub(super) dry_run: bool,
// Parallel compression controls while keeping backward compatibility with
// the original serial cleaner behavior.
/// Preferred archive codec for compression-enabled cleanup passes.
pub(super) compression_algorithm: CompressionAlgorithm,
/// Enables the work-stealing compression path when compression is active.
pub(super) parallel_compress: bool,
/// Number of worker threads used by the parallel compressor.
pub(super) parallel_workers: usize,
/// Zstd compression level when zstd is selected.
pub(super) zstd_compression_level: i32,
/// Whether a failed zstd attempt should retry with gzip.
pub(super) zstd_fallback_to_gzip: bool,
/// Number of internal threads requested from the zstd encoder.
pub(super) zstd_workers: usize,
}
impl LogCleaner {
/// Create a builder with the required path and filename matching inputs.
pub fn builder(
log_dir: impl Into<PathBuf>,
file_pattern: impl Into<String>,
active_filename: impl Into<String>,
) -> LogCleanerBuilder {
LogCleanerBuilder::new(log_dir, file_pattern, active_filename)
}
/// Effective gzip level after clamping — what compression actually uses.
pub fn effective_gzip_level(&self) -> u32 {
self.gzip_compression_level
}
/// Effective zstd level after clamping — what compression actually uses.
pub fn effective_zstd_level(&self) -> i32 {
self.zstd_compression_level
}
/// Perform one full cleanup pass.
pub fn cleanup(&self) -> Result<(usize, u64), std::io::Error> {
if !self.log_dir.exists() {
debug!(
event = EVENT_LOG_CLEANER_STATE,
component = LOG_COMPONENT_OBS,
subsystem = LOG_SUBSYSTEM_LOG_CLEANER,
state = "log_dir_missing",
log_dir = ?self.log_dir,
"log cleaner state changed"
);
return Ok((0, 0));
}
let mut total_deleted = 0usize;
let mut total_freed = 0u64;
let LogScanResult {
mut logs,
mut compressed_archives,
} = scan_log_directory(
&self.log_dir,
&self.file_pattern,
Some(&self.active_filename),
self.match_mode,
&self.exclude_patterns,
self.min_file_age_seconds,
self.delete_empty_files,
self.dry_run,
)?;
if !logs.is_empty() {
logs.sort_by_key(|f| f.modified);
let total_size: u64 = logs.iter().map(|f| f.size).sum();
info!(
event = EVENT_LOG_CLEANER_STATE,
component = LOG_COMPONENT_OBS,
subsystem = LOG_SUBSYSTEM_LOG_CLEANER,
state = "scan_completed",
regular_log_files = logs.len(),
total_bytes = total_size,
total_megabytes = total_size as f64 / 1024.0 / 1024.0,
"log cleaner state changed"
);
// Select the oldest files first, then additionally trim any files
// that still violate configured size constraints.
let to_delete = self.select_files_to_process(&logs, total_size);
if !to_delete.is_empty() {
let (deleted, freed) = if self.parallel_compress && self.compress_old_files {
self.parallel_stealing_compress(&to_delete)?
} else {
self.serial_compress_and_delete(&to_delete)?
};
total_deleted += deleted;
total_freed += freed;
}
}
if !compressed_archives.is_empty() {
let to_delete = self.select_archives_to_delete(&mut compressed_archives);
if !to_delete.is_empty() {
let (d, f) = self.delete_files(&to_delete)?;
total_deleted += d;
total_freed += f;
}
}
if total_deleted > 0 || total_freed > 0 {
counter!(METRIC_LOG_CLEANER_DELETED_FILES_TOTAL).increment(total_deleted as u64);
counter!(METRIC_LOG_CLEANER_FREED_BYTES_TOTAL).increment(total_freed);
info!(
event = EVENT_LOG_CLEANER_STATE,
component = LOG_COMPONENT_OBS,
subsystem = LOG_SUBSYSTEM_LOG_CLEANER,
state = "cleanup_completed",
deleted_files = total_deleted,
freed_bytes = total_freed,
freed_megabytes = total_freed as f64 / 1024.0 / 1024.0,
"log cleaner state changed"
);
}
Ok((total_deleted, total_freed))
}
/// Choose regular log files that should be compressed and/or deleted.
///
/// The `files` slice must already be sorted from oldest to newest. The
/// method first enforces the `keep_files` ceiling, then applies total-size
/// and per-file-size limits to the remaining tail.
pub(super) fn select_files_to_process(&self, files: &[FileInfo], total_size: u64) -> Vec<FileInfo> {
let mut to_delete = Vec::new();
if files.is_empty() {
return to_delete;
}
let must_delete_count = files.len().saturating_sub(self.keep_files);
let mut current_size = total_size;
for (idx, file) in files.iter().enumerate() {
if idx < must_delete_count {
current_size = current_size.saturating_sub(file.size);
to_delete.push(file.clone());
continue;
}
let over_total = self.max_total_size_bytes > 0 && current_size > self.max_total_size_bytes;
let over_single = self.max_single_file_size_bytes > 0 && file.size > self.max_single_file_size_bytes;
if over_total || over_single {
current_size = current_size.saturating_sub(file.size);
to_delete.push(file.clone());
}
}
to_delete
}
/// Select compressed archives to delete: those older than the retention
/// window, plus — regardless of retention — the oldest archives needed to
/// bring the archive set back under `max_total_size_bytes`.
///
/// The byte cap is the crucial part: with `compressed_file_retention_days`
/// set to 0 (retention disabled) and compression enabled, age-based expiry
/// never fires, so without this cap archives would grow without bound (the
/// cap on regular logs does not cover them). Bounding the archive set by the
/// same byte budget keeps disk usage finite even when retention is off.
fn select_archives_to_delete(&self, files: &mut [FileInfo]) -> Vec<FileInfo> {
if self.compressed_file_retention_days > MAX_RETENTION_DAYS_BEFORE_SATURATION {
warn!(
event = EVENT_LOG_CLEANER_STATE,
component = LOG_COMPONENT_OBS,
subsystem = LOG_SUBSYSTEM_LOG_CLEANER,
result = "retention_days_saturated",
configured_days = self.compressed_file_retention_days,
fallback_days = MAX_RETENTION_DAYS_BEFORE_SATURATION,
"log cleaner state changed"
);
}
// Oldest first so both age expiry and the byte-cap trim evict the
// longest-retained archives before newer ones.
files.sort_by_key(|f| f.modified);
let now = SystemTime::now();
let retention = compressed_file_retention_window(self.compressed_file_retention_days);
let retention_active = self.compressed_file_retention_days > 0;
let mut selected = vec![false; files.len()];
// 1. Age-based expiry (only when retention is enabled).
if retention_active {
for (idx, file) in files.iter().enumerate() {
if let Ok(age) = now.duration_since(file.modified)
&& age > retention
{
selected[idx] = true;
}
}
}
// 2. Byte-cap trim: drop the oldest surviving archives until the set
// fits under the configured total. 0 means "no cap".
if self.max_total_size_bytes > 0 {
let mut remaining: u64 = files
.iter()
.enumerate()
.filter(|(idx, _)| !selected[*idx])
.map(|(_, file)| file.size)
.sum();
for (idx, file) in files.iter().enumerate() {
if remaining <= self.max_total_size_bytes {
break;
}
if selected[idx] {
continue;
}
selected[idx] = true;
remaining = remaining.saturating_sub(file.size);
}
}
files
.iter()
.enumerate()
.filter(|(idx, _)| selected[*idx])
.map(|(_, file)| file.clone())
.collect()
}
/// Parallel compressor with work stealing.
///
/// The flow is intentionally split into "parallel compression" followed by
/// "serial deletion" to reduce cross-platform file-locking failures.
/// Compression workers only decide whether an archive was created; the main
/// thread remains responsible for actual source removal so deletion policy
/// and error reporting stay deterministic.
fn parallel_stealing_compress(&self, files: &[FileInfo]) -> Result<(usize, u64), std::io::Error> {
if files.len() <= 1 {
return self.serial_compress_and_delete(files);
}
let worker_count = self.parallel_workers.min(files.len()).clamp(1, MAX_PARALLEL_COMPRESS_WORKERS);
if worker_count <= 1 {
return self.serial_compress_and_delete(files);
}
let compression_options = self.compression_options();
let started_at = Instant::now();
let injector = Arc::new(Injector::new());
for file in files {
injector.push(file.clone());
}
let mut workers = Vec::with_capacity(worker_count);
let mut stealers = Vec::with_capacity(worker_count);
for _ in 0..worker_count {
let worker = Worker::new_fifo();
stealers.push(worker.stealer());
workers.push(worker);
}
let stealers = Arc::new(stealers);
let steal_attempts = Arc::new(AtomicU64::new(0));
let steal_successes = Arc::new(AtomicU64::new(0));
let (tx, rx) = bounded::<CompressionTaskResult>(worker_count.saturating_mul(2).max(8));
// Spawn a fixed-size worker set in a scoped region so panics are
// contained and can be downgraded to a serial fallback instead of
// leaking detached threads.
let scope_result = thread::scope(|scope| {
for (worker_id, local_worker) in workers.into_iter().enumerate() {
let tx = tx.clone();
let injector = Arc::clone(&injector);
let stealers = Arc::clone(&stealers);
let options = compression_options.clone();
let attempts = Arc::clone(&steal_attempts);
let successes = Arc::clone(&steal_successes);
scope.spawn(move |_| {
let mut seed = (worker_id as u64 + 1)
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
// Escalating spin->yield backoff for the transient windows
// where work is being redistributed, instead of a hot
// `continue`/`yield_now` loop that burns CPU.
let backoff = Backoff::new();
loop {
// Search order: local FIFO -> global injector batch ->
// random victim stealers.
let task = if let Some(file) = local_worker.pop() {
Some(file)
} else {
match injector.steal_batch_and_pop(&local_worker) {
Steal::Success(file) => {
Some(file)
}
Steal::Retry => {
backoff.snooze();
continue;
}
Steal::Empty => {
let stolen = Self::steal_from_victims(
worker_id,
&local_worker,
&stealers,
&attempts,
&successes,
&mut seed,
);
// Exit only when all task sources are empty.
if stolen.is_none()
&& injector.is_empty()
&& local_worker.is_empty()
&& stealers.iter().all(Stealer::is_empty)
{
break;
}
stolen
}
}
};
let Some(file) = task else {
backoff.snooze();
continue;
};
// Got work: reset the backoff so the next idle spell
// starts from a cheap spin again.
backoff.reset();
let mut file = file;
let compressed = match compress_file(&file.path, &options) {
Ok(output) => {
debug!(
event = EVENT_LOG_CLEANER_STATE,
component = LOG_COMPONENT_OBS,
subsystem = LOG_SUBSYSTEM_LOG_CLEANER,
file = ?file.path,
archive = ?output.archive_path,
algorithm = %output.algorithm_used,
input_bytes = output.input_bytes,
output_bytes = output.output_bytes,
state = "parallel_compression_done",
"log cleaner state changed"
);
file.projected_freed_bytes = output.input_bytes.saturating_sub(output.output_bytes);
true
}
Err(err) => {
warn!(event = EVENT_LOG_CLEANER_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_LOG_CLEANER, file = ?file.path, error = %err, result = "parallel_compression_failed", "log cleaner state changed");
false
}
};
if tx.send(CompressionTaskResult { file, compressed }).is_err() {
break;
}
}
});
}
drop(tx);
let mut deletable = Vec::with_capacity(files.len());
for result in rx {
if result.compressed {
deletable.push(result.file);
}
}
deletable
});
// Any worker panic triggers deterministic fallback behavior.
let deletable = match scope_result {
Ok(deletable) => deletable,
Err(_) => {
warn!(
event = EVENT_LOG_CLEANER_STATE,
component = LOG_COMPONENT_OBS,
subsystem = LOG_SUBSYSTEM_LOG_CLEANER,
result = "parallel_worker_panicked",
fallback = "serial",
"log cleaner state changed"
);
return self.serial_compress_and_delete(files);
}
};
let (deleted, freed) = self.delete_files(&deletable)?;
let elapsed = started_at.elapsed().as_secs_f64();
// NOTE on metric scope: attempts/successes count only *victim* steals
// (not the injector `steal_batch_and_pop`), and a multi-task stolen
// batch counts as a single success. So this rate reflects inter-worker
// rebalancing efficiency, not total task acquisition — read it as a
// relative health signal, not an absolute throughput measure.
let attempts = steal_attempts.load(Ordering::Relaxed);
let successes = steal_successes.load(Ordering::Relaxed);
let success_rate = if attempts == 0 {
0.0
} else {
successes as f64 / attempts as f64
};
// Emit post-run cleanup metrics for monitoring and alerting.
histogram!(METRIC_LOG_CLEANER_COMPRESS_DURATION_SECONDS).record(elapsed);
gauge!(METRIC_LOG_CLEANER_STEAL_SUCCESS_RATE).set(success_rate);
info!(
event = EVENT_LOG_CLEANER_STATE,
component = LOG_COMPONENT_OBS,
subsystem = LOG_SUBSYSTEM_LOG_CLEANER,
workers = worker_count,
algorithm = %self.compression_algorithm,
deleted,
freed,
duration_seconds = elapsed,
steal_attempts = attempts,
steal_successes = successes,
steal_success_rate = success_rate,
state = "parallel_cleanup_finished",
"log cleaner state changed"
);
Ok((deleted, freed))
}
/// Attempt to steal a task from peer workers using randomized victim order.
fn steal_from_victims(
worker_id: usize,
local_worker: &Worker<FileInfo>,
stealers: &[Stealer<FileInfo>],
attempts: &AtomicU64,
successes: &AtomicU64,
seed: &mut u64,
) -> Option<FileInfo> {
if stealers.len() <= 1 {
return None;
}
// Xorshift step to randomize victim polling order and avoid convoying.
*seed ^= *seed << 13;
*seed ^= *seed >> 7;
*seed ^= *seed << 17;
let start = (*seed as usize) % stealers.len();
let steal_result = Steal::from_iter((0..stealers.len()).map(|offset| {
let victim = (start + offset) % stealers.len();
if victim == worker_id {
return Steal::Empty;
}
attempts.fetch_add(1, Ordering::Relaxed);
stealers[victim].steal_batch_and_pop(local_worker)
}));
match steal_result {
Steal::Success(file) => {
successes.fetch_add(1, Ordering::Relaxed);
Some(file)
}
Steal::Retry | Steal::Empty => None,
}
}
/// Serial fallback path and non-parallel baseline.
///
/// This path is also used whenever the task set is too small to benefit
/// from worker orchestration.
fn serial_compress_and_delete(&self, files: &[FileInfo]) -> Result<(usize, u64), std::io::Error> {
let started_at = Instant::now();
let mut deletable = Vec::with_capacity(files.len());
if self.compress_old_files {
let options = self.compression_options();
for file in files {
match compress_file(&file.path, &options) {
Ok(output) => {
debug!(
event = EVENT_LOG_CLEANER_STATE,
component = LOG_COMPONENT_OBS,
subsystem = LOG_SUBSYSTEM_LOG_CLEANER,
file = ?file.path,
archive = ?output.archive_path,
algorithm = %output.algorithm_used,
input_bytes = output.input_bytes,
output_bytes = output.output_bytes,
state = "serial_compression_done",
"log cleaner state changed"
);
let mut file = file.clone();
file.projected_freed_bytes = output.input_bytes.saturating_sub(output.output_bytes);
deletable.push(file);
}
Err(err) => {
warn!(event = EVENT_LOG_CLEANER_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_LOG_CLEANER, file = ?file.path, error = %err, result = "serial_compression_failed", "log cleaner state changed");
}
}
}
} else {
deletable.extend(files.iter().cloned());
}
let (deleted, freed) = self.delete_files(&deletable)?;
histogram!(METRIC_LOG_CLEANER_COMPRESS_DURATION_SECONDS).record(started_at.elapsed().as_secs_f64());
Ok((deleted, freed))
}
/// Snapshot compression-related configuration for a single cleanup pass.
fn compression_options(&self) -> CompressionOptions {
CompressionOptions {
algorithm: self.compression_algorithm,
gzip_level: self.gzip_compression_level,
zstd_level: self.zstd_compression_level,
zstd_workers: self.zstd_workers,
zstd_fallback_to_gzip: self.zstd_fallback_to_gzip,
dry_run: self.dry_run,
}
}
/// Delete a file while refusing symlinks and accommodating platform quirks.
fn secure_delete(&self, path: &PathBuf) -> std::io::Result<()> {
let meta = std::fs::symlink_metadata(path)?;
if meta.file_type().is_symlink() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Security: refusing to delete symlink: {:?}", path),
));
}
#[cfg(windows)]
{
// Retry removes to mitigate transient handle races from external
// scanners/AV software.
let mut last_err: Option<std::io::Error> = None;
for _ in 0..3 {
match std::fs::remove_file(path) {
Ok(()) => return Ok(()),
Err(err) => {
last_err = Some(err);
std::thread::sleep(Duration::from_millis(20));
}
}
}
if let Some(err) = last_err {
return Err(err);
}
Ok(())
}
#[cfg(not(windows))]
{
std::fs::remove_file(path)
}
}
/// Delete the supplied files and return `(deleted_count, freed_bytes)`.
///
/// In dry-run mode the returned counters still reflect the projected work
/// so callers and metrics can report what would have happened.
pub(super) fn delete_files(&self, files: &[FileInfo]) -> Result<(usize, u64), std::io::Error> {
let mut deleted = 0usize;
let mut freed = 0u64;
for f in files {
if self.dry_run {
info!(event = EVENT_LOG_CLEANER_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_LOG_CLEANER, state = "dry_run_delete", file = ?f.path, bytes = f.size, "log cleaner state changed");
deleted += 1;
freed += f.projected_freed_bytes;
continue;
}
match self.secure_delete(&f.path) {
Ok(()) => {
deleted += 1;
freed += f.projected_freed_bytes;
debug!(event = EVENT_LOG_CLEANER_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_LOG_CLEANER, state = "deleted", file = ?f.path, bytes = f.size, projected_freed_bytes = f.projected_freed_bytes, "log cleaner state changed");
}
Err(e) => {
error!(event = EVENT_LOG_CLEANER_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_LOG_CLEANER, result = "delete_failed", file = ?f.path, error = %e, "log cleaner state changed");
}
}
}
// Durability: persist the directory entries so the unlinks cannot be
// reordered ahead of the archive data that justified them. Best-effort;
// opening a directory as a file is not portable (e.g. Windows).
if !self.dry_run
&& deleted > 0
&& let Ok(dir) = std::fs::File::open(&self.log_dir)
{
let _ = dir.sync_all();
}
Ok((deleted, freed))
}
}
/// Builder for [`LogCleaner`].
///
/// The builder keeps startup code readable when an application only needs to
/// override a subset of retention knobs.
pub struct LogCleanerBuilder {
log_dir: PathBuf,
file_pattern: String,
active_filename: String,
match_mode: FileMatchMode,
keep_files: usize,
max_total_size_bytes: u64,
max_single_file_size_bytes: u64,
compress_old_files: bool,
gzip_compression_level: u32,
compressed_file_retention_days: u64,
exclude_patterns: Vec<String>,
delete_empty_files: bool,
min_file_age_seconds: u64,
dry_run: bool,
compression_algorithm: CompressionAlgorithm,
parallel_compress: bool,
parallel_workers: usize,
zstd_compression_level: i32,
zstd_fallback_to_gzip: bool,
zstd_workers: usize,
}
impl LogCleanerBuilder {
/// Create a builder with conservative defaults.
pub fn new(log_dir: impl Into<PathBuf>, file_pattern: impl Into<String>, active_filename: impl Into<String>) -> Self {
Self {
log_dir: log_dir.into(),
file_pattern: file_pattern.into(),
active_filename: active_filename.into(),
match_mode: FileMatchMode::Prefix,
keep_files: DEFAULT_LOG_KEEP_FILES,
max_total_size_bytes: 0,
max_single_file_size_bytes: 0,
compress_old_files: false,
gzip_compression_level: 6,
compressed_file_retention_days: 0,
exclude_patterns: Vec::new(),
delete_empty_files: false,
min_file_age_seconds: 0,
dry_run: false,
compression_algorithm: CompressionAlgorithm::default(),
parallel_compress: true,
parallel_workers: default_parallel_workers(),
zstd_compression_level: 8,
zstd_fallback_to_gzip: true,
zstd_workers: 1,
}
}
/// Configure whether `file_pattern` is matched as a prefix or suffix.
pub fn match_mode(mut self, match_mode: FileMatchMode) -> Self {
self.match_mode = match_mode;
self
}
/// Keep at most this many newest regular log files.
pub fn keep_files(mut self, keep_files: usize) -> Self {
self.keep_files = keep_files;
self
}
/// Cap the aggregate size of retained regular log files.
pub fn max_total_size_bytes(mut self, max_total_size_bytes: u64) -> Self {
self.max_total_size_bytes = max_total_size_bytes;
self
}
/// Cap the size of any individual regular log file.
pub fn max_single_file_size_bytes(mut self, max_single_file_size_bytes: u64) -> Self {
self.max_single_file_size_bytes = max_single_file_size_bytes;
self
}
/// Enable archival compression before deleting selected source logs.
pub fn compress_old_files(mut self, compress_old_files: bool) -> Self {
self.compress_old_files = compress_old_files;
self
}
/// Set the gzip compression level used for gzip output or gzip fallback.
pub fn gzip_compression_level(mut self, gzip_compression_level: u32) -> Self {
self.gzip_compression_level = gzip_compression_level;
self
}
/// Set how long compressed archives may remain on disk.
pub fn compressed_file_retention_days(mut self, days: u64) -> Self {
self.compressed_file_retention_days = days;
self
}
/// Exclude files matching these glob patterns from every cleanup pass.
pub fn exclude_patterns(mut self, patterns: Vec<String>) -> Self {
self.exclude_patterns = patterns;
self
}
/// Allow the scanner to remove matching zero-byte regular logs immediately.
pub fn delete_empty_files(mut self, delete_empty_files: bool) -> Self {
self.delete_empty_files = delete_empty_files;
self
}
/// Require regular log files to be at least this old before processing.
pub fn min_file_age_seconds(mut self, seconds: u64) -> Self {
self.min_file_age_seconds = seconds;
self
}
/// Enable dry-run mode for scans, compression decisions, and deletion.
pub fn dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
/// Set the preferred compression algorithm explicitly.
pub fn compression_algorithm(mut self, algorithm: CompressionAlgorithm) -> Self {
self.compression_algorithm = algorithm;
self
}
/// Parse and set the compression algorithm from configuration text.
pub fn compression_algorithm_str(mut self, algorithm: impl AsRef<str>) -> Self {
self.compression_algorithm = CompressionAlgorithm::from_config_str(algorithm.as_ref());
self
}
/// Enable or disable the parallel work-stealing compression path.
pub fn parallel_compress(mut self, enabled: bool) -> Self {
self.parallel_compress = enabled;
self
}
/// Set the number of compression workers, clamped to at least one.
pub fn parallel_workers(mut self, workers: usize) -> Self {
self.parallel_workers = workers.max(1);
self
}
/// Set the zstd compression level.
pub fn zstd_compression_level(mut self, level: i32) -> Self {
self.zstd_compression_level = level;
self
}
/// Retry compression with gzip when zstd encoding fails.
pub fn zstd_fallback_to_gzip(mut self, enabled: bool) -> Self {
self.zstd_fallback_to_gzip = enabled;
self
}
/// Set the number of internal worker threads requested from zstd.
pub fn zstd_workers(mut self, workers: usize) -> Self {
self.zstd_workers = workers.max(1);
self
}
/// Finalize the builder into an immutable [`LogCleaner`].
///
/// Invalid glob patterns are ignored rather than failing construction, and
/// codec-related numeric values are clamped into safe ranges.
pub fn build(self) -> LogCleaner {
// Defense-in-depth for the active-file guard: the scanner protects the
// live log by exact filename equality against `active_filename`. An
// empty value silently disables that protection, so a non-empty
// `file_pattern` could then make the live log a deletion candidate.
// Warn instead of failing so misuse of the public builder is visible.
if self.active_filename.trim().is_empty() && !self.file_pattern.trim().is_empty() {
warn!(
event = EVENT_LOG_CLEANER_STATE,
component = LOG_COMPONENT_OBS,
subsystem = LOG_SUBSYSTEM_LOG_CLEANER,
result = "empty_active_filename",
"active log filename is empty; the active-file exclusion is disabled and the live log may become a deletion candidate"
);
}
// Surface, rather than silently drop, invalid exclude globs: a dropped
// pattern turns "protect this file" into "delete this file", so an
// operator's typo (or a literal comma splitting a char-class) must be
// visible instead of failing open.
let mut patterns = Vec::new();
for raw in self.exclude_patterns {
match glob::Pattern::new(&raw) {
Ok(pattern) => patterns.push(pattern),
Err(err) => warn!(
event = EVENT_LOG_CLEANER_STATE,
component = LOG_COMPONENT_OBS,
subsystem = LOG_SUBSYSTEM_LOG_CLEANER,
result = "invalid_exclude_pattern",
pattern = %raw,
error = %err,
"log cleaner state changed"
),
}
}
LogCleaner {
log_dir: self.log_dir,
file_pattern: self.file_pattern,
active_filename: self.active_filename,
match_mode: self.match_mode,
keep_files: self.keep_files,
max_total_size_bytes: self.max_total_size_bytes,
max_single_file_size_bytes: self.max_single_file_size_bytes,
compress_old_files: self.compress_old_files,
// gzip level 0 is a valid "store" (no compression) mode, so keep the
// lower bound at 0 rather than silently bumping it to 1.
gzip_compression_level: self.gzip_compression_level.clamp(0, 9),
compressed_file_retention_days: self.compressed_file_retention_days,
exclude_patterns: patterns,
delete_empty_files: self.delete_empty_files,
min_file_age_seconds: self.min_file_age_seconds,
dry_run: self.dry_run,
compression_algorithm: self.compression_algorithm,
parallel_compress: self.parallel_compress,
parallel_workers: self.parallel_workers.max(1),
// zstd level 0 means "codec default"; the real maximum is 22, not
// 21, so preserve both boundaries instead of silently narrowing.
zstd_compression_level: self.zstd_compression_level.clamp(0, 22),
zstd_fallback_to_gzip: self.zstd_fallback_to_gzip,
zstd_workers: self.zstd_workers.max(1),
}
}
}
#[cfg(test)]
mod tests {
use super::{MAX_RETENTION_DAYS_BEFORE_SATURATION, SECONDS_PER_DAY, compressed_file_retention_window};
use std::time::Duration;
#[test]
fn compressed_file_retention_window_scales_days_without_wrap() {
assert_eq!(compressed_file_retention_window(3), Duration::from_secs(3 * SECONDS_PER_DAY));
}
#[test]
fn compressed_file_retention_window_saturates_on_large_values() {
assert_eq!(compressed_file_retention_window(u64::MAX), Duration::from_secs(u64::MAX));
}
#[test]
fn retention_day_saturation_boundary_is_safe() {
assert_eq!(
compressed_file_retention_window(MAX_RETENTION_DAYS_BEFORE_SATURATION),
Duration::from_secs(MAX_RETENTION_DAYS_BEFORE_SATURATION * SECONDS_PER_DAY)
);
assert_eq!(
compressed_file_retention_window(MAX_RETENTION_DAYS_BEFORE_SATURATION.saturating_add(1)),
Duration::from_secs(u64::MAX)
);
}
}