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::ExplainPlan;
5use crate::{db::query::plan::hash_parts, traits::FieldValue};
6use sha2::{Digest, Sha256};
7
8///
9/// PlanFingerprint
10///
11/// Stable, deterministic fingerprint for logical plans.
12///
13
14#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub struct PlanFingerprint([u8; 32]);
16
17impl PlanFingerprint {
18    pub(crate) const fn from_bytes(bytes: [u8; 32]) -> Self {
19        Self(bytes)
20    }
21
22    #[must_use]
23    pub fn as_hex(&self) -> String {
24        let mut out = String::with_capacity(64);
25        for byte in self.0 {
26            use std::fmt::Write as _;
27            let _ = write!(out, "{byte:02x}");
28        }
29        out
30    }
31}
32
33impl std::fmt::Display for PlanFingerprint {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        f.write_str(&self.as_hex())
36    }
37}
38
39impl<K> super::LogicalPlan<K>
40where
41    K: FieldValue,
42{
43    /// Compute a stable fingerprint for this logical plan.
44    #[must_use]
45    pub fn fingerprint(&self) -> PlanFingerprint {
46        self.explain().fingerprint()
47    }
48}
49
50impl ExplainPlan {
51    /// Compute a stable fingerprint for this explain plan.
52    #[must_use]
53    pub fn fingerprint(&self) -> PlanFingerprint {
54        let mut hasher = Sha256::new();
55        hasher.update(b"planfp:v2");
56        hash_parts::hash_explain_plan_profile(
57            &mut hasher,
58            self,
59            hash_parts::ExplainHashProfile::FingerprintV2,
60        );
61        let digest = hasher.finalize();
62        let mut out = [0u8; 32];
63        out.copy_from_slice(&digest);
64        PlanFingerprint(out)
65    }
66}
67
68///
69/// TESTS
70///
71
72#[cfg(test)]
73mod tests {
74    use crate::db::query::intent::{KeyAccess, access_plan_from_keys_value};
75    use crate::db::query::plan::{AccessPath, DeleteLimitSpec, LogicalPlan};
76    use crate::db::query::predicate::Predicate;
77    use crate::db::query::{FieldRef, QueryMode, ReadConsistency};
78    use crate::model::index::IndexModel;
79    use crate::types::Ulid;
80    use crate::value::Value;
81
82    #[test]
83    fn fingerprint_is_deterministic_for_equivalent_predicates() {
84        let id = Ulid::default();
85
86        let predicate_a = Predicate::And(vec![
87            FieldRef::new("id").eq(id),
88            FieldRef::new("other").eq(Value::Text("x".to_string())),
89        ]);
90        let predicate_b = Predicate::And(vec![
91            FieldRef::new("other").eq(Value::Text("x".to_string())),
92            FieldRef::new("id").eq(id),
93        ]);
94
95        let mut plan_a: LogicalPlan<Value> =
96            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
97        plan_a.predicate = Some(predicate_a);
98
99        let mut plan_b: LogicalPlan<Value> =
100            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
101        plan_b.predicate = Some(predicate_b);
102
103        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
104    }
105
106    #[test]
107    fn fingerprint_is_deterministic_for_by_keys() {
108        let a = Ulid::from_u128(1);
109        let b = Ulid::from_u128(2);
110
111        let access_a = access_plan_from_keys_value(&KeyAccess::Many(vec![a, b, a]));
112        let access_b = access_plan_from_keys_value(&KeyAccess::Many(vec![b, a]));
113
114        let plan_a: LogicalPlan<Value> = LogicalPlan {
115            mode: QueryMode::Load(crate::db::query::LoadSpec::new()),
116            access: access_a,
117            predicate: None,
118            order: None,
119            delete_limit: None,
120            page: None,
121            consistency: ReadConsistency::MissingOk,
122        };
123        let plan_b: LogicalPlan<Value> = LogicalPlan {
124            mode: QueryMode::Load(crate::db::query::LoadSpec::new()),
125            access: access_b,
126            predicate: None,
127            order: None,
128            delete_limit: None,
129            page: None,
130            consistency: ReadConsistency::MissingOk,
131        };
132
133        assert_eq!(plan_a.fingerprint(), plan_b.fingerprint());
134    }
135
136    #[test]
137    fn fingerprint_changes_with_index_choice() {
138        const INDEX_FIELDS: [&str; 1] = ["idx_a"];
139        const INDEX_A: IndexModel = IndexModel::new(
140            "fingerprint::idx_a",
141            "fingerprint::store",
142            &INDEX_FIELDS,
143            false,
144        );
145        const INDEX_B: IndexModel = IndexModel::new(
146            "fingerprint::idx_b",
147            "fingerprint::store",
148            &INDEX_FIELDS,
149            false,
150        );
151
152        let plan_a: LogicalPlan<Value> = LogicalPlan::new(
153            AccessPath::IndexPrefix {
154                index: INDEX_A,
155                values: vec![Value::Text("alpha".to_string())],
156            },
157            crate::db::query::ReadConsistency::MissingOk,
158        );
159        let plan_b: LogicalPlan<Value> = LogicalPlan::new(
160            AccessPath::IndexPrefix {
161                index: INDEX_B,
162                values: vec![Value::Text("alpha".to_string())],
163            },
164            crate::db::query::ReadConsistency::MissingOk,
165        );
166
167        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
168    }
169
170    #[test]
171    fn fingerprint_changes_with_pagination() {
172        let mut plan_a: LogicalPlan<Value> =
173            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
174        let mut plan_b: LogicalPlan<Value> =
175            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
176        plan_a.page = Some(crate::db::query::plan::PageSpec {
177            limit: Some(10),
178            offset: 0,
179        });
180        plan_b.page = Some(crate::db::query::plan::PageSpec {
181            limit: Some(10),
182            offset: 1,
183        });
184
185        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
186    }
187
188    #[test]
189    fn fingerprint_changes_with_delete_limit() {
190        let mut plan_a: LogicalPlan<Value> =
191            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
192        let mut plan_b: LogicalPlan<Value> =
193            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
194        plan_a.mode = QueryMode::Delete(crate::db::query::DeleteSpec::new());
195        plan_b.mode = QueryMode::Delete(crate::db::query::DeleteSpec::new());
196        plan_a.delete_limit = Some(DeleteLimitSpec { max_rows: 2 });
197        plan_b.delete_limit = Some(DeleteLimitSpec { max_rows: 3 });
198
199        assert_ne!(plan_a.fingerprint(), plan_b.fingerprint());
200    }
201
202    #[test]
203    fn fingerprint_is_stable_for_full_scan() {
204        let plan: LogicalPlan<Value> =
205            LogicalPlan::new(AccessPath::<Value>::FullScan, ReadConsistency::MissingOk);
206        let fingerprint_a = plan.fingerprint();
207        let fingerprint_b = plan.fingerprint();
208        assert_eq!(fingerprint_a, fingerprint_b);
209    }
210}