yggdrasil/src/tasks/image_cache_cleanup.rs

236 lines
7.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 图片磁盘缓存定期清理任务。
//!
//! 仅在 `server` feature 启用时编译,每小时运行一次。
//! 删除超过保留时间的文件,并在总大小超过上限时按修改时间删除最旧的文件。
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use tokio::time::interval;
const CACHE_DIR: &str = "uploads/.cache";
/// 启动图片磁盘缓存清理循环,每小时触发一次。
pub async fn run_cleanup() {
let mut ticker = interval(Duration::from_secs(3600));
loop {
if let Err(e) = cleanup_image_cache().await {
tracing::error!("Image disk cache cleanup error: {:?}", e);
}
ticker.tick().await;
}
}
/// 读取环境变量并清理默认磁盘缓存目录。
pub async fn cleanup_image_cache() -> io::Result<()> {
let base = Path::new(CACHE_DIR);
let max_mb = std::env::var("IMAGE_DISK_CACHE_MAX_MB")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1024);
let max_age_hours = std::env::var("IMAGE_DISK_CACHE_MAX_AGE_HOURS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(168);
let (deleted, bytes_freed) = cleanup_image_cache_at(base, max_mb, max_age_hours).await?;
if !deleted.is_empty() {
tracing::info!(
"Image disk cache cleanup: removed {} files, freed {} bytes",
deleted.len(),
bytes_freed
);
}
Ok(())
}
/// 清理指定目录下的图片磁盘缓存。
///
/// 返回被删除文件的路径列表以及释放的总字节数。
pub async fn cleanup_image_cache_at(
base: &Path,
max_mb: u64,
max_age_hours: u64,
) -> io::Result<(Vec<PathBuf>, u64)> {
if !base.exists() {
return Ok((Vec::new(), 0));
}
let max_age = Duration::from_secs(max_age_hours * 3600);
let now = SystemTime::now();
let cutoff = now - max_age;
let mut entries: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
collect_files(base, &mut entries).await?;
let mut deleted = Vec::new();
let mut bytes_freed: u64 = 0;
// 第一轮:删除超过保留期限的文件。
let mut remaining: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
for (path, size, mtime) in entries {
if mtime < cutoff {
match tokio::fs::remove_file(&path).await {
Ok(_) => {
deleted.push(path);
bytes_freed += size;
}
Err(e) => {
tracing::warn!("Failed to remove expired cache file {:?}: {:?}", path, e);
}
}
} else {
remaining.push((path, size, mtime));
}
}
// 第二轮:若总大小仍超过上限,按修改时间从旧到新删除。
let max_bytes = max_mb.saturating_mul(1024 * 1024);
let mut total: u64 = remaining.iter().map(|(_, size, _)| size).sum();
if total > max_bytes {
remaining.sort_by_key(|a| a.2);
for (path, size, _) in remaining {
if total <= max_bytes {
break;
}
match tokio::fs::remove_file(&path).await {
Ok(_) => {
total -= size;
deleted.push(path);
bytes_freed += size;
}
Err(e) => {
tracing::warn!(
"Failed to remove cache file {:?} for size cap: {:?}",
path,
e
);
}
}
}
}
Ok((deleted, bytes_freed))
}
/// 递归收集目录下的所有常规文件,返回路径、大小与修改时间。
async fn collect_files(
base: &Path,
entries: &mut Vec<(PathBuf, u64, SystemTime)>,
) -> io::Result<()> {
let mut stack = vec![base.to_path_buf()];
while let Some(dir) = stack.pop() {
let mut reader = tokio::fs::read_dir(&dir).await?;
while let Some(entry) = reader.next_entry().await? {
let metadata = entry.metadata().await?;
if metadata.is_file() {
let mtime = metadata.modified()?;
entries.push((entry.path(), metadata.len(), mtime));
} else if metadata.is_dir() {
stack.push(entry.path());
}
}
}
Ok(())
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
use std::time::{Duration, UNIX_EPOCH};
use tokio::time::sleep;
fn temp_cache_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("yggdrasil_image_cache_test_{}_{}", nanos, std::process::id()))
}
#[tokio::test]
async fn cleanup_ignores_missing_directory() {
let dir = temp_cache_dir();
let (deleted, freed) = cleanup_image_cache_at(&dir, 1024, 168).await.unwrap();
assert!(deleted.is_empty());
assert_eq!(freed, 0);
}
#[tokio::test]
async fn cleanup_removes_expired_files_by_age() {
let dir = temp_cache_dir();
tokio::fs::create_dir_all(&dir).await.unwrap();
let old = dir.join("old.dat");
tokio::fs::write(&old, b"old content").await.unwrap();
// 确保文件的修改时间严格早于清理时计算的截止时间。
sleep(Duration::from_millis(1100)).await;
let (deleted, freed) = cleanup_image_cache_at(&dir, 1024, 0).await.unwrap();
assert_eq!(deleted.len(), 1);
assert!(!old.exists());
assert!(freed > 0);
tokio::fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn cleanup_keeps_recent_files() {
let dir = temp_cache_dir();
tokio::fs::create_dir_all(&dir).await.unwrap();
let recent = dir.join("recent.dat");
tokio::fs::write(&recent, b"recent content").await.unwrap();
let (deleted, freed) = cleanup_image_cache_at(&dir, 1024, 168).await.unwrap();
assert!(deleted.is_empty());
assert_eq!(freed, 0);
assert!(recent.exists());
tokio::fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn cleanup_enforces_size_cap_by_mtime() {
let dir = temp_cache_dir();
tokio::fs::create_dir_all(&dir).await.unwrap();
let f1 = dir.join("oldest.dat");
tokio::fs::write(&f1, vec![0u8; 1024 * 1024]).await.unwrap();
sleep(Duration::from_millis(1100)).await;
let f2 = dir.join("middle.dat");
tokio::fs::write(&f2, vec![0u8; 1024 * 1024]).await.unwrap();
sleep(Duration::from_millis(1100)).await;
let f3 = dir.join("newest.dat");
tokio::fs::write(&f3, vec![0u8; 1024 * 1024]).await.unwrap();
// 上限 2 MB当前 3 MB应删除最旧的一个文件。
let (deleted, freed) = cleanup_image_cache_at(&dir, 2, 1000).await.unwrap();
assert_eq!(deleted.len(), 1);
assert!(!f1.exists());
assert!(f2.exists());
assert!(f3.exists());
assert_eq!(freed, 1024 * 1024);
tokio::fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn cleanup_recurses_into_subdirectories() {
let dir = temp_cache_dir();
let sub = dir.join("nested");
tokio::fs::create_dir_all(&sub).await.unwrap();
let nested = sub.join("nested.dat");
tokio::fs::write(&nested, b"nested content").await.unwrap();
sleep(Duration::from_millis(1100)).await;
let (deleted, _freed) = cleanup_image_cache_at(&dir, 1024, 0).await.unwrap();
assert_eq!(deleted.len(), 1);
assert!(!nested.exists());
tokio::fs::remove_dir_all(&dir).await.unwrap();
}
}