yggdrasil/tasks/
image_cache_cleanup.rs1use std::io;
7use std::path::{Path, PathBuf};
8use std::time::{Duration, SystemTime};
9use tokio::time::interval;
10
11const CACHE_DIR: &str = "uploads/.cache";
12const BYTES_PER_MB: u64 = 1024 * 1024;
13const SECS_PER_HOUR: u64 = 3600;
14const MAX_AGE_HOURS_CAP: u64 = 87_600; pub async fn run_cleanup() {
21 let mut ticker = interval(Duration::from_secs(SECS_PER_HOUR));
22 loop {
23 if let Err(e) = cleanup_image_cache().await {
24 tracing::error!("Image disk cache cleanup error: {:?}", e);
25 }
26 ticker.tick().await;
27 }
28}
29
30pub async fn cleanup_image_cache() -> io::Result<()> {
32 let base = Path::new(CACHE_DIR);
33 let settings = crate::api::settings::runtime_image_cache_settings().await;
34 let max_mb = settings.disk_cache_max_mb;
35 let max_age_hours = settings.disk_cache_max_age_hours;
36 let (deleted, bytes_freed) =
37 cleanup_image_cache_at(base, max_mb as u64, max_age_hours as u64).await?;
38 if !deleted.is_empty() {
39 tracing::info!(
40 "Image disk cache cleanup: removed {} files, freed {} bytes",
41 deleted.len(),
42 bytes_freed
43 );
44 }
45 Ok(())
46}
47
48pub async fn cleanup_image_cache_at(
52 base: &Path,
53 max_mb: u64,
54 max_age_hours: u64,
55) -> io::Result<(Vec<PathBuf>, u64)> {
56 if !base.exists() {
57 return Ok((Vec::new(), 0));
58 }
59
60 let max_age_hours = max_age_hours.min(MAX_AGE_HOURS_CAP);
63 let max_age = Duration::from_secs(max_age_hours.saturating_mul(SECS_PER_HOUR));
64 let now = SystemTime::now();
65 let cutoff = now.checked_sub(max_age).unwrap_or(SystemTime::UNIX_EPOCH);
66
67 let mut entries: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
68 collect_files(base, &mut entries).await?;
69
70 let mut deleted = Vec::new();
71 let mut bytes_freed: u64 = 0;
72
73 let mut remaining: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
75 for (path, size, mtime) in entries {
76 if mtime < cutoff {
77 match tokio::fs::remove_file(&path).await {
78 Ok(_) => {
79 deleted.push(path);
80 bytes_freed += size;
81 }
82 Err(e) => {
83 tracing::warn!("Failed to remove expired cache file {:?}: {:?}", path, e);
84 }
85 }
86 } else {
87 remaining.push((path, size, mtime));
88 }
89 }
90
91 let max_bytes = max_mb.saturating_mul(BYTES_PER_MB);
93 let mut total: u64 = remaining.iter().map(|(_, size, _)| size).sum();
94 if total > max_bytes {
95 remaining.sort_by_key(|a| a.2);
96 for (path, size, _) in remaining {
97 if total <= max_bytes {
98 break;
99 }
100 match tokio::fs::remove_file(&path).await {
101 Ok(_) => {
102 total -= size;
103 deleted.push(path);
104 bytes_freed += size;
105 }
106 Err(e) => {
107 tracing::warn!(
108 "Failed to remove cache file {:?} for size cap: {:?}",
109 path,
110 e
111 );
112 }
113 }
114 }
115 }
116
117 Ok((deleted, bytes_freed))
118}
119
120async fn collect_files(
124 base: &Path,
125 entries: &mut Vec<(PathBuf, u64, SystemTime)>,
126) -> io::Result<()> {
127 let mut stack = vec![base.to_path_buf()];
128 while let Some(dir) = stack.pop() {
129 let mut reader = tokio::fs::read_dir(&dir).await?;
130 while let Some(entry) = reader.next_entry().await? {
131 let file_type = entry.file_type().await?;
132 if file_type.is_symlink() {
133 continue;
134 } else if file_type.is_file() {
135 let metadata = entry.metadata().await?;
136 let mtime = metadata.modified()?;
137 entries.push((entry.path(), metadata.len(), mtime));
138 } else if file_type.is_dir() {
139 stack.push(entry.path());
140 }
141 }
142 }
143 Ok(())
144}
145
146#[cfg(all(test, feature = "server"))]
147mod tests {
148 use super::*;
149 use std::time::{Duration, UNIX_EPOCH};
150 use tokio::time::sleep;
151
152 fn temp_cache_dir() -> PathBuf {
153 let nanos = SystemTime::now()
154 .duration_since(UNIX_EPOCH)
155 .unwrap()
156 .as_nanos();
157 std::env::temp_dir().join(format!(
158 "yggdrasil_image_cache_test_{}_{}",
159 nanos,
160 std::process::id()
161 ))
162 }
163
164 #[tokio::test]
165 async fn cleanup_ignores_missing_directory() {
166 let dir = temp_cache_dir();
167 let (deleted, freed) = cleanup_image_cache_at(&dir, 1024, 168).await.unwrap();
168 assert!(deleted.is_empty());
169 assert_eq!(freed, 0);
170 }
171
172 #[tokio::test]
173 async fn cleanup_removes_expired_files_by_age() {
174 let dir = temp_cache_dir();
175 tokio::fs::create_dir_all(&dir).await.unwrap();
176
177 let old = dir.join("old.dat");
178 tokio::fs::write(&old, b"old content").await.unwrap();
179 sleep(Duration::from_millis(1100)).await;
181
182 let (deleted, freed) = cleanup_image_cache_at(&dir, 1024, 0).await.unwrap();
183 assert_eq!(deleted.len(), 1);
184 assert!(!old.exists());
185 assert!(freed > 0);
186
187 tokio::fs::remove_dir_all(&dir).await.unwrap();
188 }
189
190 #[tokio::test]
191 async fn cleanup_keeps_recent_files() {
192 let dir = temp_cache_dir();
193 tokio::fs::create_dir_all(&dir).await.unwrap();
194
195 let recent = dir.join("recent.dat");
196 tokio::fs::write(&recent, b"recent content").await.unwrap();
197
198 let (deleted, freed) = cleanup_image_cache_at(&dir, 1024, 168).await.unwrap();
199 assert!(deleted.is_empty());
200 assert_eq!(freed, 0);
201 assert!(recent.exists());
202
203 tokio::fs::remove_dir_all(&dir).await.unwrap();
204 }
205
206 #[tokio::test]
207 async fn cleanup_enforces_size_cap_by_mtime() {
208 let dir = temp_cache_dir();
209 tokio::fs::create_dir_all(&dir).await.unwrap();
210
211 let f1 = dir.join("oldest.dat");
212 tokio::fs::write(&f1, vec![0u8; 1024 * 1024]).await.unwrap();
213 sleep(Duration::from_millis(1100)).await;
214
215 let f2 = dir.join("middle.dat");
216 tokio::fs::write(&f2, vec![0u8; 1024 * 1024]).await.unwrap();
217 sleep(Duration::from_millis(1100)).await;
218
219 let f3 = dir.join("newest.dat");
220 tokio::fs::write(&f3, vec![0u8; 1024 * 1024]).await.unwrap();
221
222 let (deleted, freed) = cleanup_image_cache_at(&dir, 2, 1000).await.unwrap();
224 assert_eq!(deleted.len(), 1);
225 assert!(!f1.exists());
226 assert!(f2.exists());
227 assert!(f3.exists());
228 assert_eq!(freed, 1024 * 1024);
229
230 tokio::fs::remove_dir_all(&dir).await.unwrap();
231 }
232
233 #[tokio::test]
234 async fn cleanup_recurses_into_subdirectories() {
235 let dir = temp_cache_dir();
236 let sub = dir.join("nested");
237 tokio::fs::create_dir_all(&sub).await.unwrap();
238
239 let nested = sub.join("nested.dat");
240 tokio::fs::write(&nested, b"nested content").await.unwrap();
241 sleep(Duration::from_millis(1100)).await;
242
243 let (deleted, _freed) = cleanup_image_cache_at(&dir, 1024, 0).await.unwrap();
244 assert_eq!(deleted.len(), 1);
245 assert!(!nested.exists());
246
247 tokio::fs::remove_dir_all(&dir).await.unwrap();
248 }
249
250 #[tokio::test]
251 async fn cleanup_with_huge_max_age_does_not_panic() {
252 let dir = temp_cache_dir();
256 tokio::fs::create_dir_all(&dir).await.unwrap();
257 let recent = dir.join("recent.dat");
258 tokio::fs::write(&recent, b"recent").await.unwrap();
259
260 let (deleted, _freed) = cleanup_image_cache_at(&dir, 1024, u64::MAX).await.unwrap();
263 assert!(
264 deleted.is_empty(),
265 "巨大 max_age 不应删除任何文件, got: {deleted:?}"
266 );
267 assert!(recent.exists());
268
269 tokio::fs::remove_dir_all(&dir).await.unwrap();
270 }
271}