kevy_embedded/ops_reconcile.rs
1//! Reconcile derived keys against the rows that imply them.
2//!
3//! A consumer asked for this: they had to write boot-time verification
4//! that rebuilds every derived key from the rows and diffs, and observed
5//! that everyone maintaining link or claim keys needs exactly the same
6//! thing and will write it slightly differently and slightly wrong.
7//!
8//! The recipe it checks is `docs/cookbook.md` §21 — derived state as a
9//! pure function of the row. Pass that same function here and the
10//! checker is the function; there is nothing to keep in step.
11//!
12//! Two things a hand-rolled version usually gets wrong, both handled
13//! here by construction:
14//!
15//! 1. **It scans the live keyspace**, so a write landing mid-scan is
16//! reported as drift. This runs against a [`Snapshot`], which is
17//! frozen under every shard lock, so a clean system is always clean.
18//! 2. **It only looks for missing keys.** A claim left behind by a
19//! half-applied update is an orphan, not an absence, and it is the
20//! failure that silently blocks a later insert. This diffs both ways.
21
22use crate::ops_snapshot_view::Snapshot;
23use std::collections::HashSet;
24
25/// How many example keys each direction of the diff carries. Counts are
26/// exact regardless; the samples exist to be actionable in a log line,
27/// not to be a second copy of the keyspace.
28const MAX_SAMPLES: usize = 1000;
29
30/// The result of [`Snapshot::reconcile`].
31///
32/// ```
33/// assert!(kevy_embedded::ReconcileReport::default().is_clean());
34/// ```
35#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
36#[non_exhaustive]
37pub struct ReconcileReport {
38 /// Rows visited under the row prefix.
39 ///
40 /// ```
41 /// # let s = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
42 /// # for k in [&b"user:1"[..], b"user:2", b"user:3"] {
43 /// # s.hset(k, &[(b"name", b"x")])?;
44 /// # }
45 /// # s.set(b"idx:user:1", b"")?; // user:2 and user:3 lost theirs
46 /// # s.set(b"idx:user:9", b"")?; // left behind by a deleted row
47 /// # let report = s.snapshot().reconcile(b"user:", &[b"idx:"], |key, _| vec![[&b"idx:"[..], key].concat()]);
48 /// // three rows, each implying `idx:<key>`; idx:user:1 exists, idx:user:9 has no row
49 /// assert_eq!(report.rows, 3);
50 /// # Ok::<(), kevy_embedded::KevyError>(())
51 /// ```
52 pub rows: u64,
53 /// Distinct derived keys the rows imply.
54 ///
55 /// ```
56 /// # let s = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
57 /// # for k in [&b"user:1"[..], b"user:2", b"user:3"] {
58 /// # s.hset(k, &[(b"name", b"x")])?;
59 /// # }
60 /// # s.set(b"idx:user:1", b"")?; // user:2 and user:3 lost theirs
61 /// # s.set(b"idx:user:9", b"")?; // left behind by a deleted row
62 /// # let report = s.snapshot().reconcile(b"user:", &[b"idx:"], |key, _| vec![[&b"idx:"[..], key].concat()]);
63 /// // three rows, each implying `idx:<key>`; idx:user:1 exists, idx:user:9 has no row
64 /// assert_eq!(report.expected, 3);
65 /// # Ok::<(), kevy_embedded::KevyError>(())
66 /// ```
67 pub expected: u64,
68 /// Implied by a row, absent from the store — lost derived state.
69 /// Exact count; `missing` holds up to 1000 examples.
70 ///
71 /// ```
72 /// # let s = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
73 /// # for k in [&b"user:1"[..], b"user:2", b"user:3"] {
74 /// # s.hset(k, &[(b"name", b"x")])?;
75 /// # }
76 /// # s.set(b"idx:user:1", b"")?; // user:2 and user:3 lost theirs
77 /// # s.set(b"idx:user:9", b"")?; // left behind by a deleted row
78 /// # let report = s.snapshot().reconcile(b"user:", &[b"idx:"], |key, _| vec![[&b"idx:"[..], key].concat()]);
79 /// // three rows, each implying `idx:<key>`; idx:user:1 exists, idx:user:9 has no row
80 /// assert_eq!(report.missing_count, 2);
81 /// # Ok::<(), kevy_embedded::KevyError>(())
82 /// ```
83 pub missing_count: u64,
84 /// Example missing keys.
85 ///
86 /// ```
87 /// # let s = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
88 /// # for k in [&b"user:1"[..], b"user:2", b"user:3"] {
89 /// # s.hset(k, &[(b"name", b"x")])?;
90 /// # }
91 /// # s.set(b"idx:user:1", b"")?; // user:2 and user:3 lost theirs
92 /// # s.set(b"idx:user:9", b"")?; // left behind by a deleted row
93 /// # let report = s.snapshot().reconcile(b"user:", &[b"idx:"], |key, _| vec![[&b"idx:"[..], key].concat()]);
94 /// // three rows, each implying `idx:<key>`; idx:user:1 exists, idx:user:9 has no row
95 /// let mut missing = report.missing.clone();
96 /// missing.sort();
97 /// assert_eq!(missing, [b"idx:user:2".to_vec(), b"idx:user:3".to_vec()]);
98 /// # Ok::<(), kevy_embedded::KevyError>(())
99 /// ```
100 pub missing: Vec<Vec<u8>>,
101 /// Present under a derived prefix but implied by no row — a claim
102 /// or link left behind. Exact count; `orphaned` holds examples.
103 ///
104 /// ```
105 /// # let s = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
106 /// # for k in [&b"user:1"[..], b"user:2", b"user:3"] {
107 /// # s.hset(k, &[(b"name", b"x")])?;
108 /// # }
109 /// # s.set(b"idx:user:1", b"")?; // user:2 and user:3 lost theirs
110 /// # s.set(b"idx:user:9", b"")?; // left behind by a deleted row
111 /// # let report = s.snapshot().reconcile(b"user:", &[b"idx:"], |key, _| vec![[&b"idx:"[..], key].concat()]);
112 /// // three rows, each implying `idx:<key>`; idx:user:1 exists, idx:user:9 has no row
113 /// assert_eq!(report.orphaned_count, 1);
114 /// # Ok::<(), kevy_embedded::KevyError>(())
115 /// ```
116 pub orphaned_count: u64,
117 /// Example orphaned keys.
118 ///
119 /// ```
120 /// # let s = kevy_embedded::Store::open(kevy_embedded::Config::default())?;
121 /// # for k in [&b"user:1"[..], b"user:2", b"user:3"] {
122 /// # s.hset(k, &[(b"name", b"x")])?;
123 /// # }
124 /// # s.set(b"idx:user:1", b"")?; // user:2 and user:3 lost theirs
125 /// # s.set(b"idx:user:9", b"")?; // left behind by a deleted row
126 /// # let report = s.snapshot().reconcile(b"user:", &[b"idx:"], |key, _| vec![[&b"idx:"[..], key].concat()]);
127 /// // three rows, each implying `idx:<key>`; idx:user:1 exists, idx:user:9 has no row
128 /// assert_eq!(report.orphaned, [b"idx:user:9".to_vec()]);
129 /// # Ok::<(), kevy_embedded::KevyError>(())
130 /// ```
131 pub orphaned: Vec<Vec<u8>>,
132}
133
134impl ReconcileReport {
135 /// Whether the rows and their derived keys agree exactly.
136 pub fn is_clean(&self) -> bool {
137 self.missing_count == 0 && self.orphaned_count == 0
138 }
139
140 /// Whether the sample lists were cut short — the counts are still
141 /// exact, so a caller logging only samples should say so.
142 pub fn truncated(&self) -> bool {
143 self.missing.len() as u64 != self.missing_count
144 || self.orphaned.len() as u64 != self.orphaned_count
145 }
146}
147
148impl Snapshot {
149 /// Rebuild every derived key from the rows under `rows_prefix` and
150 /// diff it against what actually exists under `derived_prefixes`.
151 ///
152 /// `derived` is the function from a row to the keys it implies —
153 /// the same one the write path uses (cookbook §21). Whatever it
154 /// returns for a row is what that row is entitled to; anything else
155 /// under `derived_prefixes` is an orphan.
156 ///
157 /// `derived_prefixes` must cover everywhere derived keys live. A
158 /// derived key outside them is invisible to both directions of the
159 /// diff: it cannot be reported missing (nothing looks for it) and
160 /// it cannot be reported orphaned (nothing enumerates it). This is
161 /// the one way to get a falsely clean report, so the prefixes are a
162 /// required argument rather than an option with a default.
163 ///
164 /// The snapshot is frozen, so a clean system reports clean no
165 /// matter what writers are doing.
166 ///
167 /// ```
168 /// # use kevy_embedded::{Store, Config};
169 /// # let store = Store::open(Config::default())?;
170 /// store.set(b"user:1", b"ann")?;
171 /// store.set(b"email:user:1", b"1")?; // the one key user:1 implies
172 /// store.set(b"dept:gone", b"1")?; // implied by no row
173 /// let report = store.snapshot().reconcile(
174 /// b"user:",
175 /// &[b"email:", b"dept:"],
176 /// |key, _row| vec![[b"email:".as_slice(), key].concat()],
177 /// );
178 /// assert!(!report.is_clean());
179 /// assert_eq!((report.missing_count, report.orphaned), (0, vec![b"dept:gone".to_vec()]));
180 /// # Ok::<(), kevy_embedded::KevyError>(())
181 /// ```
182 pub fn reconcile(
183 &self,
184 rows_prefix: &[u8],
185 derived_prefixes: &[&[u8]],
186 mut derived: impl FnMut(&[u8], &kevy_store::Value) -> Vec<Vec<u8>>,
187 ) -> ReconcileReport {
188 let mut report = ReconcileReport::default();
189 let mut expected: HashSet<Vec<u8>> = HashSet::new();
190 self.each_prefix(rows_prefix, |k, v, _ttl| {
191 report.rows += 1;
192 expected.extend(derived(k, v));
193 });
194 report.expected = expected.len() as u64;
195
196 let mut present: HashSet<Vec<u8>> = HashSet::new();
197 for p in derived_prefixes {
198 self.each_prefix(p, |k, _v, _ttl| {
199 present.insert(k.to_vec());
200 });
201 }
202
203 collect(expected.difference(&present), &mut report.missing_count, &mut report.missing);
204 collect(present.difference(&expected), &mut report.orphaned_count, &mut report.orphaned);
205 report
206 }
207}
208
209/// Count everything, keep the first [`MAX_SAMPLES`].
210fn collect<'a>(
211 keys: impl Iterator<Item = &'a Vec<u8>>,
212 count: &mut u64,
213 samples: &mut Vec<Vec<u8>>,
214) {
215 for k in keys {
216 *count += 1;
217 if samples.len() < MAX_SAMPLES {
218 samples.push(k.clone());
219 }
220 }
221}