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