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