Skip to main content

icydb_core/db/query/plan/
fingerprint.rs

1//! Deterministic plan fingerprinting derived from the explain projection.
2#![allow(clippy::cast_possible_truncation)]
3
4use super::{
5    ExplainAccessPath, ExplainDeleteLimit, ExplainOrderBy, ExplainPagination, ExplainPlan,
6    ExplainPredicate,
7};
8use crate::{
9    db::{
10        index::fingerprint::hash_value,
11        query::{QueryMode, ReadConsistency, predicate::coercion::CoercionId},
12    },
13    traits::FieldValue,
14};
15use sha2::{Digest, Sha256};
16
17///
18/// PlanFingerprint
19///
20/// Stable, deterministic fingerprint for logical plans.
21///
22
23#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
24pub struct PlanFingerprint([u8; 32]);
25
26impl PlanFingerprint {
27    pub(crate) const fn from_bytes(bytes: [u8; 32]) -> Self {
28        Self(bytes)
29    }
30
31    #[must_use]
32    pub fn as_hex(&self) -> String {
33        let mut out = String::with_capacity(64);
34        for byte in self.0 {
35            use std::fmt::Write as _;
36            let _ = write!(out, "{byte:02x}");
37        }
38        out
39    }
40}
41
42impl std::fmt::Display for PlanFingerprint {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.write_str(&self.as_hex())
45    }
46}
47
48impl<K> super::LogicalPlan<K>
49where
50    K: FieldValue,
51{
52    /// Compute a stable fingerprint for this logical plan.
53    #[must_use]
54    pub fn fingerprint(&self) -> PlanFingerprint {
55        self.explain().fingerprint()
56    }
57}
58
59impl ExplainPlan {
60    /// Compute a stable fingerprint for this explain plan.
61    #[must_use]
62    pub fn fingerprint(&self) -> PlanFingerprint {
63        let mut hasher = Sha256::new();
64        hasher.update(b"planfp:v2");
65        hash_explain_plan(&mut hasher, self);
66        let digest = hasher.finalize();
67        let mut out = [0u8; 32];
68        out.copy_from_slice(&digest);
69        PlanFingerprint(out)
70    }
71}
72
73fn hash_explain_plan(hasher: &mut Sha256, plan: &ExplainPlan) {
74    write_tag(hasher, 0x01);
75    hash_access(hasher, &plan.access);
76
77    write_tag(hasher, 0x02);
78    hash_predicate(hasher, &plan.predicate);
79
80    write_tag(hasher, 0x03);
81    hash_order(hasher, &plan.order_by);
82
83    write_tag(hasher, 0x04);
84    hash_page(hasher, &plan.page);
85
86    write_tag(hasher, 0x05);
87    hash_delete_limit(hasher, &plan.delete_limit);
88
89    write_tag(hasher, 0x06);
90    hash_consistency(hasher, plan.consistency);
91
92    write_tag(hasher, 0x07);
93    hash_mode(hasher, plan.mode);
94}
95
96fn hash_access(hasher: &mut Sha256, access: &ExplainAccessPath) {
97    match access {
98        ExplainAccessPath::ByKey { key } => {
99            write_tag(hasher, 0x10);
100            write_value(hasher, key);
101        }
102        ExplainAccessPath::ByKeys { keys } => {
103            write_tag(hasher, 0x11);
104            write_u32(hasher, keys.len() as u32);
105            for key in keys {
106                write_value(hasher, key);
107            }
108        }
109        ExplainAccessPath::KeyRange { start, end } => {
110            write_tag(hasher, 0x12);
111            write_value(hasher, start);
112            write_value(hasher, end);
113        }
114        ExplainAccessPath::IndexPrefix {
115            name,
116            fields,
117            prefix_len,
118            values,
119        } => {
120            write_tag(hasher, 0x13);
121            write_str(hasher, name);
122            write_u32(hasher, fields.len() as u32);
123            for field in fields {
124                write_str(hasher, field);
125            }
126            write_u32(hasher, *prefix_len as u32);
127            write_u32(hasher, values.len() as u32);
128            for value in values {
129                write_value(hasher, value);
130            }
131        }
132        ExplainAccessPath::FullScan => {
133            write_tag(hasher, 0x14);
134        }
135        ExplainAccessPath::Union(children) => {
136            write_tag(hasher, 0x15);
137            write_u32(hasher, children.len() as u32);
138            for child in children {
139                hash_access(hasher, child);
140            }
141        }
142        ExplainAccessPath::Intersection(children) => {
143            write_tag(hasher, 0x16);
144            write_u32(hasher, children.len() as u32);
145            for child in children {
146                hash_access(hasher, child);
147            }
148        }
149    }
150}
151
152fn hash_predicate(hasher: &mut Sha256, predicate: &ExplainPredicate) {
153    match predicate {
154        ExplainPredicate::None => write_tag(hasher, 0x20),
155        ExplainPredicate::True => write_tag(hasher, 0x21),
156        ExplainPredicate::False => write_tag(hasher, 0x22),
157        ExplainPredicate::And(children) => {
158            write_tag(hasher, 0x23);
159            write_u32(hasher, children.len() as u32);
160            for child in children {
161                hash_predicate(hasher, child);
162            }
163        }
164        ExplainPredicate::Or(children) => {
165            write_tag(hasher, 0x24);
166            write_u32(hasher, children.len() as u32);
167            for child in children {
168                hash_predicate(hasher, child);
169            }
170        }
171        ExplainPredicate::Not(inner) => {
172            write_tag(hasher, 0x25);
173            hash_predicate(hasher, inner);
174        }
175        ExplainPredicate::Compare {
176            field,
177            op,
178            value,
179            coercion,
180        } => {
181            write_tag(hasher, 0x26);
182            write_str(hasher, field);
183            write_tag(hasher, op.tag());
184            write_value(hasher, value);
185            hash_coercion(hasher, coercion.id, &coercion.params);
186        }
187        ExplainPredicate::IsNull { field } => {
188            write_tag(hasher, 0x27);
189            write_str(hasher, field);
190        }
191        ExplainPredicate::IsMissing { field } => {
192            write_tag(hasher, 0x28);
193            write_str(hasher, field);
194        }
195        ExplainPredicate::IsEmpty { field } => {
196            write_tag(hasher, 0x29);
197            write_str(hasher, field);
198        }
199        ExplainPredicate::IsNotEmpty { field } => {
200            write_tag(hasher, 0x2a);
201            write_str(hasher, field);
202        }
203        ExplainPredicate::TextContains { field, value } => {
204            write_tag(hasher, 0x2e);
205            write_str(hasher, field);
206            write_value(hasher, value);
207        }
208        ExplainPredicate::TextContainsCi { field, value } => {
209            write_tag(hasher, 0x2f);
210            write_str(hasher, field);
211            write_value(hasher, value);
212        }
213    }
214}
215
216fn hash_order(hasher: &mut Sha256, order: &ExplainOrderBy) {
217    match order {
218        ExplainOrderBy::None => write_tag(hasher, 0x30),
219        ExplainOrderBy::Fields(fields) => {
220            write_tag(hasher, 0x31);
221            write_u32(hasher, fields.len() as u32);
222            for field in fields {
223                write_str(hasher, &field.field);
224                write_tag(hasher, order_direction_tag(field.direction));
225            }
226        }
227    }
228}
229
230fn hash_page(hasher: &mut Sha256, page: &ExplainPagination) {
231    match page {
232        ExplainPagination::None => write_tag(hasher, 0x40),
233        ExplainPagination::Page { limit, offset } => {
234            write_tag(hasher, 0x41);
235            match limit {
236                Some(limit) => {
237                    write_tag(hasher, 0x01);
238                    write_u32(hasher, *limit);
239                }
240                None => write_tag(hasher, 0x00),
241            }
242            write_u32(hasher, *offset);
243        }
244    }
245}
246
247fn hash_delete_limit(hasher: &mut Sha256, limit: &ExplainDeleteLimit) {
248    match limit {
249        ExplainDeleteLimit::None => write_tag(hasher, 0x42),
250        ExplainDeleteLimit::Limit { max_rows } => {
251            write_tag(hasher, 0x43);
252            write_u32(hasher, *max_rows);
253        }
254    }
255}
256
257fn hash_consistency(hasher: &mut Sha256, consistency: ReadConsistency) {
258    match consistency {
259        ReadConsistency::MissingOk => write_tag(hasher, 0x50),
260        ReadConsistency::Strict => write_tag(hasher, 0x51),
261    }
262}
263
264fn hash_mode(hasher: &mut Sha256, mode: QueryMode) {
265    match mode {
266        QueryMode::Load(_) => write_tag(hasher, 0x60),
267        QueryMode::Delete(_) => write_tag(hasher, 0x61),
268    }
269}
270
271fn hash_coercion(
272    hasher: &mut Sha256,
273    id: CoercionId,
274    params: &std::collections::BTreeMap<String, String>,
275) {
276    write_tag(hasher, coercion_id_tag(id));
277    write_u32(hasher, params.len() as u32);
278    for (key, value) in params {
279        write_str(hasher, key);
280        write_str(hasher, value);
281    }
282}
283
284fn write_value(hasher: &mut Sha256, value: &crate::value::Value) {
285    match hash_value(value) {
286        Ok(digest) => hasher.update(digest),
287        Err(err) => {
288            write_tag(hasher, 0xEE);
289            write_str(hasher, &err.display_with_class());
290        }
291    }
292}
293
294fn write_str(hasher: &mut Sha256, value: &str) {
295    write_u32(hasher, value.len() as u32);
296    hasher.update(value.as_bytes());
297}
298
299fn write_u32(hasher: &mut Sha256, value: u32) {
300    hasher.update(value.to_be_bytes());
301}
302
303fn write_tag(hasher: &mut Sha256, tag: u8) {
304    hasher.update([tag]);
305}
306
307const fn order_direction_tag(direction: crate::db::query::plan::OrderDirection) -> u8 {
308    match direction {
309        crate::db::query::plan::OrderDirection::Asc => 0x01,
310        crate::db::query::plan::OrderDirection::Desc => 0x02,
311    }
312}
313
314const fn coercion_id_tag(id: CoercionId) -> u8 {
315    match id {
316        CoercionId::Strict => 0x01,
317        CoercionId::NumericWiden => 0x02,
318        CoercionId::TextCasefold => 0x04,
319        CoercionId::CollectionElement => 0x05,
320    }
321}
322
323///
324/// TESTS
325///
326
327#[cfg(test)]
328mod tests {
329    use crate::db::query::intent::{KeyAccess, access_plan_from_keys_value};
330    use crate::db::query::plan::{AccessPath, DeleteLimitSpec, LogicalPlan};
331    use crate::db::query::predicate::Predicate;
332    use crate::db::query::{FieldRef, QueryMode, ReadConsistency};
333    use crate::model::index::IndexModel;
334    use crate::types::Ulid;
335    use crate::value::Value;
336
337    #[test]
338    fn fingerprint_is_deterministic_for_equivalent_predicates() {
339        let id = Ulid::default();
340
341        let predicate_a = Predicate::And(vec![
342            FieldRef::new("id").eq(id),
343            FieldRef::new("other").eq(Value::Text("x".to_string())),
344        ]);
345        let predicate_b = Predicate::And(vec![
346            FieldRef::new("other").eq(Value::Text("x".to_string())),
347            FieldRef::new("id").eq(id),
348        ]);
349
350        let mut plan_a: LogicalPlan<Value> =
351            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
352        plan_a.predicate = Some(predicate_a);
353
354        let mut plan_b: LogicalPlan<Value> =
355            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
356        plan_b.predicate = Some(predicate_b);
357
358        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
359    }
360
361    #[test]
362    fn fingerprint_is_deterministic_for_by_keys() {
363        let a = Ulid::from_u128(1);
364        let b = Ulid::from_u128(2);
365
366        let access_a = access_plan_from_keys_value(&KeyAccess::Many(vec![a, b, a]));
367        let access_b = access_plan_from_keys_value(&KeyAccess::Many(vec![b, a]));
368
369        let plan_a: LogicalPlan<Value> = LogicalPlan {
370            mode: QueryMode::Load(crate::db::query::LoadSpec::new()),
371            access: access_a,
372            predicate: None,
373            order: None,
374            delete_limit: None,
375            page: None,
376            consistency: ReadConsistency::MissingOk,
377        };
378        let plan_b: LogicalPlan<Value> = LogicalPlan {
379            mode: QueryMode::Load(crate::db::query::LoadSpec::new()),
380            access: access_b,
381            predicate: None,
382            order: None,
383            delete_limit: None,
384            page: None,
385            consistency: ReadConsistency::MissingOk,
386        };
387
388        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
389    }
390
391    #[test]
392    fn fingerprint_changes_with_index_choice() {
393        const INDEX_FIELDS: [&str; 1] = ["idx_a"];
394        const INDEX_A: IndexModel = IndexModel::new(
395            "fingerprint::idx_a",
396            "fingerprint::store",
397            &INDEX_FIELDS,
398            false,
399        );
400        const INDEX_B: IndexModel = IndexModel::new(
401            "fingerprint::idx_b",
402            "fingerprint::store",
403            &INDEX_FIELDS,
404            false,
405        );
406
407        let plan_a: LogicalPlan<Value> = LogicalPlan::new(
408            AccessPath::IndexPrefix {
409                index: INDEX_A,
410                values: vec![Value::Text("alpha".to_string())],
411            },
412            crate::db::query::ReadConsistency::MissingOk,
413        );
414        let plan_b: LogicalPlan<Value> = LogicalPlan::new(
415            AccessPath::IndexPrefix {
416                index: INDEX_B,
417                values: vec![Value::Text("alpha".to_string())],
418            },
419            crate::db::query::ReadConsistency::MissingOk,
420        );
421
422        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
423    }
424
425    #[test]
426    fn fingerprint_changes_with_pagination() {
427        let mut plan_a: LogicalPlan<Value> =
428            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
429        let mut plan_b: LogicalPlan<Value> =
430            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
431        plan_a.page = Some(crate::db::query::plan::PageSpec {
432            limit: Some(10),
433            offset: 0,
434        });
435        plan_b.page = Some(crate::db::query::plan::PageSpec {
436            limit: Some(10),
437            offset: 1,
438        });
439
440        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
441    }
442
443    #[test]
444    fn fingerprint_changes_with_delete_limit() {
445        let mut plan_a: LogicalPlan<Value> =
446            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
447        let mut plan_b: LogicalPlan<Value> =
448            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
449        plan_a.mode = QueryMode::Delete(crate::db::query::DeleteSpec::new());
450        plan_b.mode = QueryMode::Delete(crate::db::query::DeleteSpec::new());
451        plan_a.delete_limit = Some(DeleteLimitSpec { max_rows: 2 });
452        plan_b.delete_limit = Some(DeleteLimitSpec { max_rows: 3 });
453
454        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
455    }
456
457    #[test]
458    fn fingerprint_is_stable_for_full_scan() {
459        let plan: LogicalPlan<Value> =
460            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
461        let fingerprint_a = plan.fingerprint();
462        let fingerprint_b = plan.fingerprint();
463        assert_eq!(fingerprint_a, fingerprint_b);
464    }
465}