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