diff --git a/Cargo.lock b/Cargo.lock index b179d89..2b92e91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -672,6 +672,7 @@ dependencies = [ "tower", "tower-http", "tracing", + "tracing-appender", "tracing-subscriber", "unicode-normalization", "windows-sys 0.61.2", @@ -2464,6 +2465,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "2.0.119" @@ -2929,6 +2936,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.19", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/TODOS.md b/TODOS.md index 855052a..fc53048 100644 --- a/TODOS.md +++ b/TODOS.md @@ -284,7 +284,7 @@ - [ ] 根据实测调整批量大小队列容量和并发 - [x] 增加带排空阶段恢复滞回和探测失败保护的磁盘只读降级策略 - [ ] 增加数据库备份检查点和恢复验证 -- [ ] 增加日志轮转和保留策略 +- [x] 增加可配置的终端日志滚动文件日志和保留文件上限 - [x] 验证间歇运行和正常退出恢复 - [x] 完成本机约七小时真实持续运行并确认采集索引和搜索服务可用 - [ ] 验证二十四小时和七天连续运行 @@ -336,4 +336,4 @@ 完成二十四小时持续运行并继续观察私有内存 Metadata 成功率候选队列深度和每条成功 Metadata 的网络成本 -随后增加日志轮转和 RocksDB 检查点恢复 +随后增加 RocksDB 检查点恢复 diff --git a/dht-search.example.toml b/dht-search.example.toml index 4b4d55b..24e5210 100644 --- a/dht-search.example.toml +++ b/dht-search.example.toml @@ -13,6 +13,14 @@ check_interval_secs = 10 minimum_free_bytes = 5368709120 resume_free_bytes = 6442450944 +[logging] +directory = "data/logs" +file_enabled = true +console_enabled = false +rotation = "daily" +retain_files = 7 +file_prefix = "dht-search" + [metadata_limits] max_metadata_bytes = 10485760 max_files = 20000 diff --git a/dht-search/Cargo.toml b/dht-search/Cargo.toml index 3bcbf40..5cebb48 100644 --- a/dht-search/Cargo.toml +++ b/dht-search/Cargo.toml @@ -31,6 +31,7 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", " tokio-util.workspace = true toml = "0.9" tracing.workspace = true +tracing-appender = "0.2" tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } tower-http = { version = "0.6", features = ["fs"] } unicode-normalization = "0.1" diff --git a/dht-search/README.md b/dht-search/README.md index f68e721..d2e1e1b 100644 --- a/dht-search/README.md +++ b/dht-search/README.md @@ -101,6 +101,23 @@ Windows 下索引每五秒批量提交 临时文件占用会自动指数退避 磁盘空间探测失败时采用保守策略进入保护状态 `/stats` 返回 `disk_state` `disk_available_bytes` 阈值 活跃写入数 探测失败数 状态转换数和拒绝任务数 Web 运行状态使用绿色或黄色状态点展示正常与保护状态 +### 日志轮转和保留 + +应用会在读取配置后初始化日志 默认只写入 `data/logs` 的滚动文件而不重复输出到终端 因此用脚本或后台进程启动时不需要再把标准错误重定向到长期增长的日志文件 + +| 配置项 | 默认值 | 作用 | +|---|---:|---| +| `logging.directory` | `data/logs` | 日志文件目录 相对主配置文件解析 | +| `logging.file_enabled` | `true` | 启用滚动文件日志 | +| `logging.console_enabled` | `false` | 同时输出到当前终端 | +| `logging.rotation` | `daily` | 轮转周期 支持 `minutely` `hourly` `daily` 和 `never` | +| `logging.retain_files` | `7` | 最多保留的匹配日志文件数量 | +| `logging.file_prefix` | `dht-search` | 日志文件名前缀 | + +默认按天轮转时保留 7 个文件约等于保留最近 7 天 日志组件只清理同目录中同时匹配前缀和 `.log` 后缀的普通文件 不删除目录和符号链接 清理失败会输出错误但不会让服务退出 + +开发时需要直接观察终端日志可以设置 `console_enabled = true` 文件日志和终端日志不能同时关闭 + ### Metadata 安全限制 应用会在 Metadata 下载和进入 RocksDB 前执行两层资源与结构校验 diff --git a/dht-search/src/config.rs b/dht-search/src/config.rs index 4533ef5..f791bc6 100644 --- a/dht-search/src/config.rs +++ b/dht-search/src/config.rs @@ -36,6 +36,7 @@ pub(crate) struct AppConfig { pub(crate) metadata_limits: MetadataLimitsConfig, pub(crate) dht: DhtConfig, pub(crate) disk_guard: DiskGuardConfig, + pub(crate) logging: LoggingConfig, pub(crate) http: HttpConfig, pub(crate) verification: VerificationConfig, } @@ -80,6 +81,27 @@ pub(crate) struct DiskGuardConfig { pub(crate) resume_free_bytes: u64, } +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct LoggingConfig { + pub(crate) directory: PathBuf, + pub(crate) file_enabled: bool, + pub(crate) console_enabled: bool, + pub(crate) rotation: LogRotation, + pub(crate) retain_files: usize, + pub(crate) file_prefix: String, +} + +#[derive(Debug, Clone, Copy, Default, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum LogRotation { + Minutely, + Hourly, + #[default] + Daily, + Never, +} + #[derive(Debug, Clone, Deserialize)] #[serde(default, deny_unknown_fields)] pub(crate) struct VerificationConfig { @@ -137,6 +159,10 @@ impl Cli { config.content_filter_file = base.join(&config.content_filter_file); } config.content_filter_file = normalize_absolute(config.content_filter_file)?; + if config.logging.directory.is_relative() { + config.logging.directory = base.join(&config.logging.directory); + } + config.logging.directory = normalize_absolute(config.logging.directory)?; if config.http.web_dir.is_relative() { config.http.web_dir = base.join(&config.http.web_dir); } @@ -270,6 +296,25 @@ impl AppConfig { "磁盘检查间隔必须大于零且恢复阈值必须大于保护阈值".to_owned(), )); } + if !self.logging.file_enabled && !self.logging.console_enabled { + return Err(AppError::Config( + "文件日志和终端日志不能同时关闭".to_owned(), + )); + } + if self.logging.file_enabled && self.logging.retain_files == 0 { + return Err(AppError::Config("日志保留文件数量必须大于零".to_owned())); + } + if self.logging.file_prefix.trim().is_empty() + || self + .logging + .file_prefix + .chars() + .any(|character| character.is_control() || matches!(character, '/' | '\\')) + { + return Err(AppError::Config( + "日志文件前缀不能为空且不能包含路径分隔符或控制字符".to_owned(), + )); + } if self.dht.max_outbound_queries_per_second == 0 || self.dht.outbound_query_burst == 0 || self.dht.metadata_connects_per_second == 0 @@ -302,6 +347,7 @@ impl Default for AppConfig { metadata_limits: MetadataLimitsConfig::default(), dht: DhtConfig::default(), disk_guard: DiskGuardConfig::default(), + logging: LoggingConfig::default(), http: HttpConfig::default(), verification: VerificationConfig::default(), } @@ -366,6 +412,19 @@ impl Default for DiskGuardConfig { } } +impl Default for LoggingConfig { + fn default() -> Self { + Self { + directory: PathBuf::from("data/logs"), + file_enabled: true, + console_enabled: false, + rotation: LogRotation::Daily, + retain_files: 7, + file_prefix: "dht-search".to_owned(), + } + } +} + impl Default for VerificationConfig { fn default() -> Self { Self { @@ -451,6 +510,7 @@ mod tests { config.content_filter_file, directory.path().join("content-filters.toml") ); + assert_eq!(config.logging.directory, directory.path().join("data/logs")); assert_eq!(config.http.web_dir, directory.path().join("web/dist")); } @@ -489,4 +549,19 @@ mod tests { config.disk_guard.resume_free_bytes = config.disk_guard.minimum_free_bytes; assert!(matches!(config.validate(), Err(AppError::Config(_)))); } + + #[test] + fn logging_requires_at_least_one_output() { + let mut config = AppConfig::default(); + config.logging.file_enabled = false; + config.logging.console_enabled = false; + assert!(matches!(config.validate(), Err(AppError::Config(_)))); + } + + #[test] + fn logging_prefix_cannot_escape_the_log_directory() { + let mut config = AppConfig::default(); + config.logging.file_prefix = "../service".into(); + assert!(matches!(config.validate(), Err(AppError::Config(_)))); + } } diff --git a/dht-search/src/main.rs b/dht-search/src/main.rs index 70327ae..9c984fc 100644 --- a/dht-search/src/main.rs +++ b/dht-search/src/main.rs @@ -16,11 +16,21 @@ mod verification; #[tokio::main] async fn main() { - telemetry::init(); - let result = match config::Cli::parse().load() { - Ok(config) => app::run(config).await, - Err(error) => Err(error), + let config = match config::Cli::parse().load() { + Ok(config) => config, + Err(error) => { + eprintln!("无法加载配置: {error}"); + std::process::exit(1); + } }; + let _telemetry = match telemetry::init(&config.logging) { + Ok(guard) => guard, + Err(error) => { + eprintln!("无法初始化日志: {error}"); + std::process::exit(1); + } + }; + let result = app::run(config).await; if let Err(error) = result { tracing::error!(%error, "dht-search 退出"); std::process::exit(1); diff --git a/dht-search/src/telemetry.rs b/dht-search/src/telemetry.rs index 81573d6..8970885 100644 --- a/dht-search/src/telemetry.rs +++ b/dht-search/src/telemetry.rs @@ -1,16 +1,143 @@ -// 负责初始化结构化日志指标和运行状态观测 +// 负责初始化终端与滚动文件日志并持有异步日志刷新守卫 -use tracing_subscriber::EnvFilter; +use tracing_appender::{ + non_blocking::WorkerGuard, + rolling::{RollingFileAppender, Rotation}, +}; +use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; -pub(crate) fn init() { +use crate::config::{LogRotation, LoggingConfig}; + +pub(crate) struct TelemetryGuard { + _file_guard: Option, +} + +pub(crate) fn init(config: &LoggingConfig) -> Result { let filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("warn,dht_search=info,dht_crawler=info")); - if let Err(error) = tracing_subscriber::fmt() - .with_env_filter(filter) - .with_target(true) - .with_ansi(std::io::IsTerminal::is_terminal(&std::io::stderr())) + let console_layer = config.console_enabled.then(|| { + tracing_subscriber::fmt::layer() + .with_target(true) + .with_ansi(std::io::IsTerminal::is_terminal(&std::io::stderr())) + .with_writer(std::io::stderr) + }); + let (file_layer, file_guard) = if config.file_enabled { + let appender = build_file_appender(config)?; + let (writer, guard) = tracing_appender::non_blocking(appender); + ( + Some( + tracing_subscriber::fmt::layer() + .with_target(true) + .with_ansi(false) + .with_writer(writer), + ), + Some(guard), + ) + } else { + (None, None) + }; + tracing_subscriber::registry() + .with(filter) + .with(console_layer) + .with(file_layer) .try_init() - { - eprintln!("无法初始化日志订阅器: {error}"); + .map_err(|error| error.to_string())?; + Ok(TelemetryGuard { + _file_guard: file_guard, + }) +} + +fn build_file_appender(config: &LoggingConfig) -> Result { + std::fs::create_dir_all(&config.directory).map_err(|error| error.to_string())?; + RollingFileAppender::builder() + .rotation(rotation(config.rotation)) + .filename_prefix(&config.file_prefix) + .filename_suffix("log") + .max_log_files(config.retain_files) + .build(&config.directory) + .map_err(|error| error.to_string()) +} + +const fn rotation(value: LogRotation) -> Rotation { + match value { + LogRotation::Minutely => Rotation::MINUTELY, + LogRotation::Hourly => Rotation::HOURLY, + LogRotation::Daily => Rotation::DAILY, + LogRotation::Never => Rotation::NEVER, + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn rolling_appender_writes_inside_configured_directory() { + let directory = TempDir::new().unwrap(); + let config = LoggingConfig { + directory: directory.path().to_path_buf(), + rotation: LogRotation::Never, + retain_files: 2, + file_prefix: "service-test".into(), + ..LoggingConfig::default() + }; + let appender = build_file_appender(&config).unwrap(); + let (mut writer, guard) = tracing_appender::non_blocking(appender); + writeln!(writer, "hello rolling log").unwrap(); + drop(writer); + drop(guard); + + let files: Vec<_> = std::fs::read_dir(directory.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(files.len(), 1); + assert!(files[0].to_string_lossy().starts_with("service-test")); + let contents = std::fs::read_to_string(directory.path().join(&files[0])).unwrap(); + assert!(contents.contains("hello rolling log")); + } + + #[test] + fn retention_limit_prunes_only_matching_log_files() { + let directory = TempDir::new().unwrap(); + for day in 1..=3 { + std::fs::write( + directory + .path() + .join(format!("service-test.2020-01-0{day}.log")), + day.to_string(), + ) + .unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + std::fs::write(directory.path().join("unrelated.log"), "keep").unwrap(); + std::fs::create_dir(directory.path().join("service-test.directory.log")).unwrap(); + let config = LoggingConfig { + directory: directory.path().to_path_buf(), + rotation: LogRotation::Daily, + retain_files: 2, + file_prefix: "service-test".into(), + ..LoggingConfig::default() + }; + + drop(build_file_appender(&config).unwrap()); + + let matching_files = std::fs::read_dir(directory.path()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file())) + .filter(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + name.starts_with("service-test") && name.ends_with(".log") + }) + .count(); + assert!(matching_files <= 2); + assert!(directory.path().join("unrelated.log").exists()); + assert!(directory.path().join("service-test.directory.log").is_dir()); } }