Skip to main content

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}