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) = backup_bundle_store::delete_bundle(bundles_ks, &record.bundle_id).await
136 {
137 warn!(
138 bundle_id = %record.bundle_id,
139 error = %e,
140 "sweeper: failed to delete terminal record; retry next pass"
141 );
142 continue;
143 }
144 stats.deleted += 1;
145 debug!(
146 bundle_id = %record.bundle_id,
147 state = ?record.state,
148 created_at = %record.created_at,
149 "sweeper: terminal bundle past retention; record removed"
150 );
151 }
152 }
153
154 let _ = blob_dir;
156
157 if stats.expired > 0 || stats.deleted > 0 {
158 info!(
159 expired = stats.expired,
160 deleted = stats.deleted,
161 blobs_removed = stats.blobs_removed,
162 "backup-bundle sweeper pruned bundles"
163 );
164 }
165 Ok(stats)
166}
167
168#[allow(dead_code)]
174pub fn is_terminal(record: &BundleRecord) -> bool {
175 record.state.is_terminal()
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::backup_bundle_store::{BundleKind, BundleRecord};
182 use uuid::Uuid;
183 use vti_common::config::StoreConfig as VtiStoreConfig;
184
185 async fn setup() -> (tempfile::TempDir, KeyspaceHandle, std::path::PathBuf) {
186 let dir = tempfile::tempdir().unwrap();
187 let store = vti_common::store::Store::open(&VtiStoreConfig {
188 data_dir: dir.path().into(),
189 })
190 .unwrap();
191 let ks = store.keyspace(crate::BACKUP_BUNDLES_SWEEPER_TEST).unwrap();
192 let blob_dir = dir.path().join("backups");
193 tokio::fs::create_dir_all(&blob_dir).await.unwrap();
194 (dir, ks, blob_dir)
195 }
196
197 fn record(
198 kind: BundleKind,
199 state: BundleState,
200 created_at: chrono::DateTime<Utc>,
201 expires_at: chrono::DateTime<Utc>,
202 blob_path: Option<std::path::PathBuf>,
203 ) -> BundleRecord {
204 BundleRecord {
205 bundle_id: Uuid::new_v4(),
206 kind,
207 state,
208 created_at,
209 expires_at,
210 created_by: "did:example:admin".into(),
211 algorithm: "stream".into(),
212 expected_sha256: "0".repeat(64),
213 expected_size_bytes: 1,
214 token_hash: [0u8; 32],
215 blob_path,
216 }
217 }
218
219 #[tokio::test]
220 async fn ttl_pass_expires_non_terminal_records_past_deadline() {
221 let (_dir, ks, blob_dir) = setup().await;
222 let now = Utc::now();
223 let blob = blob_dir.join("expired.vtabak");
224 tokio::fs::write(&blob, b"bytes").await.unwrap();
225
226 let r = record(
227 BundleKind::Export,
228 BundleState::ExportReady,
229 now - Duration::minutes(10),
230 now - Duration::minutes(5),
231 Some(blob.clone()),
232 );
233 let id = r.bundle_id;
234 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
235
236 let stats = sweep_bundles(&ks, &blob_dir).await.unwrap();
237 assert_eq!(stats.expired, 1);
238 assert_eq!(stats.deleted, 0);
239 assert_eq!(stats.blobs_removed, 1);
240
241 let restored = backup_bundle_store::get_bundle(&ks, &id)
242 .await
243 .unwrap()
244 .unwrap();
245 assert_eq!(restored.state, BundleState::Expired);
246 assert!(restored.blob_path.is_none());
247 assert!(!blob.exists(), "blob file should be deleted");
248 }
249
250 #[tokio::test]
251 async fn ttl_pass_ignores_records_still_within_ttl() {
252 let (_dir, ks, blob_dir) = setup().await;
253 let now = Utc::now();
254 let r = record(
255 BundleKind::Import,
256 BundleState::ImportPending,
257 now,
258 now + Duration::minutes(5),
259 None,
260 );
261 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
262
263 let stats = sweep_bundles(&ks, &blob_dir).await.unwrap();
264 assert_eq!(stats, SweepStats::default());
265 }
266
267 #[tokio::test]
268 async fn ttl_pass_ignores_already_terminal_records() {
269 let (_dir, ks, blob_dir) = setup().await;
273 let now = Utc::now();
274 let r = record(
275 BundleKind::Export,
276 BundleState::ExportAcked,
277 now - Duration::minutes(30),
278 now - Duration::minutes(10),
279 None,
280 );
281 let id = r.bundle_id;
282 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
283
284 let stats = sweep_bundles(&ks, &blob_dir).await.unwrap();
285 assert_eq!(stats, SweepStats::default());
289 let restored = backup_bundle_store::get_bundle(&ks, &id)
290 .await
291 .unwrap()
292 .unwrap();
293 assert_eq!(restored.state, BundleState::ExportAcked);
294 }
295
296 #[tokio::test]
297 async fn retention_pass_deletes_terminal_records_past_cutoff() {
298 let (_dir, ks, blob_dir) = setup().await;
299 let now = Utc::now();
300 let r = record(
301 BundleKind::Import,
302 BundleState::ImportCommitted,
303 now - Duration::hours(48),
304 now - Duration::hours(47),
305 None,
306 );
307 let id = r.bundle_id;
308 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
309
310 let stats = sweep_bundles(&ks, &blob_dir).await.unwrap();
311 assert_eq!(stats.deleted, 1);
312 assert_eq!(stats.expired, 0);
313 assert!(
314 backup_bundle_store::get_bundle(&ks, &id)
315 .await
316 .unwrap()
317 .is_none()
318 );
319 }
320
321 #[tokio::test]
322 async fn retention_pass_keeps_fresh_terminal_records() {
323 let (_dir, ks, blob_dir) = setup().await;
324 let now = Utc::now();
325 let r = record(
326 BundleKind::Export,
327 BundleState::Aborted,
328 now - Duration::hours(1),
329 now - Duration::minutes(30),
330 None,
331 );
332 let id = r.bundle_id;
333 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
334
335 let stats = sweep_bundles(&ks, &blob_dir).await.unwrap();
336 assert_eq!(stats, SweepStats::default());
337 assert!(
338 backup_bundle_store::get_bundle(&ks, &id)
339 .await
340 .unwrap()
341 .is_some()
342 );
343 }
344
345 #[tokio::test]
346 async fn retention_pass_removes_orphan_blob_alongside_record() {
347 let (_dir, ks, blob_dir) = setup().await;
350 let now = Utc::now();
351 let blob = blob_dir.join("orphan.vtabak");
352 tokio::fs::write(&blob, b"stale").await.unwrap();
353 let r = record(
354 BundleKind::Export,
355 BundleState::ExportDownloaded,
356 now - Duration::hours(48),
357 now - Duration::hours(47),
358 Some(blob.clone()),
359 );
360 let id = r.bundle_id;
361 backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
362
363 let stats = sweep_bundles(&ks, &blob_dir).await.unwrap();
364 assert_eq!(stats.deleted, 1);
365 assert_eq!(stats.blobs_removed, 1);
366 assert!(!blob.exists());
367 assert!(
368 backup_bundle_store::get_bundle(&ks, &id)
369 .await
370 .unwrap()
371 .is_none()
372 );
373 }
374
375 #[tokio::test]
376 async fn sweep_combines_ttl_and_retention_in_one_pass() {
377 let (_dir, ks, blob_dir) = setup().await;
386 let now = Utc::now();
387
388 let r1 = record(
389 BundleKind::Export,
390 BundleState::ExportReady,
391 now - Duration::minutes(10),
392 now - Duration::minutes(1),
393 None,
394 );
395 let r1_id = r1.bundle_id;
396 let r2 = record(
397 BundleKind::Export,
398 BundleState::Aborted,
399 now - Duration::hours(48),
400 now - Duration::hours(47),
401 None,
402 );
403 let r2_id = r2.bundle_id;
404 let r3 = record(
405 BundleKind::Import,
406 BundleState::ImportCommitted,
407 now - Duration::hours(1),
408 now - Duration::minutes(55),
409 None,
410 );
411 let r3_id = r3.bundle_id;
412 let r4 = record(
413 BundleKind::Import,
414 BundleState::ImportPending,
415 now,
416 now + Duration::minutes(5),
417 None,
418 );
419 let r4_id = r4.bundle_id;
420
421 for r in [&r1, &r2, &r3, &r4] {
422 backup_bundle_store::store_bundle(&ks, r).await.unwrap();
423 }
424
425 let stats = sweep_bundles(&ks, &blob_dir).await.unwrap();
426 assert_eq!(stats.expired, 1);
427 assert_eq!(stats.deleted, 1);
428 assert_eq!(stats.blobs_removed, 0);
429
430 assert_eq!(
431 backup_bundle_store::get_bundle(&ks, &r1_id)
432 .await
433 .unwrap()
434 .unwrap()
435 .state,
436 BundleState::Expired
437 );
438 assert!(
439 backup_bundle_store::get_bundle(&ks, &r2_id)
440 .await
441 .unwrap()
442 .is_none()
443 );
444 assert_eq!(
445 backup_bundle_store::get_bundle(&ks, &r3_id)
446 .await
447 .unwrap()
448 .unwrap()
449 .state,
450 BundleState::ImportCommitted
451 );
452 assert_eq!(
453 backup_bundle_store::get_bundle(&ks, &r4_id)
454 .await
455 .unwrap()
456 .unwrap()
457 .state,
458 BundleState::ImportPending
459 );
460 }
461}