feat(tasks): add periodic image disk cache cleanup

This commit is contained in:
xfy 2026-06-18 10:03:38 +08:00
parent 36554af5f5
commit 0b107c3f2e
3 changed files with 243 additions and 0 deletions

View File

@ -73,6 +73,11 @@ fn main() {
tasks::post_purge::run_purge().await;
});
// 启动后台定时任务:图片磁盘缓存清理
tokio::spawn(async {
tasks::image_cache_cleanup::run_cleanup().await;
});
// 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖
let config = ServeConfig::builder().incremental(
dioxus::server::IncrementalRendererConfig::default().invalidate_after(

View File

@ -0,0 +1,235 @@
//! 图片磁盘缓存定期清理任务。
//!
//! 仅在 `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();
}
}

View File

@ -11,3 +11,6 @@ pub mod session_cleanup;
/// 定时清理回收站中超过保留期的已删除文章。
#[cfg(feature = "server")]
pub mod post_purge;
/// 定时清理图片磁盘缓存,避免缓存目录无限增长。
#[cfg(feature = "server")]
pub mod image_cache_cleanup;