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#[derive(Debug, Default, Clone)]
32pub struct ReconcileReport {
33 /// Rows visited under the row prefix.
34 pub rows: u64,
35 /// Distinct derived keys the rows imply.
36 pub expected: u64,
37 /// Implied by a row, absent from the store — lost derived state.
38 /// Exact count; `missing` holds up to [`MAX_SAMPLES`] examples.
39 pub missing_count: u64,
40 /// Example missing keys.
41 pub missing: Vec<Vec<u8>>,
42 /// Present under a derived prefix but implied by no row — a claim
43 /// or link left behind. Exact count; `orphaned` holds examples.
44 pub orphaned_count: u64,
45 /// Example orphaned keys.
46 pub orphaned: Vec<Vec<u8>>,
47}
48
49impl ReconcileReport {
50 /// Whether the rows and their derived keys agree exactly.
51 pub fn is_clean(&self) -> bool {
52 self.missing_count == 0 && self.orphaned_count == 0
53 }
54
55 /// Whether the sample lists were cut short — the counts are still
56 /// exact, so a caller logging only samples should say so.
57 pub fn truncated(&self) -> bool {
58 self.missing.len() as u64 != self.missing_count
59 || self.orphaned.len() as u64 != self.orphaned_count
60 }
61}
62
63impl Snapshot {
64 /// Rebuild every derived key from the rows under `rows_prefix` and
65 /// diff it against what actually exists under `derived_prefixes`.
66 ///
67 /// `derived` is the function from a row to the keys it implies —
68 /// the same one the write path uses (cookbook §21). Whatever it
69 /// returns for a row is what that row is entitled to; anything else
70 /// under `derived_prefixes` is an orphan.
71 ///
72 /// `derived_prefixes` must cover everywhere derived keys live. A
73 /// derived key outside them is invisible to both directions of the
74 /// diff: it cannot be reported missing (nothing looks for it) and
75 /// it cannot be reported orphaned (nothing enumerates it). This is
76 /// the one way to get a falsely clean report, so the prefixes are a
77 /// required argument rather than an option with a default.
78 ///
79 /// The snapshot is frozen, so a clean system reports clean no
80 /// matter what writers are doing.
81 ///
82 /// ```no_run
83 /// # use kevy_embedded::{Store, Config};
84 /// # let store = Store::open(Config::default()).unwrap();
85 /// let report = store.snapshot().reconcile(
86 /// b"user:",
87 /// &[b"email:", b"dept:"],
88 /// |key, _row| vec![[b"email:".as_slice(), key].concat()],
89 /// );
90 /// assert!(report.is_clean());
91 /// ```
92 pub fn reconcile(
93 &self,
94 rows_prefix: &[u8],
95 derived_prefixes: &[&[u8]],
96 mut derived: impl FnMut(&[u8], &kevy_store::Value) -> Vec<Vec<u8>>,
97 ) -> ReconcileReport {
98 let mut report = ReconcileReport::default();
99 let mut expected: HashSet<Vec<u8>> = HashSet::new();
100 self.each_prefix(rows_prefix, |k, v, _ttl| {
101 report.rows += 1;
102 expected.extend(derived(k, v));
103 });
104 report.expected = expected.len() as u64;
105
106 let mut present: HashSet<Vec<u8>> = HashSet::new();
107 for p in derived_prefixes {
108 self.each_prefix(p, |k, _v, _ttl| {
109 present.insert(k.to_vec());
110 });
111 }
112
113 collect(expected.difference(&present), &mut report.missing_count, &mut report.missing);
114 collect(present.difference(&expected), &mut report.orphaned_count, &mut report.orphaned);
115 report
116 }
117}
118
119/// Count everything, keep the first [`MAX_SAMPLES`].
120fn collect<'a>(
121 keys: impl Iterator<Item = &'a Vec<u8>>,
122 count: &mut u64,
123 samples: &mut Vec<Vec<u8>>,
124) {
125 for k in keys {
126 *count += 1;
127 if samples.len() < MAX_SAMPLES {
128 samples.push(k.clone());
129 }
130 }
131}