Skip to main content

vcon_server/store/
examine.rs

1//! What `check` reads of one tenant's Store: every kept version opened,
2//! the files no row names, versions due for purge, and purges since the
3//! tenant key was last rotated. Reading changes nothing.
4
5use std::collections::BTreeSet;
6
7use time::OffsetDateTime;
8use time::format_description::well_known::Rfc3339;
9
10use super::{Kept, Store, StoreError, due, io};
11use crate::clock::from_nanos;
12use crate::ids::VersionId;
13use crate::retention::Retention;
14
15/// One Store as `check` found it.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Examination {
18    /// Every kept version, live or hidden.
19    pub versions: u64,
20    /// Versions whose object file is missing or does not open.
21    pub damaged: Vec<VersionId>,
22    /// Files under `objects/` that are a version's, or its temporary, and
23    /// that no row names: what start-up recovery removes.
24    pub leftovers: Vec<String>,
25    /// Versions due for purge now.
26    pub due: u64,
27    /// When the longest-waiting of them fell due.
28    pub oldest_due_since: Option<OffsetDateTime>,
29    /// Purges recorded since `key_since`: their keys are destroyed only by
30    /// the next rotation.
31    pub purges_since_key: u64,
32    /// When the oldest of those purges was made.
33    pub oldest_purge_since_key: Option<OffsetDateTime>,
34}
35
36impl Store {
37    /// Examines the Store under `retention` at `now`, counting purges made
38    /// since `key_since`.
39    pub fn examine(
40        &self,
41        retention: Retention,
42        key_since: OffsetDateTime,
43        now: OffsetDateTime,
44    ) -> Result<Examination, StoreError> {
45        let mut tx = self.catalog.snapshot()?;
46        let rows = tx.examined()?;
47        let time = |nanos| from_nanos(nanos).ok_or(StoreError::Damaged);
48        let mut found = Examination {
49            versions: rows.len() as u64,
50            damaged: Vec::new(),
51            leftovers: Vec::new(),
52            due: 0,
53            oldest_due_since: None,
54            purges_since_key: 0,
55            oldest_purge_since_key: None,
56        };
57        for row in &rows {
58            let (version, stored_at, held) = (row.version, row.stored_at, row.held);
59            match self.read_version(&mut tx, row.uuid, version) {
60                Ok(_) => {}
61                Err(StoreError::Damaged) => found.damaged.push(version),
62                Err(e) => return Err(e),
63            }
64            let Some(hidden_at) = row.hidden_at else {
65                continue;
66            };
67            let stored_at = time(stored_at)?;
68            let kept = Kept {
69                stored_at,
70                hidden: true,
71                held,
72            };
73            if !due(&kept, retention, now) {
74                continue;
75            }
76            // Due once its period ended, or from its hiding if that came
77            // later.
78            let end = retention.after(stored_at).ok_or(StoreError::Damaged)?;
79            let since = end.max(time(hidden_at)?);
80            found.due += 1;
81            found.oldest_due_since = Some(found.oldest_due_since.map_or(since, |o| o.min(since)));
82        }
83        let named: BTreeSet<String> = rows.iter().map(|row| row.version.to_string()).collect();
84        for name in self.objects.list().map_err(io)? {
85            let name = name.as_str();
86            let (id, temporary) = match name.strip_suffix(".tmp") {
87                Some(id) => (id, true),
88                None => (name, false),
89            };
90            if VersionId::parse(id).is_some() && (temporary || !named.contains(id)) {
91                found.leftovers.push(name.to_owned());
92            }
93        }
94        found.leftovers.sort();
95        drop(tx);
96        (found.purges_since_key, found.oldest_purge_since_key) = self.purges_since(key_since)?;
97        Ok(found)
98    }
99
100    /// How many purges were recorded since `since`, and when the oldest of
101    /// them was made: work only the tenant key's next rotation finishes.
102    pub fn purges_since(
103        &self,
104        since: OffsetDateTime,
105    ) -> Result<(u64, Option<OffsetDateTime>), StoreError> {
106        let purged = self.catalog.snapshot()?.purge_times()?;
107        let (mut count, mut oldest) = (0, None::<OffsetDateTime>);
108        for at in purged {
109            let at = OffsetDateTime::parse(&at, &Rfc3339).map_err(|_| StoreError::Damaged)?;
110            if at >= since {
111                count += 1;
112                oldest = Some(oldest.map_or(at, |o| o.min(at)));
113            }
114        }
115        Ok((count, oldest))
116    }
117}
118
119#[cfg(test)]
120mod examine_tests {
121    use time::macros::datetime;
122
123    use super::super::testing::vcon;
124    use super::super::tests::{Fixture, fixture};
125    use super::*;
126    use crate::fs::Fs;
127    use crate::store::Created;
128
129    const START: OffsetDateTime = datetime!(2026-10-23 12:00 UTC);
130
131    fn stored(f: &Fixture, n: u8) -> (uuid::Uuid, VersionId) {
132        let (found, body) = vcon(&format!("01a07da8-c2bb-83e5-b9a2-279e0d16bc4{n}"), "kept");
133        let Ok(Created::New(version)) = f.store.create(&f.caller, &found, &body) else {
134            panic!("a new vCon")
135        };
136        (found.uuid, version)
137    }
138
139    fn examine(f: &Fixture, retention: Retention, now: OffsetDateTime) -> Examination {
140        f.store.examine(retention, START, now).expect("examined")
141    }
142
143    #[test]
144    fn a_sound_store_reports_its_versions_and_nothing_else() {
145        let f = fixture();
146        stored(&f, 0);
147        stored(&f, 1);
148        assert_eq!(
149            examine(&f, Retention::Years(7), START),
150            Examination {
151                versions: 2,
152                damaged: vec![],
153                leftovers: vec![],
154                due: 0,
155                oldest_due_since: None,
156                purges_since_key: 0,
157                oldest_purge_since_key: None,
158            }
159        );
160    }
161
162    #[test]
163    fn a_missing_or_unreadable_object_is_damage() {
164        let f = fixture();
165        let (_, missing) = stored(&f, 0);
166        let (_, altered) = stored(&f, 1);
167        stored(&f, 2);
168        let objects = f.store.objects_dir.clone();
169        f.fs.remove(&objects.join(missing.to_string()))
170            .expect("remove");
171        f.fs.remove(&objects.join(altered.to_string()))
172            .expect("remove");
173        f.fs.write_new(&objects.join(altered.to_string()), b"not a sealed body")
174            .expect("write");
175        let mut expected = vec![missing, altered];
176        expected.sort_by_key(|v| v.0);
177        assert_eq!(examine(&f, Retention::Years(7), START).damaged, expected);
178    }
179
180    #[test]
181    fn files_no_row_names_are_leftovers_and_foreign_files_are_not_ours() {
182        let f = fixture();
183        let (_, version) = stored(&f, 0);
184        let orphan = VersionId([0x0e; 16]);
185        let objects = f.store.objects_dir.clone();
186        for name in [
187            orphan.to_string(),
188            format!("{orphan}.tmp"),
189            format!("{version}.tmp"),
190            "notes.txt".to_owned(),
191        ] {
192            f.fs.write_new(&objects.join(name), b"x").expect("write");
193        }
194        let mut expected = vec![
195            orphan.to_string(),
196            format!("{orphan}.tmp"),
197            format!("{version}.tmp"),
198        ];
199        expected.sort();
200        let found = examine(&f, Retention::Years(7), START);
201        assert_eq!(found.leftovers, expected);
202        assert_eq!(found.damaged, Vec::<VersionId>::new());
203    }
204
205    #[test]
206    fn versions_due_are_counted_from_when_each_fell_due_and_a_hold_keeps_them() {
207        let f = fixture();
208        let (deleted_early, _) = stored(&f, 0);
209        let (deleted_late, _) = stored(&f, 1);
210        let (held, _) = stored(&f, 2);
211        stored(&f, 3);
212        f.store
213            .delete(&f.caller, deleted_early, Retention::Years(7))
214            .expect("deleted");
215        f.store
216            .delete(&f.caller, held, Retention::Years(7))
217            .expect("deleted");
218        f.store.set_hold(&f.caller, held, true).expect("held");
219        // Deleted after its period had already run out: due from the delete.
220        f.clock.set(datetime!(2026-10-26 09:00 UTC));
221        f.store
222            .delete(&f.caller, deleted_late, Retention::Years(7))
223            .expect("deleted");
224        let found = examine(&f, Retention::Days(1), datetime!(2026-10-27 12:00 UTC));
225        assert_eq!(found.due, 2, "the held one is not due");
226        assert_eq!(
227            found.oldest_due_since,
228            Some(datetime!(2026-10-24 12:00 UTC)),
229            "a day after it was stored"
230        );
231        let later = examine(&f, Retention::Years(7), datetime!(2026-10-27 12:00 UTC));
232        assert_eq!((later.due, later.oldest_due_since), (0, None));
233        assert_eq!(later.versions, 4);
234    }
235
236    /// A version hidden after its period had run out became due when it was
237    /// hidden, not when the period ended.
238    #[test]
239    fn a_version_hidden_late_is_due_from_its_hiding() {
240        let f = fixture();
241        let (uuid, _) = stored(&f, 0);
242        f.clock.set(datetime!(2026-10-26 09:00 UTC));
243        f.store
244            .delete(&f.caller, uuid, Retention::Years(7))
245            .expect("deleted");
246        let found = examine(&f, Retention::Days(1), datetime!(2026-10-27 12:00 UTC));
247        assert_eq!(found.due, 1);
248        assert_eq!(
249            found.oldest_due_since,
250            Some(datetime!(2026-10-26 09:00 UTC))
251        );
252    }
253
254    #[test]
255    fn purges_since_the_key_was_rotated_are_counted() {
256        let f = fixture();
257        let (first, _) = stored(&f, 0);
258        let (second, _) = stored(&f, 1);
259        f.clock.set(datetime!(2026-10-24 12:00 UTC));
260        f.store
261            .delete(&f.caller, first, Retention::Days(0))
262            .expect("deleted and purged");
263        f.clock.set(datetime!(2026-10-25 12:00 UTC));
264        f.store
265            .delete(&f.caller, second, Retention::Days(0))
266            .expect("deleted and purged");
267        // A purge that failed was recorded too; it erased nothing.
268        f.store
269            .conn()
270            .execute(
271                "INSERT INTO audit (at, actor, action, target, version, status, count)
272                 VALUES ('2026-10-23T13:00:00Z', 'sweep', 'purge', NULL, NULL, 500, 1)",
273                [],
274            )
275            .expect("a failed purge's record");
276        let now = datetime!(2026-10-26 12:00 UTC);
277        let all = f
278            .store
279            .examine(Retention::Days(0), START, now)
280            .expect("examined");
281        assert_eq!(all.purges_since_key, 2);
282        assert_eq!(
283            all.oldest_purge_since_key,
284            Some(datetime!(2026-10-24 12:00 UTC))
285        );
286        let since = f
287            .store
288            .examine(Retention::Days(0), datetime!(2026-10-25 00:00 UTC), now)
289            .expect("examined");
290        assert_eq!(since.purges_since_key, 1);
291        assert_eq!(
292            since.oldest_purge_since_key,
293            Some(datetime!(2026-10-25 12:00 UTC))
294        );
295        assert_eq!(since.versions, 0);
296    }
297}