1use 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#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Examination {
18 pub versions: u64,
20 pub damaged: Vec<VersionId>,
22 pub leftovers: Vec<String>,
25 pub due: u64,
27 pub oldest_due_since: Option<OffsetDateTime>,
29 pub purges_since_key: u64,
32 pub oldest_purge_since_key: Option<OffsetDateTime>,
34}
35
36impl Store {
37 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 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 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 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 #[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 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}