Skip to main content

vta_backup/
backup_bundle_sweeper.rs

1//! Background pruning of expired + terminal backup bundles.
2//!
3//! Two passes on each invocation:
4//!
5//! 1. **TTL pass** — for every non-terminal bundle whose
6//!    `expires_at` has passed, delete its blob file (if any) and
7//!    transition the record to `Expired`. The expired record
8//!    persists for the retention window so audit tools and the
9//!    operator-facing CLI can still see what happened.
10//!
11//! 2. **Retention pass** — for every terminal bundle (Aborted,
12//!    Expired, ExportAcked, ImportCommitted, ExportDownloaded)
13//!    older than the retention cutoff, remove the record entirely.
14//!    Default retention is 24h from `created_at` — long enough for
15//!    operator audit follow-up, short enough that records don't
16//!    accumulate.
17//!
18//! Called from the storage thread's interval loop in
19//! `server::run()`. Failures log at `warn!` but don't abort the
20//! loop — a transient fjall or filesystem error shouldn't take
21//! down the storage thread.
22
23use 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
32/// How long a terminal bundle's record sticks around before the
33/// retention pass deletes it. Long enough for operator audit
34/// follow-up, short enough to keep the keyspace tidy.
35pub const RETENTION_DURATION_HOURS: i64 = 24;
36
37/// Sweeper result counters. Returned for the storage thread's
38/// log line and consumed by tests asserting the right work
39/// happened.
40#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
41pub struct SweepStats {
42    /// Number of records transitioned to `Expired` this pass.
43    pub expired: usize,
44    /// Number of records deleted from the keyspace this pass
45    /// (terminal + past retention).
46    pub deleted: usize,
47    /// Number of blob files removed from disk this pass. Tracked
48    /// independently of `expired`/`deleted` because crash-recovery
49    /// pre-conditions may leave orphan files paired with
50    /// already-cleaned records.
51    pub blobs_removed: usize,
52}
53
54/// Run one sweep pass over the backup-bundle keyspace.
55///
56/// Safe to call concurrently with handler-driven mutations on the
57/// same keyspace — fjall serialises individual key writes, and
58/// every transition we apply here is idempotent (a record we
59/// expire is one that wasn't terminal when we read it; if a
60/// handler raced us and made it terminal first, our subsequent
61/// `store_bundle` overwrites the terminal state with `Expired`,
62/// which is also terminal — same outcome).
63pub 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            // TTL pass: expire this record.
76            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                        // Don't transition — try again next pass.
88                        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            // Retention pass: remove the record. Also delete any
116            // orphan blob_path that managed to survive (defence
117            // against earlier sweeper bugs / partial failures).
118            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            // A chunked bundle's plan goes with its record. Absent for stream
136            // bundles; a failure here only leaves an inert record to retry.
137            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    // Drop unused field-lint guard.
165    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/// `is_terminal` already lives on `BundleState` but we re-export
179/// the predicate signature here as `pub` indirection so future
180/// callers can write `backup_bundle_sweeper::is_terminal(state)`
181/// without reaching into the store module's surface. Currently
182/// referenced only by the sweeper itself + tests.
183#[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        // A bundle in `ExportAcked` (terminal) past its expires_at
280        // must NOT be re-transitioned to Expired — it has its own
281        // terminal state and we shouldn't churn the record.
282        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        // expires_at is past but state is terminal — TTL pass skips.
296        // created_at is within retention (10 min ago < 24h) so
297        // retention pass also skips.
298        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        // A terminal bundle with a stale blob_path (defence
358        // against partial-cleanup) should have BOTH cleaned up.
359        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        // Mix of records: one expires-now (ExportReady, past
388        // expires_at), one terminal-and-old (Aborted, > 24h ago),
389        // one terminal-but-fresh (ImportCommitted, 1h ago), one
390        // not-yet-expired (ImportPending). After sweep:
391        //   - first: state = Expired (TTL pass)
392        //   - second: removed entirely (retention pass)
393        //   - third: untouched
394        //   - fourth: untouched
395        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}