feat: 增加滚动文件日志和保留策略

This commit is contained in:
chuan
2026-08-10 13:14:57 +08:00
parent 104b165f46
commit 2ff9e1d139
8 changed files with 273 additions and 15 deletions
Generated
+20
View File
@@ -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"
+2 -2
View File
@@ -284,7 +284,7 @@
- [ ] 根据实测调整批量大小队列容量和并发
- [x] 增加带排空阶段恢复滞回和探测失败保护的磁盘只读降级策略
- [ ] 增加数据库备份检查点和恢复验证
- [ ] 增加日志轮转和保留策略
- [x] 增加可配置的终端日志滚动文件日志和保留文件上限
- [x] 验证间歇运行和正常退出恢复
- [x] 完成本机约七小时真实持续运行并确认采集索引和搜索服务可用
- [ ] 验证二十四小时和七天连续运行
@@ -336,4 +336,4 @@
完成二十四小时持续运行并继续观察私有内存 Metadata 成功率候选队列深度和每条成功 Metadata 的网络成本
随后增加日志轮转和 RocksDB 检查点恢复
随后增加 RocksDB 检查点恢复
+8
View File
@@ -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
+1
View File
@@ -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"
+17
View File
@@ -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 前执行两层资源与结构校验
+75
View File
@@ -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(_))));
}
}
+14 -4
View File
@@ -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);
+136 -9
View File
@@ -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<WorkerGuard>,
}
pub(crate) fn init(config: &LoggingConfig) -> Result<TelemetryGuard, String> {
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<RollingFileAppender, String> {
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());
}
}