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, ExplainProjection,
7};
8use crate::db::index::fingerprint::hash_value;
9use crate::db::query::QueryMode;
10use crate::db::query::{
11    ReadConsistency,
12    predicate::{CompareOp, coercion::CoercionId},
13};
14use crate::key::Key;
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 super::LogicalPlan {
49    /// Compute a stable fingerprint for this logical plan.
50    #[must_use]
51    pub fn fingerprint(&self) -> PlanFingerprint {
52        self.explain().fingerprint()
53    }
54}
55
56impl ExplainPlan {
57    /// Compute a stable fingerprint for this explain plan.
58    #[must_use]
59    pub fn fingerprint(&self) -> PlanFingerprint {
60        let mut hasher = Sha256::new();
61        hasher.update(b"planfp:v2");
62        hash_explain_plan(&mut hasher, self);
63        let digest = hasher.finalize();
64        let mut out = [0u8; 32];
65        out.copy_from_slice(&digest);
66        PlanFingerprint(out)
67    }
68}
69
70fn hash_explain_plan(hasher: &mut Sha256, plan: &ExplainPlan) {
71    write_tag(hasher, 0x01);
72    hash_access(hasher, &plan.access);
73
74    write_tag(hasher, 0x02);
75    hash_predicate(hasher, &plan.predicate);
76
77    write_tag(hasher, 0x03);
78    hash_order(hasher, &plan.order_by);
79
80    write_tag(hasher, 0x04);
81    hash_page(hasher, &plan.page);
82
83    write_tag(hasher, 0x05);
84    hash_delete_limit(hasher, &plan.delete_limit);
85
86    write_tag(hasher, 0x06);
87    hash_projection(hasher, &plan.projection);
88
89    write_tag(hasher, 0x07);
90    hash_consistency(hasher, plan.consistency);
91
92    write_tag(hasher, 0x08);
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_key(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_key(hasher, key);
107            }
108        }
109        ExplainAccessPath::KeyRange { start, end } => {
110            write_tag(hasher, 0x12);
111            write_key(hasher, start);
112            write_key(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, compare_op_tag(*op));
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::MapContainsKey {
204            field,
205            key,
206            coercion,
207        } => {
208            write_tag(hasher, 0x2b);
209            write_str(hasher, field);
210            write_value(hasher, key);
211            hash_coercion(hasher, coercion.id, &coercion.params);
212        }
213        ExplainPredicate::MapContainsValue {
214            field,
215            value,
216            coercion,
217        } => {
218            write_tag(hasher, 0x2c);
219            write_str(hasher, field);
220            write_value(hasher, value);
221            hash_coercion(hasher, coercion.id, &coercion.params);
222        }
223        ExplainPredicate::MapContainsEntry {
224            field,
225            key,
226            value,
227            coercion,
228        } => {
229            write_tag(hasher, 0x2d);
230            write_str(hasher, field);
231            write_value(hasher, key);
232            write_value(hasher, value);
233            hash_coercion(hasher, coercion.id, &coercion.params);
234        }
235        ExplainPredicate::TextContains { field, value } => {
236            write_tag(hasher, 0x2e);
237            write_str(hasher, field);
238            write_value(hasher, value);
239        }
240        ExplainPredicate::TextContainsCi { field, value } => {
241            write_tag(hasher, 0x2f);
242            write_str(hasher, field);
243            write_value(hasher, value);
244        }
245    }
246}
247
248fn hash_order(hasher: &mut Sha256, order: &ExplainOrderBy) {
249    match order {
250        ExplainOrderBy::None => write_tag(hasher, 0x30),
251        ExplainOrderBy::Fields(fields) => {
252            write_tag(hasher, 0x31);
253            write_u32(hasher, fields.len() as u32);
254            for field in fields {
255                write_str(hasher, &field.field);
256                write_tag(hasher, order_direction_tag(field.direction));
257            }
258        }
259    }
260}
261
262fn hash_page(hasher: &mut Sha256, page: &ExplainPagination) {
263    match page {
264        ExplainPagination::None => write_tag(hasher, 0x40),
265        ExplainPagination::Page { limit, offset } => {
266            write_tag(hasher, 0x41);
267            match limit {
268                Some(limit) => {
269                    write_tag(hasher, 0x01);
270                    write_u32(hasher, *limit);
271                }
272                None => write_tag(hasher, 0x00),
273            }
274            write_u64(hasher, *offset);
275        }
276    }
277}
278
279fn hash_delete_limit(hasher: &mut Sha256, limit: &ExplainDeleteLimit) {
280    match limit {
281        ExplainDeleteLimit::None => write_tag(hasher, 0x42),
282        ExplainDeleteLimit::Limit { max_rows } => {
283            write_tag(hasher, 0x43);
284            write_u32(hasher, *max_rows);
285        }
286    }
287}
288
289fn hash_projection(hasher: &mut Sha256, projection: &ExplainProjection) {
290    match projection {
291        ExplainProjection::All => write_tag(hasher, 0x40),
292    }
293}
294
295fn hash_consistency(hasher: &mut Sha256, consistency: ReadConsistency) {
296    match consistency {
297        ReadConsistency::MissingOk => write_tag(hasher, 0x50),
298        ReadConsistency::Strict => write_tag(hasher, 0x51),
299    }
300}
301
302fn hash_mode(hasher: &mut Sha256, mode: QueryMode) {
303    match mode {
304        QueryMode::Load(_) => write_tag(hasher, 0x60),
305        QueryMode::Delete(_) => write_tag(hasher, 0x61),
306    }
307}
308
309fn hash_coercion(
310    hasher: &mut Sha256,
311    id: CoercionId,
312    params: &std::collections::BTreeMap<String, String>,
313) {
314    write_tag(hasher, coercion_id_tag(id));
315    write_u32(hasher, params.len() as u32);
316    for (key, value) in params {
317        write_str(hasher, key);
318        write_str(hasher, value);
319    }
320}
321
322fn write_key(hasher: &mut Sha256, key: &Key) {
323    match key.to_bytes() {
324        Ok(bytes) => hasher.update(bytes),
325        Err(err) => {
326            write_tag(hasher, 0xED);
327            write_str(hasher, &err.to_string());
328        }
329    }
330}
331
332fn write_value(hasher: &mut Sha256, value: &crate::value::Value) {
333    match hash_value(value) {
334        Ok(digest) => hasher.update(digest),
335        Err(err) => {
336            write_tag(hasher, 0xEE);
337            write_str(hasher, &err.display_with_class());
338        }
339    }
340}
341
342fn write_str(hasher: &mut Sha256, value: &str) {
343    write_u32(hasher, value.len() as u32);
344    hasher.update(value.as_bytes());
345}
346
347fn write_u32(hasher: &mut Sha256, value: u32) {
348    hasher.update(value.to_be_bytes());
349}
350
351fn write_u64(hasher: &mut Sha256, value: u64) {
352    hasher.update(value.to_be_bytes());
353}
354
355fn write_tag(hasher: &mut Sha256, tag: u8) {
356    hasher.update([tag]);
357}
358
359const fn compare_op_tag(op: CompareOp) -> u8 {
360    match op {
361        CompareOp::Eq => 0x01,
362        CompareOp::Ne => 0x02,
363        CompareOp::Lt => 0x03,
364        CompareOp::Lte => 0x04,
365        CompareOp::Gt => 0x05,
366        CompareOp::Gte => 0x06,
367        CompareOp::In => 0x07,
368        CompareOp::NotIn => 0x08,
369        CompareOp::AnyIn => 0x09,
370        CompareOp::AllIn => 0x0a,
371        CompareOp::Contains => 0x0b,
372        CompareOp::StartsWith => 0x0c,
373        CompareOp::EndsWith => 0x0d,
374    }
375}
376
377const fn order_direction_tag(direction: crate::db::query::plan::OrderDirection) -> u8 {
378    match direction {
379        crate::db::query::plan::OrderDirection::Asc => 0x01,
380        crate::db::query::plan::OrderDirection::Desc => 0x02,
381    }
382}
383
384const fn coercion_id_tag(id: CoercionId) -> u8 {
385    match id {
386        CoercionId::Strict => 0x01,
387        CoercionId::NumericWiden => 0x02,
388        CoercionId::IdentifierText => 0x03,
389        CoercionId::TextCasefold => 0x04,
390        CoercionId::CollectionElement => 0x05,
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use crate::db::query::plan::{AccessPath, DeleteLimitSpec, LogicalPlan};
397    use crate::db::query::{FieldRef, plan::planner::PlannerEntity};
398    use crate::db::query::{Query, QueryMode, ReadConsistency};
399    use crate::model::index::IndexModel;
400    use crate::types::Ulid;
401    use crate::value::Value;
402
403    #[test]
404    fn fingerprint_is_deterministic_for_equivalent_predicates() {
405        let id = Ulid::default();
406
407        let query_a = Query::<PlannerEntity>::new(ReadConsistency::MissingOk)
408            .filter(FieldRef::new("id").eq(id))
409            .filter(FieldRef::new("other").eq("x"));
410
411        let query_b = Query::<PlannerEntity>::new(ReadConsistency::MissingOk)
412            .filter(FieldRef::new("other").eq("x"))
413            .filter(FieldRef::new("id").eq(id));
414
415        let plan_a = query_a.plan().expect("plan a");
416        let plan_b = query_b.plan().expect("plan b");
417
418        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
419    }
420
421    #[test]
422    fn fingerprint_changes_with_index_choice() {
423        const INDEX_FIELDS: [&str; 1] = ["idx_a"];
424        const INDEX_A: IndexModel = IndexModel::new(
425            "fingerprint::idx_a",
426            "fingerprint::store",
427            &INDEX_FIELDS,
428            false,
429        );
430        const INDEX_B: IndexModel = IndexModel::new(
431            "fingerprint::idx_b",
432            "fingerprint::store",
433            &INDEX_FIELDS,
434            false,
435        );
436
437        let plan_a = LogicalPlan::new(
438            AccessPath::IndexPrefix {
439                index: INDEX_A,
440                values: vec![Value::Text("alpha".to_string())],
441            },
442            crate::db::query::ReadConsistency::MissingOk,
443        );
444        let plan_b = LogicalPlan::new(
445            AccessPath::IndexPrefix {
446                index: INDEX_B,
447                values: vec![Value::Text("alpha".to_string())],
448            },
449            crate::db::query::ReadConsistency::MissingOk,
450        );
451
452        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
453    }
454
455    #[test]
456    fn fingerprint_changes_with_pagination() {
457        let mut plan_a = LogicalPlan::new(
458            AccessPath::FullScan,
459            crate::db::query::ReadConsistency::MissingOk,
460        );
461        let mut plan_b = LogicalPlan::new(
462            AccessPath::FullScan,
463            crate::db::query::ReadConsistency::MissingOk,
464        );
465        plan_a.page = Some(crate::db::query::plan::PageSpec {
466            limit: Some(10),
467            offset: 0,
468        });
469        plan_b.page = Some(crate::db::query::plan::PageSpec {
470            limit: Some(10),
471            offset: 1,
472        });
473
474        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
475    }
476
477    #[test]
478    fn fingerprint_changes_with_delete_limit() {
479        let mut plan_a = LogicalPlan::new(
480            AccessPath::FullScan,
481            crate::db::query::ReadConsistency::MissingOk,
482        );
483        let mut plan_b = LogicalPlan::new(
484            AccessPath::FullScan,
485            crate::db::query::ReadConsistency::MissingOk,
486        );
487        plan_a.mode = QueryMode::Delete(crate::db::query::DeleteSpec::new());
488        plan_b.mode = QueryMode::Delete(crate::db::query::DeleteSpec::new());
489        plan_a.delete_limit = Some(DeleteLimitSpec { max_rows: 2 });
490        plan_b.delete_limit = Some(DeleteLimitSpec { max_rows: 3 });
491
492        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
493    }
494
495    #[test]
496    fn fingerprint_is_stable_for_full_scan() {
497        let plan = LogicalPlan::new(
498            AccessPath::FullScan,
499            crate::db::query::ReadConsistency::MissingOk,
500        );
501        let fingerprint_a = plan.fingerprint();
502        let fingerprint_b = plan.fingerprint();
503        assert_eq!(fingerprint_a, fingerprint_b);
504    }
505}