1use std::{
21 collections::HashSet,
22 path::{Path, PathBuf},
23 time::{Duration, SystemTime},
24};
25
26use sqlx::{PgConnection, PgPool};
27
28use serde_json::json;
29use uuid::Uuid;
30
31use crate::{
32 domain::FileRetention,
33 error::{AppError, AppResult},
34 preview,
35 services::{audit, revisions},
36 vault::Vault,
37};
38
39pub const BLOB_COLUMNS: &[(&str, &str)] = &[
46 ("change_request_attachments", "sha256"),
47 ("file_versions", "sha256"),
48 ("files", "sha256"),
49 ("staged_imports", "sha256"),
50 ("upload_sessions", "base_sha256"),
51 ("upload_sessions", "sha256"),
52 ("upload_sessions", "stored_sha256"),
53];
54
55pub const NOT_BLOB_COLUMNS: &[(&str, &str)] = &[
57 ("branding_logos", "sha256"),
59 ("shape_signatures", "sha256"),
60];
61
62pub const IN_FLIGHT_GRACE: Duration = Duration::from_secs(3600);
66
67pub const DEFAULT_GC_GRACE_HOURS: u64 = 24;
69
70const BATCH: usize = 1000;
72
73const PREVIEW_TEMP_AGE: Duration = Duration::from_secs(3600);
75
76pub async fn referenced(conn: &mut PgConnection, hashes: &[String]) -> AppResult<HashSet<String>> {
78 let mut found = HashSet::new();
79 for chunk in hashes.chunks(BATCH) {
80 let union = BLOB_COLUMNS
81 .iter()
82 .map(|(table, column)| {
83 format!("SELECT {column} AS sha256 FROM {table} WHERE {column} = ANY($1)")
84 })
85 .collect::<Vec<_>>()
86 .join(" UNION ");
87 let rows: Vec<String> = sqlx::query_scalar(&union)
88 .bind(chunk)
89 .fetch_all(&mut *conn)
90 .await?;
91 found.extend(rows);
92 }
93 Ok(found)
94}
95
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub enum Release {
99 Removed,
101 Referenced,
103 Recent,
105 Missing,
107}
108
109pub async fn release_blob(
113 conn: &mut PgConnection,
114 vault: &Vault,
115 sha256: &str,
116 grace: Duration,
117) -> AppResult<Release> {
118 let sha256 = sha256.to_string();
119 if !referenced(conn, std::slice::from_ref(&sha256))
120 .await?
121 .is_empty()
122 {
123 return Ok(Release::Referenced);
124 }
125 match vault.object_age(&sha256).await? {
126 None => Ok(Release::Missing),
127 Some(age) if age < grace => Ok(Release::Recent),
128 Some(_) if vault.remove_object_older_than(&sha256, grace).await? => Ok(Release::Removed),
129 Some(_) => Ok(Release::Recent),
131 }
132}
133
134#[derive(Clone, Debug)]
136pub struct Orphan {
137 pub sha256: String,
138 pub size: u64,
139 pub removed: bool,
142}
143
144#[derive(Clone, Debug, Default)]
146pub struct GcReport {
147 pub scanned: u64,
149 pub referenced: u64,
151 pub recent: u64,
153 pub orphans: Vec<Orphan>,
155}
156
157impl GcReport {
158 pub fn orphan_bytes(&self) -> u64 {
159 self.orphans.iter().map(|o| o.size).sum()
160 }
161
162 pub fn removed(&self) -> (u64, u64) {
163 self.orphans
164 .iter()
165 .filter(|o| o.removed)
166 .fold((0, 0), |(n, bytes), o| (n + 1, bytes + o.size))
167 }
168}
169
170pub async fn collect_garbage(
175 pool: &PgPool,
176 vault: &Vault,
177 grace: Duration,
178 apply: bool,
179) -> AppResult<GcReport> {
180 let mut report = GcReport::default();
181 let mut conn = pool.acquire().await?;
182 let now = SystemTime::now();
183 for prefix in vault.object_prefixes().await? {
184 let objects = vault.objects_in(&prefix).await?;
185 report.scanned += objects.len() as u64;
186 let hashes: Vec<String> = objects.iter().map(|o| o.sha256.clone()).collect();
187 let known = referenced(&mut conn, &hashes).await?;
188 for object in objects {
189 if known.contains(&object.sha256) {
190 report.referenced += 1;
191 continue;
192 }
193 let age = now.duration_since(object.modified).unwrap_or_default();
194 if age < grace {
195 report.recent += 1;
196 continue;
197 }
198 let removed = apply
199 && vault
200 .remove_object_older_than(&object.sha256, grace)
201 .await?;
202 report.orphans.push(Orphan {
203 sha256: object.sha256,
204 size: object.size,
205 removed,
206 });
207 }
208 }
209 Ok(report)
210}
211
212#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
214pub struct PreviewPurge {
215 pub removed: u64,
217 pub removed_bytes: u64,
218 pub kept_bytes: u64,
220}
221
222struct CacheFile {
223 path: PathBuf,
224 sha256: String,
225 size: u64,
226 used: SystemTime,
227}
228
229pub async fn purge_previews(pool: &PgPool, dir: &Path, max_bytes: u64) -> AppResult<PreviewPurge> {
236 let mut purge = PreviewPurge::default();
237 let mut kept = Vec::new();
238 let mut conn = pool.acquire().await?;
239 let mut tops = match tokio::fs::read_dir(dir).await {
240 Ok(tops) => tops,
241 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(purge),
242 Err(e) => return Err(e.into()),
243 };
244 let now = SystemTime::now();
245 while let Some(top) = tops.next_entry().await? {
246 if !top.file_type().await?.is_dir() {
247 continue;
248 }
249 let mut current = Vec::new();
250 let mut files = tokio::fs::read_dir(top.path()).await?;
251 while let Some(file) = files.next_entry().await? {
252 let meta = file.metadata().await?;
253 if !meta.is_file() {
254 continue;
255 }
256 let name = file.file_name();
257 let Some(name) = name.to_str() else {
258 continue;
259 };
260 let modified = meta.modified().unwrap_or(now);
261 match preview::cache_entry(name) {
262 preview::CacheEntry::Current(sha256) => current.push(CacheFile {
263 path: file.path(),
264 sha256: sha256.to_string(),
265 size: meta.len(),
266 used: meta.accessed().map_or(modified, |a| a.max(modified)),
269 }),
270 preview::CacheEntry::Stale => {
271 remove_cache_file(&file.path(), meta.len(), &mut purge).await;
272 }
273 preview::CacheEntry::Temporary
274 if now.duration_since(modified).unwrap_or_default() > PREVIEW_TEMP_AGE =>
275 {
276 remove_cache_file(&file.path(), meta.len(), &mut purge).await;
277 }
278 preview::CacheEntry::Temporary | preview::CacheEntry::Unknown => {}
279 }
280 }
281 let hashes: Vec<String> = current.iter().map(|f| f.sha256.clone()).collect();
282 let known = referenced(&mut conn, &hashes).await?;
283 for f in current {
284 if known.contains(&f.sha256) {
285 kept.push(f);
286 } else {
287 remove_cache_file(&f.path, f.size, &mut purge).await;
288 }
289 }
290 }
291 let mut total: u64 = kept.iter().map(|f| f.size).sum();
292 if max_bytes > 0 && total > max_bytes {
293 kept.sort_by_key(|f| f.used);
294 for f in &kept {
295 if total <= max_bytes {
296 break;
297 }
298 if remove_cache_file(&f.path, f.size, &mut purge).await {
299 total -= f.size;
300 }
301 }
302 }
303 purge.kept_bytes = total;
304 Ok(purge)
305}
306
307async fn remove_cache_file(path: &Path, size: u64, purge: &mut PreviewPurge) -> bool {
308 match tokio::fs::remove_file(path).await {
309 Ok(()) => {
310 purge.removed += 1;
311 purge.removed_bytes += size;
312 true
313 }
314 Err(_) => false,
316 }
317}
318
319pub const DEFAULT_LATEST_GRACE_DAYS: u32 = 7;
322
323pub const MAX_LATEST_GRACE_DAYS: u32 = 3650;
325
326#[derive(Clone, Debug)]
329pub struct PurgedFile {
330 pub item_number: String,
331 pub revision: String,
332 pub filename: String,
333 pub file_id: Uuid,
334 pub versions: Vec<i32>,
335 pub bytes: u64,
336}
337
338#[derive(Clone, Debug, Default)]
340pub struct VersionPurge {
341 pub files: Vec<PurgedFile>,
342 pub blobs_removed: u64,
345}
346
347impl VersionPurge {
348 pub fn versions(&self) -> u64 {
349 self.files.iter().map(|f| f.versions.len() as u64).sum()
350 }
351
352 pub fn bytes(&self) -> u64 {
353 self.files.iter().map(|f| f.bytes).sum()
354 }
355}
356
357pub async fn purge_superseded_versions(
380 pool: &PgPool,
381 vault: &Vault,
382 grace_days: u32,
383 apply: bool,
384) -> AppResult<VersionPurge> {
385 if grace_days == 0 || grace_days > MAX_LATEST_GRACE_DAYS {
386 return Err(AppError::BadRequest(format!(
387 "The grace period must be between 1 and {MAX_LATEST_GRACE_DAYS} days."
388 )));
389 }
390 let days = i32::try_from(grace_days).unwrap_or(i32::MAX);
391 let candidates: Vec<Uuid> = sqlx::query_scalar(
392 "SELECT f.id FROM files f JOIN revisions r ON r.id = f.revision_id
393 WHERE f.retention = 'latest' AND r.state = 'in_work' AND f.current_version > 1
394 ORDER BY f.id",
395 )
396 .fetch_all(pool)
397 .await?;
398 let mut out = VersionPurge::default();
399 let mut shas = Vec::new();
400 for file_id in candidates {
401 let mut tx = pool.begin().await?;
402 let revision_id: Option<Uuid> =
403 sqlx::query_scalar("SELECT revision_id FROM files WHERE id = $1")
404 .bind(file_id)
405 .fetch_optional(&mut *tx)
406 .await?;
407 let Some(revision_id) = revision_id else {
408 continue;
409 };
410 let rev = match revisions::lock(&mut tx, revision_id).await {
411 Ok(rev) => rev,
412 Err(AppError::NotFound(_)) => continue,
413 Err(e) => return Err(e),
414 };
415 let current: Option<(i32, String, String)> = sqlx::query_as(
416 "SELECT current_version, retention, filename FROM files WHERE id = $1 FOR UPDATE",
417 )
418 .bind(file_id)
419 .fetch_optional(&mut *tx)
420 .await?;
421 let Some((current, retention, filename)) = current else {
422 continue;
423 };
424 if retention != FileRetention::Latest.as_str() || rev.state != "in_work" {
425 continue;
426 }
427 let old: Vec<(i32, String, i64)> = sqlx::query_as(
428 "WITH s AS (
429 SELECT version, sha256, size_bytes,
430 lead(uploaded_at) OVER (ORDER BY version) AS superseded_at
431 FROM file_versions WHERE file_id = $1
432 )
433 SELECT version, sha256, size_bytes FROM s
434 WHERE version < $2 AND superseded_at < now() - make_interval(days => $3)
435 ORDER BY version",
436 )
437 .bind(file_id)
438 .bind(current)
439 .bind(days)
440 .fetch_all(&mut *tx)
441 .await?;
442 if old.is_empty() {
443 continue;
444 }
445 let versions: Vec<i32> = old.iter().map(|(v, _, _)| *v).collect();
446 let bytes: u64 = old
447 .iter()
448 .map(|(_, _, size)| u64::try_from(*size).unwrap_or_default())
449 .sum();
450 if apply {
451 sqlx::query("DELETE FROM file_versions WHERE file_id = $1 AND version = ANY($2)")
452 .bind(file_id)
453 .bind(&versions)
454 .execute(&mut *tx)
455 .await?;
456 audit::record(
457 &mut tx,
458 None,
459 "file.purge_versions",
460 "revision",
461 Some(rev.id),
462 format!(
463 "Removed {} superseded version(s) of {filename} from {} (retention: latest)",
464 versions.len(),
465 revisions::label(&rev)
466 ),
467 json!({
468 "item_id": rev.item_id,
469 "file_id": file_id,
470 "filename": filename,
471 "versions": versions,
472 "sha256": old.iter().map(|(_, sha, _)| sha.as_str()).collect::<Vec<_>>(),
473 "bytes": bytes,
474 "grace_days": grace_days,
475 "kept_version": current,
476 }),
477 )
478 .await?;
479 shas.extend(old.iter().map(|(_, sha, _)| sha.clone()));
480 }
481 tx.commit().await?;
482 out.files.push(PurgedFile {
483 item_number: rev.item_number.clone(),
484 revision: rev.code.clone(),
485 filename,
486 file_id,
487 versions,
488 bytes,
489 });
490 }
491 shas.sort();
492 shas.dedup();
493 for sha256 in shas {
494 let mut conn = pool.acquire().await?;
495 if release_blob(&mut conn, vault, &sha256, IN_FLIGHT_GRACE).await? == Release::Removed {
496 out.blobs_removed += 1;
497 }
498 }
499 Ok(out)
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use std::collections::BTreeSet;
506
507 fn hash_columns_of_the_migrations() -> BTreeSet<(String, String)> {
510 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
511 let table =
512 regex::Regex::new(r"(?i)^\s*(?:CREATE|ALTER)\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)")
513 .unwrap();
514 let column = regex::Regex::new(r"(?i)\b(\w*sha256)\s+TEXT\b").unwrap();
515 let mut found = BTreeSet::new();
516 let mut paths: Vec<_> = std::fs::read_dir(dir)
517 .unwrap()
518 .map(|e| e.unwrap().path())
519 .filter(|p| p.extension().is_some_and(|e| e == "sql"))
520 .collect();
521 paths.sort();
522 for path in paths {
523 let text = std::fs::read_to_string(&path).unwrap();
524 let mut current = String::new();
525 for line in text.lines() {
526 let line = line.split("--").next().unwrap_or_default();
528 if let Some(c) = table.captures(line) {
529 current = c[1].to_lowercase();
530 }
531 for c in column.captures_iter(line) {
533 found.insert((current.clone(), c[1].to_lowercase()));
534 }
535 }
536 }
537 found
538 }
539
540 #[test]
541 fn every_hash_column_is_classified() {
542 let found = hash_columns_of_the_migrations();
543 assert!(
544 found.contains(&("files".into(), "sha256".into())),
545 "the scanner sees the columns: {found:?}"
546 );
547 let listed: BTreeSet<(String, String)> = BLOB_COLUMNS
548 .iter()
549 .chain(NOT_BLOB_COLUMNS)
550 .map(|(t, c)| (t.to_string(), c.to_string()))
551 .collect();
552 let unlisted: Vec<_> = found.difference(&listed).collect();
553 assert!(
554 unlisted.is_empty(),
555 "a migration adds a hash column: list it in BLOB_COLUMNS (it names vault content, \
556 which must never be removed while it is referenced) or in NOT_BLOB_COLUMNS: \
557 {unlisted:?}"
558 );
559 let stale: Vec<_> = listed.difference(&found).collect();
560 assert!(stale.is_empty(), "no such column: {stale:?}");
561 }
562}