1use std::path::Path;
24
25use chrono::{Duration, Utc};
26use tracing::{debug, info, warn};
27
28use crate::backup_bundle_store::{self, BundleRecord, BundleState};
29use vti_common::error::AppError;
30use vti_common::store::KeyspaceHandle;
31
32pub const RETENTION_DURATION_HOURS: i64 = 24;
36
37#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
41pub struct SweepStats {
42 pub expired: usize,
44 pub deleted: usize,
47 pub blobs_removed: usize,
52}
53
54pub async fn sweep_bundles(
64 bundles_ks: &KeyspaceHandle,
65 blob_dir: &Path,
66) -> Result<SweepStats, AppError> {
67 let mut stats = SweepStats::default();
68 let now = Utc::now();
69 let retention_cutoff = now - Duration::hours(RETENTION_DURATION_HOURS);
70
71 let all = backup_bundle_store::list_bundles(bundles_ks).await?;
72
73 for record in all {
74 if !record.state.is_terminal() && record.expires_at <= now {
75 let removed_blob = if let Some(ref path) = record.blob_path {
77 match tokio::fs::remove_file(path).await {
78 Ok(()) => true,
79 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
80 Err(e) => {
81 warn!(
82 bundle_id = %record.bundle_id,
83 path = %path.display(),
84 error = %e,
85 "sweeper: failed to delete blob during TTL expiry"
86 );
87 continue;
89 }
90 }
91 } else {
92 false
93 };
94 let mut expired = record.clone();
95 expired.state = BundleState::Expired;
96 expired.blob_path = None;
97 if let Err(e) = backup_bundle_store::store_bundle(bundles_ks, &expired).await {
98 warn!(
99 bundle_id = %record.bundle_id,
100 error = %e,
101 "sweeper: failed to persist Expired state; retry next pass"
102 );
103 continue;
104 }
105 stats.expired += 1;
106 if removed_blob {
107 stats.blobs_removed += 1;
108 }
109 debug!(
110 bundle_id = %record.bundle_id,
111 expired_at = %record.expires_at,
112 "sweeper: bundle expired"
113 );
114 } else if record.state.is_terminal() && record.created_at <= retention_cutoff {
115 if let Some(ref path) = record.blob_path {
119 match tokio::fs::remove_file(path).await {
120 Ok(()) => {
121 stats.blobs_removed += 1;
122 }
123 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
124 Err(e) => {
125 warn!(
126 bundle_id = %record.bundle_id,
127 path = %path.display(),
128 error = %e,
129 "sweeper: failed to delete orphan blob during retention pass"
130 );
131 continue;
132 }
133 }
134 }
135 if let Err(e) = crate::ops::chunked::delete_plan(bundles_ks, &record.bundle_id).await {
138 warn!(
139 bundle_id = %record.bundle_id,
140 error = %e,
141 "sweeper: failed to delete chunk plan; retry next pass"
142 );
143 continue;
144 }
145 if let Err(e) = backup_bundle_store::delete_bundle(bundles_ks, &record.bundle_id).await
146 {
147 warn!(
148 bundle_id = %record.bundle_id,
149 error = %e,
150 "sweeper: failed to delete terminal record; retry next pass"
151 );
152 continue;
153 }
154 stats.deleted += 1;
155 debug!(
156 bundle_id = %record.bundle_id,
157 state = ?record.state,
158 created_at = %record.created_at,
159 "sweeper: terminal bundle past retention; record removed"
160 );
161 }
162 }
163
164 let _ = blob_dir;
166
167 if stats.expired > 0 || stats.deleted > 0 {
168 info!(
169 expired = stats.expired,
170 deleted = stats.deleted,
171 blobs_removed = stats.blobs_removed,
172 "backup-bundle sweeper pruned bundles"
173 );
174 }
175 Ok(stats)
176}
177
178#[allow(dead_code)]
184pub fn is_terminal(record: &BundleRecord) -> bool {
185 record.state.is_terminal()
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use crate::backup_bundle_store::{BundleKind, BundleRecord};
192 use uuid::Uuid;
193 use vti_common::config::StoreConfig as VtiStoreConfig;
194
195 async fn setup() -> (tempfile::TempDir, KeyspaceHandle, std::path::PathBuf) {
196 let dir = tempfile::tempdir().unwrap();
197 let store = vti_common::store::Store::open(&VtiStoreConfig {
198 data_dir: dir.path().into(),
199 })
200 .unwrap();
201 let ks = store.keyspace(crate::BACKUP_BUNDLES_SWEEPER_TEST).unwrap();
202 let blob_dir = dir.path().join("backups");
203 tokio::fs::create_dir_all(&blob_dir).await.unwrap();
204 (dir, ks, blob_dir)
205 }
206
207 fn record(
208 kind: BundleKind,
209 state: BundleState,
210 created_at: chrono::DateTime<Utc>,
211 expires_at: chrono::DateTime<Utc>,
212 blob_path: Option<std::path::PathBuf>,
213 ) -> BundleRecord {
214 BundleRecord {
215 bundle_id: Uuid::new_v4(),
216 kind,
217 state,
218 created_at,
219 expires_at,
220 created_by: "did:example:admin".into(),
221 algorithm: "stream".into(),
222 expected_sha256: "0".repeat(64),
223 expected_size_bytes: 1,
224 token_hash: [0u8; 32],
225 blob_path,
226 }
227 }
228
229 #[tokio::test]
230 async fn ttl_pass_expires_non_terminal_records_past_deadline() {
231 let (_dir, ks, blob_dir) = setup().await;
232 let now = Utc::now();
233 let blob = blob_dir.join("expired.vtabak");
234 tokio::fs::write(&blob, b"bytes").await.unwrap();
235
236 let r = record(
237 BundleKind::Export,
238 BundleState::ExportReady,
239 now - Duration::minutes(10),
240 now - Duration::minutes(5),
241 Some(blob.clone()),
242 );
243 let id = r.bundle_id;
244 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
245
246 let stats = sweep_bundles(&ks, &blob_dir).await.unwrap();
247 assert_eq!(stats.expired, 1);
248 assert_eq!(stats.deleted, 0);
249 assert_eq!(stats.blobs_removed, 1);
250
251 let restored = backup_bundle_store::get_bundle(&ks, &id)
252 .await
253 .unwrap()
254 .unwrap();
255 assert_eq!(restored.state, BundleState::Expired);
256 assert!(restored.blob_path.is_none());
257 assert!(!blob.exists(), "blob file should be deleted");
258 }
259
260 #[tokio::test]
261 async fn ttl_pass_ignores_records_still_within_ttl() {
262 let (_dir, ks, blob_dir) = setup().await;
263 let now = Utc::now();
264 let r = record(
265 BundleKind::Import,
266 BundleState::ImportPending,
267 now,
268 now + Duration::minutes(5),
269 None,
270 );
271 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
272
273 let stats = sweep_bundles(&ks, &blob_dir).await.unwrap();
274 assert_eq!(stats, SweepStats::default());
275 }
276
277 #[tokio::test]
278 async fn ttl_pass_ignores_already_terminal_records() {
279 let (_dir, ks, blob_dir) = setup().await;
283 let now = Utc::now();
284 let r = record(
285 BundleKind::Export,
286 BundleState::ExportAcked,
287 now - Duration::minutes(30),
288 now - Duration::minutes(10),
289 None,
290 );
291 let id = r.bundle_id;
292 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
293
294 let stats = sweep_bundles(&ks, &blob_dir).await.unwrap();
295 assert_eq!(stats, SweepStats::default());
299 let restored = backup_bundle_store::get_bundle(&ks, &id)
300 .await
301 .unwrap()
302 .unwrap();
303 assert_eq!(restored.state, BundleState::ExportAcked);
304 }
305
306 #[tokio::test]
307 async fn retention_pass_deletes_terminal_records_past_cutoff() {
308 let (_dir, ks, blob_dir) = setup().await;
309 let now = Utc::now();
310 let r = record(
311 BundleKind::Import,
312 BundleState::ImportCommitted,
313 now - Duration::hours(48),
314 now - Duration::hours(47),
315 None,
316 );
317 let id = r.bundle_id;
318 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
319
320 let stats = sweep_bundles(&ks, &blob_dir).await.unwrap();
321 assert_eq!(stats.deleted, 1);
322 assert_eq!(stats.expired, 0);
323 assert!(
324 backup_bundle_store::get_bundle(&ks, &id)
325 .await
326 .unwrap()
327 .is_none()
328 );
329 }
330
331 #[tokio::test]
332 async fn retention_pass_keeps_fresh_terminal_records() {
333 let (_dir, ks, blob_dir) = setup().await;
334 let now = Utc::now();
335 let r = record(
336 BundleKind::Export,
337 BundleState::Aborted,
338 now - Duration::hours(1),
339 now - Duration::minutes(30),
340 None,
341 );
342 let id = r.bundle_id;
343 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
344
345 let stats = sweep_bundles(&ks, &blob_dir).await.unwrap();
346 assert_eq!(stats, SweepStats::default());
347 assert!(
348 backup_bundle_store::get_bundle(&ks, &id)
349 .await
350 .unwrap()
351 .is_some()
352 );
353 }
354
355 #[tokio::test]
356 async fn retention_pass_removes_orphan_blob_alongside_record() {
357 let (_dir, ks, blob_dir) = setup().await;
360 let now = Utc::now();
361 let blob = blob_dir.join("orphan.vtabak");
362 tokio::fs::write(&blob, b"stale").await.unwrap();
363 let r = record(
364 BundleKind::Export,
365 BundleState::ExportDownloaded,
366 now - Duration::hours(48),
367 now - Duration::hours(47),
368 Some(blob.clone()),
369 );
370 let id = r.bundle_id;
371 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
372
373 let stats = sweep_bundles(&ks, &blob_dir).await.unwrap();
374 assert_eq!(stats.deleted, 1);
375 assert_eq!(stats.blobs_removed, 1);
376 assert!(!blob.exists());
377 assert!(
378 backup_bundle_store::get_bundle(&ks, &id)
379 .await
380 .unwrap()
381 .is_none()
382 );
383 }
384
385 #[tokio::test]
386 async fn sweep_combines_ttl_and_retention_in_one_pass() {
387 let (_dir, ks, blob_dir) = setup().await;
396 let now = Utc::now();
397
398 let r1 = record(
399 BundleKind::Export,
400 BundleState::ExportReady,
401 now - Duration::minutes(10),
402 now - Duration::minutes(1),
403 None,
404 );
405 let r1_id = r1.bundle_id;
406 let r2 = record(
407 BundleKind::Export,
408 BundleState::Aborted,
409 now - Duration::hours(48),
410 now - Duration::hours(47),
411 None,
412 );
413 let r2_id = r2.bundle_id;
414 let r3 = record(
415 BundleKind::Import,
416 BundleState::ImportCommitted,
417 now - Duration::hours(1),
418 now - Duration::minutes(55),
419 None,
420 );
421 let r3_id = r3.bundle_id;
422 let r4 = record(
423 BundleKind::Import,
424 BundleState::ImportPending,
425 now,
426 now + Duration::minutes(5),
427 None,
428 );
429 let r4_id = r4.bundle_id;
430
431 for r in [&r1, &r2, &r3, &r4] {
432 backup_bundle_store::store_bundle(&ks, r).await.unwrap();
433 }
434
435 let stats = sweep_bundles(&ks, &blob_dir).await.unwrap();
436 assert_eq!(stats.expired, 1);
437 assert_eq!(stats.deleted, 1);
438 assert_eq!(stats.blobs_removed, 0);
439
440 assert_eq!(
441 backup_bundle_store::get_bundle(&ks, &r1_id)
442 .await
443 .unwrap()
444 .unwrap()
445 .state,
446 BundleState::Expired
447 );
448 assert!(
449 backup_bundle_store::get_bundle(&ks, &r2_id)
450 .await
451 .unwrap()
452 .is_none()
453 );
454 assert_eq!(
455 backup_bundle_store::get_bundle(&ks, &r3_id)
456 .await
457 .unwrap()
458 .unwrap()
459 .state,
460 BundleState::ImportCommitted
461 );
462 assert_eq!(
463 backup_bundle_store::get_bundle(&ks, &r4_id)
464 .await
465 .unwrap()
466 .unwrap()
467 .state,
468 BundleState::ImportPending
469 );
470 }
471}