| 9 |
9 |
|
//! Nothing in the scan pipeline calls this module yet; it is wired up in
|
| 10 |
10 |
|
//! later chunks of the scanner-streaming refactor.
|
| 11 |
11 |
|
|
| 12 |
|
- |
use crate::constants::{SCAN_SPOOL_FREE_RESERVE_BYTES, SCAN_SPOOL_MAX_BYTES};
|
|
12 |
+ |
use crate::constants::{SCAN_SPOOL_FREE_RESERVE_BYTES, SCAN_SPOOL_MAX_BYTES, SCAN_SPOOL_ORPHAN_AGE_SECS};
|
| 13 |
13 |
|
use s3_storage::ByteStream;
|
| 14 |
14 |
|
use std::path::{Path, PathBuf};
|
| 15 |
15 |
|
use tokio::fs::{File, OpenOptions};
|
| 125 |
125 |
|
|
| 126 |
126 |
|
Ok(SpoolHandle { path })
|
| 127 |
127 |
|
}
|
|
128 |
+ |
|
|
129 |
+ |
/// Reaper outcome — surfaced for logs and metrics.
|
|
130 |
+ |
#[derive(Debug, Default, Clone, Copy)]
|
|
131 |
+ |
pub struct ReaperReport {
|
|
132 |
+ |
pub deleted: u64,
|
|
133 |
+ |
pub kept: u64,
|
|
134 |
+ |
pub errors: u64,
|
|
135 |
+ |
}
|
|
136 |
+ |
|
|
137 |
+ |
/// Walk the spool directory and delete every regular file present —
|
|
138 |
+ |
/// intended for startup, when no live scan can own anything on disk.
|
|
139 |
+ |
/// Missing directory is not an error (a fresh box hasn't created it yet).
|
|
140 |
+ |
pub fn reap_all(spool_dir: &Path) -> ReaperReport {
|
|
141 |
+ |
reap_predicate(spool_dir, |_meta| true)
|
|
142 |
+ |
}
|
|
143 |
+ |
|
|
144 |
+ |
/// Walk the spool directory and delete regular files older than the
|
|
145 |
+ |
/// orphan-age threshold. Intended for the scheduler's 5-minute tick.
|
|
146 |
+ |
pub fn reap_orphans(spool_dir: &Path) -> ReaperReport {
|
|
147 |
+ |
let threshold = std::time::Duration::from_secs(SCAN_SPOOL_ORPHAN_AGE_SECS);
|
|
148 |
+ |
reap_predicate(spool_dir, |meta| {
|
|
149 |
+ |
meta.modified()
|
|
150 |
+ |
.ok()
|
|
151 |
+ |
.and_then(|m| m.elapsed().ok())
|
|
152 |
+ |
.map(|age| age > threshold)
|
|
153 |
+ |
.unwrap_or(false)
|
|
154 |
+ |
})
|
|
155 |
+ |
}
|
|
156 |
+ |
|
|
157 |
+ |
fn reap_predicate<F: Fn(&std::fs::Metadata) -> bool>(
|
|
158 |
+ |
spool_dir: &Path,
|
|
159 |
+ |
should_delete: F,
|
|
160 |
+ |
) -> ReaperReport {
|
|
161 |
+ |
let mut report = ReaperReport::default();
|
|
162 |
+ |
let entries = match std::fs::read_dir(spool_dir) {
|
|
163 |
+ |
Ok(rd) => rd,
|
|
164 |
+ |
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return report,
|
|
165 |
+ |
Err(e) => {
|
|
166 |
+ |
tracing::warn!(dir = %spool_dir.display(), error = %e, "spool reaper: read_dir failed");
|
|
167 |
+ |
report.errors += 1;
|
|
168 |
+ |
return report;
|
|
169 |
+ |
}
|
|
170 |
+ |
};
|
|
171 |
+ |
for entry in entries.flatten() {
|
|
172 |
+ |
let path = entry.path();
|
|
173 |
+ |
let meta = match entry.metadata() {
|
|
174 |
+ |
Ok(m) => m,
|
|
175 |
+ |
Err(_) => {
|
|
176 |
+ |
report.errors += 1;
|
|
177 |
+ |
continue;
|
|
178 |
+ |
}
|
|
179 |
+ |
};
|
|
180 |
+ |
if !meta.is_file() {
|
|
181 |
+ |
continue;
|
|
182 |
+ |
}
|
|
183 |
+ |
if !should_delete(&meta) {
|
|
184 |
+ |
report.kept += 1;
|
|
185 |
+ |
continue;
|
|
186 |
+ |
}
|
|
187 |
+ |
match std::fs::remove_file(&path) {
|
|
188 |
+ |
Ok(()) => {
|
|
189 |
+ |
report.deleted += 1;
|
|
190 |
+ |
tracing::info!(path = %path.display(), "spool reaper: deleted orphan");
|
|
191 |
+ |
}
|
|
192 |
+ |
Err(e) => {
|
|
193 |
+ |
report.errors += 1;
|
|
194 |
+ |
tracing::warn!(path = %path.display(), error = %e, "spool reaper: delete failed");
|
|
195 |
+ |
}
|
|
196 |
+ |
}
|
|
197 |
+ |
}
|
|
198 |
+ |
report
|
|
199 |
+ |
}
|
|
200 |
+ |
|
|
201 |
+ |
#[cfg(test)]
|
|
202 |
+ |
mod tests {
|
|
203 |
+ |
use super::*;
|
|
204 |
+ |
|
|
205 |
+ |
#[test]
|
|
206 |
+ |
fn reap_all_deletes_present_files() {
|
|
207 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
208 |
+ |
std::fs::write(dir.path().join("scan-1.tmp"), b"x").unwrap();
|
|
209 |
+ |
std::fs::write(dir.path().join("scan-2.tmp"), b"y").unwrap();
|
|
210 |
+ |
let report = reap_all(dir.path());
|
|
211 |
+ |
assert_eq!(report.deleted, 2);
|
|
212 |
+ |
assert_eq!(report.errors, 0);
|
|
213 |
+ |
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
|
|
214 |
+ |
}
|
|
215 |
+ |
|
|
216 |
+ |
#[test]
|
|
217 |
+ |
fn reap_orphans_keeps_recent_files() {
|
|
218 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
219 |
+ |
std::fs::write(dir.path().join("scan-1.tmp"), b"x").unwrap();
|
|
220 |
+ |
let report = reap_orphans(dir.path());
|
|
221 |
+ |
assert_eq!(report.deleted, 0);
|
|
222 |
+ |
assert_eq!(report.kept, 1);
|
|
223 |
+ |
}
|
|
224 |
+ |
|
|
225 |
+ |
#[test]
|
|
226 |
+ |
fn reap_handles_missing_directory() {
|
|
227 |
+ |
let report = reap_all(std::path::Path::new("/nonexistent/spool/dir/xyz123"));
|
|
228 |
+ |
assert_eq!(report.deleted, 0);
|
|
229 |
+ |
assert_eq!(report.errors, 0);
|
|
230 |
+ |
}
|
|
231 |
+ |
|
|
232 |
+ |
#[test]
|
|
233 |
+ |
fn spool_handle_drops_file() {
|
|
234 |
+ |
let dir = tempfile::tempdir().unwrap();
|
|
235 |
+ |
let path = dir.path().join("scan-drop.tmp");
|
|
236 |
+ |
std::fs::write(&path, b"data").unwrap();
|
|
237 |
+ |
let handle = SpoolHandle { path: path.clone() };
|
|
238 |
+ |
assert!(path.exists());
|
|
239 |
+ |
drop(handle);
|
|
240 |
+ |
assert!(!path.exists());
|
|
241 |
+ |
}
|
|
242 |
+ |
}
|