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            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    // Drop unused field-lint guard.
155    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/// `is_terminal` already lives on `BundleState` but we re-export
169/// the predicate signature here as `pub` indirection so future
170/// callers can write `backup_bundle_sweeper::is_terminal(state)`
171/// without reaching into the store module's surface. Currently
172/// referenced only by the sweeper itself + tests.
173#[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        // A bundle in `ExportAcked` (terminal) past its expires_at
270        // must NOT be re-transitioned to Expired — it has its own
271        // terminal state and we shouldn't churn the record.
272        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        // expires_at is past but state is terminal — TTL pass skips.
286        // created_at is within retention (10 min ago < 24h) so
287        // retention pass also skips.
288        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        // A terminal bundle with a stale blob_path (defence
348        // against partial-cleanup) should have BOTH cleaned up.
349        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        // Mix of records: one expires-now (ExportReady, past
378        // expires_at), one terminal-and-old (Aborted, > 24h ago),
379        // one terminal-but-fresh (ImportCommitted, 1h ago), one
380        // not-yet-expired (ImportPending). After sweep:
381        //   - first: state = Expired (TTL pass)
382        //   - second: removed entirely (retention pass)
383        //   - third: untouched
384        //   - fourth: untouched
385        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}