Skip to main content

rivet/state/
schema.rs

1use crate::error::Result;
2
3use super::StateStore;
4
5/// One column in a schema snapshot.
6#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
7pub struct SchemaColumn {
8    pub name: String,
9    #[serde(rename = "type")]
10    pub data_type: String,
11}
12
13/// Compute the canonical schema fingerprint for a set of columns.
14///
15/// The fingerprint is `"xxh3:<16-char-lowercase-hex>"`.  The algorithm prefix
16/// is part of the format so future hashers (sha256, blake3) can coexist —
17/// readers MUST verify the prefix before interpreting the hex body.
18///
19/// Canonicalization: columns are sorted by name (case-sensitive) and
20/// serialized as `<name>\t<data_type>\n` joined with no separator.  Column
21/// **order** in the source schema does not affect the fingerprint, but
22/// column **names** and **types** do; renaming or retyping a column changes
23/// the fingerprint.
24///
25/// This is the value written to the manifest's `schema_fingerprint` field
26/// (ADR-0012 M3) and is what `--validate` compares against to detect
27/// schema drift between the time of write and the time of verify.
28pub fn schema_fingerprint(columns: &[SchemaColumn]) -> String {
29    use xxhash_rust::xxh3::Xxh3;
30
31    let mut sorted: Vec<&SchemaColumn> = columns.iter().collect();
32    sorted.sort_by(|a, b| a.name.cmp(&b.name));
33
34    let mut h = Xxh3::new();
35    for c in &sorted {
36        h.update(c.name.as_bytes());
37        h.update(b"\t");
38        h.update(c.data_type.as_bytes());
39        h.update(b"\n");
40    }
41    format!("xxh3:{:016x}", h.digest())
42}
43
44/// Convert an Arrow schema (the dest-facing one, after internal columns are
45/// stripped — see `pipeline::sink`) into the `Vec<SchemaColumn>` the rest of
46/// the trust contract uses (`schema_fingerprint`, `store_schema`,
47/// `detect_schema_change`).
48///
49/// This is the canonical bridge between the Arrow type system and Rivet's
50/// schema-evidence representation.  It used to be inlined in three places
51/// (`pipeline/single.rs`, `pipeline/chunked/exec.rs`,
52/// `pipeline/chunked/parallel_checkpoint.rs`); each copy was a regression
53/// risk because changing the data-type representation in one would silently
54/// shift the fingerprint there but not in the others.  Centralising the
55/// conversion keeps the fingerprint stable across executors.
56///
57/// Format note: `data_type` is rendered with `{:?}` (Arrow's `Debug`), e.g.
58/// `Int64`, `Utf8`, `Timestamp(Microsecond, None)`.  This is what every
59/// existing manifest already records; switching to `Display` would shift
60/// every fingerprint in the world and is intentionally avoided.
61pub fn arrow_schema_to_columns(schema: &arrow::datatypes::Schema) -> Vec<SchemaColumn> {
62    schema
63        .fields()
64        .iter()
65        .map(|f| SchemaColumn {
66            name: f.name().clone(),
67            data_type: format!("{:?}", f.data_type()),
68        })
69        .collect()
70}
71
72/// Diff between two schema snapshots.
73#[derive(Debug)]
74pub struct SchemaChange {
75    pub added: Vec<String>,
76    pub removed: Vec<String>,
77    pub type_changed: Vec<(String, String, String)>, // (name, old_type, new_type)
78}
79
80impl SchemaChange {
81    pub fn is_empty(&self) -> bool {
82        self.added.is_empty() && self.removed.is_empty() && self.type_changed.is_empty()
83    }
84}
85
86/// Schema history store — reads and writes `export_schema`.
87///
88/// Captures a schema snapshot per export on each run and surfaces structural
89/// drift (added/removed/retyped columns) by diffing against the stored snapshot.
90impl StateStore {
91    pub fn get_stored_schema(&self, export_name: &str) -> Result<Option<Vec<SchemaColumn>>> {
92        let json = self.query_opt(
93            "SELECT columns_json FROM export_schema WHERE export_name = ?1",
94            &[export_name.into()],
95            |r| r.text(0),
96        )?;
97        Ok(match json {
98            Some(s) => Some(serde_json::from_str(&s)?),
99            None => None,
100        })
101    }
102
103    pub fn store_schema(&self, export_name: &str, columns: &[SchemaColumn]) -> Result<()> {
104        let json = serde_json::to_string(columns)?;
105        let now = chrono::Utc::now().to_rfc3339();
106        self.execute(
107            "INSERT INTO export_schema (export_name, columns_json, updated_at)
108             VALUES (?1, ?2, ?3)
109             ON CONFLICT(export_name) DO UPDATE SET
110                columns_json = excluded.columns_json,
111                updated_at = excluded.updated_at",
112            &[export_name.into(), json.into(), now.into()],
113        )?;
114        Ok(())
115    }
116
117    /// Store the schema ONLY when no baseline exists yet. Returns `true` if it
118    /// stored (was absent), `false` if an existing baseline was preserved.
119    ///
120    /// The open-time forensics probe uses this: a stored baseline is the drift
121    /// detector's comparison anchor ([`detect_schema_change`] reads it during the
122    /// export, AFTER open), so overwriting it at OPEN would blind drift detection —
123    /// a changed column would compare equal to the just-stored current schema. A
124    /// first-run failure (no baseline) still captures its schema for the post-mortem.
125    pub fn store_schema_if_absent(
126        &self,
127        export_name: &str,
128        columns: &[SchemaColumn],
129    ) -> Result<bool> {
130        if self.get_stored_schema(export_name)?.is_some() {
131            return Ok(false);
132        }
133        self.store_schema(export_name, columns)?;
134        Ok(true)
135    }
136
137    /// Detect structural drift versus the stored snapshot.
138    ///
139    /// On the first run (no stored snapshot) the current schema is stored and
140    /// `Ok(None)` is returned. On subsequent runs a diff is computed and returned
141    /// as `Ok(Some(change))` when columns differ — but the stored snapshot is
142    /// **not** updated automatically. Callers must call [`store_schema`] explicitly
143    /// after deciding whether to accept the change (policy `warn`/`continue`) or
144    /// reject it (policy `fail`, which intentionally leaves the old snapshot so the
145    /// next run detects the same change again).
146    pub fn detect_schema_change(
147        &self,
148        export_name: &str,
149        current: &[SchemaColumn],
150    ) -> Result<Option<SchemaChange>> {
151        let stored = match self.get_stored_schema(export_name)? {
152            Some(s) => s,
153            None => {
154                self.store_schema(export_name, current)?;
155                return Ok(None);
156            }
157        };
158
159        let stored_map: std::collections::HashMap<&str, &str> = stored
160            .iter()
161            .map(|c| (c.name.as_str(), c.data_type.as_str()))
162            .collect();
163        let current_map: std::collections::HashMap<&str, &str> = current
164            .iter()
165            .map(|c| (c.name.as_str(), c.data_type.as_str()))
166            .collect();
167
168        let added: Vec<String> = current
169            .iter()
170            .filter(|c| !stored_map.contains_key(c.name.as_str()))
171            .map(|c| format!("{} ({})", c.name, c.data_type))
172            .collect();
173
174        let removed: Vec<String> = stored
175            .iter()
176            .filter(|c| !current_map.contains_key(c.name.as_str()))
177            .map(|c| c.name.clone())
178            .collect();
179
180        let type_changed: Vec<(String, String, String)> = current
181            .iter()
182            .filter_map(|c| {
183                stored_map.get(c.name.as_str()).and_then(|old_type| {
184                    if *old_type != c.data_type.as_str() {
185                        Some((c.name.clone(), old_type.to_string(), c.data_type.clone()))
186                    } else {
187                        None
188                    }
189                })
190            })
191            .collect();
192
193        let change = SchemaChange {
194            added,
195            removed,
196            type_changed,
197        };
198
199        if change.is_empty() {
200            Ok(None)
201        } else {
202            Ok(Some(change))
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn store() -> StateStore {
212        StateStore::open_in_memory().expect("in-memory store")
213    }
214
215    #[test]
216    fn first_schema_stored_no_change() {
217        let s = store();
218        let cols = vec![
219            SchemaColumn {
220                name: "id".into(),
221                data_type: "Int64".into(),
222            },
223            SchemaColumn {
224                name: "name".into(),
225                data_type: "Utf8".into(),
226            },
227        ];
228        let change = s.detect_schema_change("orders", &cols).unwrap();
229        assert!(change.is_none(), "first run should detect no change");
230        assert!(s.get_stored_schema("orders").unwrap().is_some());
231    }
232
233    #[test]
234    fn same_schema_no_change() {
235        let s = store();
236        let cols = vec![SchemaColumn {
237            name: "id".into(),
238            data_type: "Int64".into(),
239        }];
240        s.detect_schema_change("t", &cols).unwrap();
241        let change = s.detect_schema_change("t", &cols).unwrap();
242        assert!(change.is_none());
243    }
244
245    #[test]
246    fn store_schema_if_absent_preserves_the_drift_baseline() {
247        // Regression (v18 open-forensics, job.rs::capture_open_forensics): the
248        // forensics probe stored the schema at run OPEN, clobbering the previous
249        // run's baseline BEFORE the export's `detect_schema_change` read it — so a
250        // genuinely added/removed column compared equal to itself and
251        // `schema_changed` stayed false (6 live schema-drift tests went RED). The
252        // fix routes the open-time store through `store_schema_if_absent`.
253        // MUTANT: swap `store_schema_if_absent` → `store_schema` (unconditional) and
254        // the final `change.is_some()` goes RED (baseline overwritten with v2).
255        let s = store();
256        let v1 = vec![SchemaColumn {
257            name: "id".into(),
258            data_type: "Int64".into(),
259        }];
260        let v2 = vec![
261            SchemaColumn {
262                name: "id".into(),
263                data_type: "Int64".into(),
264            },
265            SchemaColumn {
266                name: "added".into(),
267                data_type: "Utf8".into(),
268            },
269        ];
270        // Run 1 OPEN: no baseline → stores.
271        assert!(
272            s.store_schema_if_absent("t", &v1).unwrap(),
273            "first open: baseline absent → stored",
274        );
275        // Run 2 OPEN: baseline present → must NOT overwrite (the fix).
276        assert!(
277            !s.store_schema_if_absent("t", &v2).unwrap(),
278            "baseline present → open-forensics must preserve it, not overwrite",
279        );
280        // Run 2 export: drift detection sees v1 (baseline) vs v2 (current) → change.
281        let change = s.detect_schema_change("t", &v2).unwrap();
282        assert!(
283            change.is_some(),
284            "an added column must be detected — a store-at-open must not clobber the baseline",
285        );
286    }
287
288    #[test]
289    fn added_column_detected() {
290        let s = store();
291        let v1 = vec![SchemaColumn {
292            name: "id".into(),
293            data_type: "Int64".into(),
294        }];
295        s.detect_schema_change("t", &v1).unwrap();
296
297        let v2 = vec![
298            SchemaColumn {
299                name: "id".into(),
300                data_type: "Int64".into(),
301            },
302            SchemaColumn {
303                name: "email".into(),
304                data_type: "Utf8".into(),
305            },
306        ];
307        let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
308        assert_eq!(change.added.len(), 1);
309        assert!(change.added[0].contains("email"));
310    }
311
312    #[test]
313    fn removed_column_detected() {
314        let s = store();
315        let v1 = vec![
316            SchemaColumn {
317                name: "id".into(),
318                data_type: "Int64".into(),
319            },
320            SchemaColumn {
321                name: "old_field".into(),
322                data_type: "Utf8".into(),
323            },
324        ];
325        s.detect_schema_change("t", &v1).unwrap();
326
327        let v2 = vec![SchemaColumn {
328            name: "id".into(),
329            data_type: "Int64".into(),
330        }];
331        let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
332        assert_eq!(change.removed, vec!["old_field"]);
333    }
334
335    #[test]
336    fn type_change_detected() {
337        let s = store();
338        let v1 = vec![SchemaColumn {
339            name: "price".into(),
340            data_type: "Float64".into(),
341        }];
342        s.detect_schema_change("t", &v1).unwrap();
343
344        let v2 = vec![SchemaColumn {
345            name: "price".into(),
346            data_type: "Utf8".into(),
347        }];
348        let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
349        assert_eq!(change.type_changed.len(), 1);
350        assert_eq!(
351            change.type_changed[0],
352            ("price".into(), "Float64".into(), "Utf8".into())
353        );
354    }
355
356    #[test]
357    fn fail_policy_does_not_store_new_schema() {
358        let s = store();
359        let v1 = vec![SchemaColumn {
360            name: "id".into(),
361            data_type: "Int64".into(),
362        }];
363        s.detect_schema_change("t", &v1).unwrap();
364
365        let v2 = vec![
366            SchemaColumn {
367                name: "id".into(),
368                data_type: "Int64".into(),
369            },
370            SchemaColumn {
371                name: "new_col".into(),
372                data_type: "Utf8".into(),
373            },
374        ];
375        let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
376        assert_eq!(change.added.len(), 1);
377
378        let stored = s.get_stored_schema("t").unwrap().unwrap();
379        assert_eq!(stored.len(), 1);
380        assert_eq!(stored[0].name, "id");
381
382        let change2 = s.detect_schema_change("t", &v2).unwrap().unwrap();
383        assert_eq!(
384            change2.added.len(),
385            1,
386            "fail policy must re-detect on next run"
387        );
388    }
389
390    #[test]
391    fn warn_policy_stores_new_schema_after_change() {
392        let s = store();
393        let v1 = vec![SchemaColumn {
394            name: "id".into(),
395            data_type: "Int64".into(),
396        }];
397        s.detect_schema_change("t", &v1).unwrap();
398
399        let v2 = vec![
400            SchemaColumn {
401                name: "id".into(),
402                data_type: "Int64".into(),
403            },
404            SchemaColumn {
405                name: "extra".into(),
406                data_type: "Utf8".into(),
407            },
408        ];
409        let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
410        assert_eq!(change.added.len(), 1);
411
412        s.store_schema("t", &v2).unwrap();
413
414        let no_change = s.detect_schema_change("t", &v2).unwrap();
415        assert!(
416            no_change.is_none(),
417            "after store, same schema must produce no change"
418        );
419    }
420
421    // ── schema_fingerprint ───────────────────────────────────────────────────
422
423    fn col(name: &str, ty: &str) -> SchemaColumn {
424        SchemaColumn {
425            name: name.into(),
426            data_type: ty.into(),
427        }
428    }
429
430    #[test]
431    fn fingerprint_format_is_xxh3_prefix_plus_16_hex() {
432        let fp = schema_fingerprint(&[col("id", "Int64")]);
433        assert!(fp.starts_with("xxh3:"), "fp = {fp}");
434        let hex = &fp["xxh3:".len()..];
435        assert_eq!(hex.len(), 16, "fp = {fp}");
436        assert!(
437            hex.chars()
438                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
439            "fp = {fp}"
440        );
441    }
442
443    #[test]
444    fn fingerprint_is_order_independent() {
445        let a = vec![col("id", "Int64"), col("name", "Utf8")];
446        let b = vec![col("name", "Utf8"), col("id", "Int64")];
447        assert_eq!(schema_fingerprint(&a), schema_fingerprint(&b));
448    }
449
450    #[test]
451    fn fingerprint_changes_on_rename() {
452        let a = vec![col("id", "Int64")];
453        let b = vec![col("user_id", "Int64")];
454        assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
455    }
456
457    #[test]
458    fn fingerprint_changes_on_retype() {
459        let a = vec![col("price", "Int64")];
460        let b = vec![col("price", "Float64")];
461        assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
462    }
463
464    #[test]
465    fn fingerprint_changes_on_column_add_or_remove() {
466        let a = vec![col("id", "Int64")];
467        let b = vec![col("id", "Int64"), col("email", "Utf8")];
468        assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
469    }
470
471    #[test]
472    fn fingerprint_is_stable_across_invocations() {
473        // Guards against accidental non-determinism (HashMap iteration order,
474        // process-local randomness in a future xxh3 update, etc).  This is
475        // the value written to manifests — it MUST be reproducible.
476        let cols = vec![col("a", "Int64"), col("b", "Utf8"), col("c", "Float64")];
477        let fp1 = schema_fingerprint(&cols);
478        let fp2 = schema_fingerprint(&cols);
479        let fp3 = schema_fingerprint(&cols);
480        assert_eq!(fp1, fp2);
481        assert_eq!(fp2, fp3);
482    }
483
484    #[test]
485    fn fingerprint_distinguishes_split_columns() {
486        // Defends against a naive concat-without-separator implementation:
487        // "ab" + "c" must hash differently from "a" + "bc".
488        let a = vec![col("ab", "Int64"), col("c", "Utf8")];
489        let b = vec![col("a", "Int64"), col("bc", "Utf8")];
490        assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
491    }
492
493    #[test]
494    fn fingerprint_empty_input_is_well_defined() {
495        // Empty schema (no columns) must produce a deterministic value
496        // rather than panicking; the manifest writer may need to emit a
497        // placeholder fingerprint for degenerate plans.
498        let fp = schema_fingerprint(&[]);
499        assert!(fp.starts_with("xxh3:"));
500    }
501}