Skip to main content

rivet/state/
schema.rs

1use crate::error::Result;
2
3use super::{StateConn, StateStore, pg_sql};
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        match &self.conn {
93            StateConn::Sqlite(c) => {
94                let mut stmt =
95                    c.prepare("SELECT columns_json FROM export_schema WHERE export_name = ?1")?;
96                let result = stmt.query_row([export_name], |row| {
97                    let json_str: String = row.get(0)?;
98                    Ok(json_str)
99                });
100                match result {
101                    Ok(json_str) => {
102                        let cols: Vec<SchemaColumn> = serde_json::from_str(&json_str)?;
103                        Ok(Some(cols))
104                    }
105                    Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
106                    Err(e) => Err(e.into()),
107                }
108            }
109            StateConn::Postgres(client) => {
110                let mut c = client.borrow_mut();
111                match c.query_opt(
112                    "SELECT columns_json FROM export_schema WHERE export_name = $1",
113                    &[&export_name],
114                )? {
115                    Some(row) => {
116                        let json_str: String = row.get(0);
117                        let cols: Vec<SchemaColumn> = serde_json::from_str(&json_str)?;
118                        Ok(Some(cols))
119                    }
120                    None => Ok(None),
121                }
122            }
123        }
124    }
125
126    pub fn store_schema(&self, export_name: &str, columns: &[SchemaColumn]) -> Result<()> {
127        let json = serde_json::to_string(columns)?;
128        let now = chrono::Utc::now().to_rfc3339();
129        let sql = "INSERT INTO export_schema (export_name, columns_json, updated_at)
130             VALUES (?1, ?2, ?3)
131             ON CONFLICT(export_name) DO UPDATE SET
132                columns_json = excluded.columns_json,
133                updated_at = excluded.updated_at";
134        match &self.conn {
135            StateConn::Sqlite(c) => {
136                c.execute(sql, rusqlite::params![export_name, json, now])?;
137            }
138            StateConn::Postgres(client) => {
139                let mut c = client.borrow_mut();
140                c.execute(&pg_sql(sql), &[&export_name, &json, &now])?;
141            }
142        }
143        Ok(())
144    }
145
146    /// Store the schema ONLY when no baseline exists yet. Returns `true` if it
147    /// stored (was absent), `false` if an existing baseline was preserved.
148    ///
149    /// The open-time forensics probe uses this: a stored baseline is the drift
150    /// detector's comparison anchor ([`detect_schema_change`] reads it during the
151    /// export, AFTER open), so overwriting it at OPEN would blind drift detection —
152    /// a changed column would compare equal to the just-stored current schema. A
153    /// first-run failure (no baseline) still captures its schema for the post-mortem.
154    pub fn store_schema_if_absent(
155        &self,
156        export_name: &str,
157        columns: &[SchemaColumn],
158    ) -> Result<bool> {
159        if self.get_stored_schema(export_name)?.is_some() {
160            return Ok(false);
161        }
162        self.store_schema(export_name, columns)?;
163        Ok(true)
164    }
165
166    /// Detect structural drift versus the stored snapshot.
167    ///
168    /// On the first run (no stored snapshot) the current schema is stored and
169    /// `Ok(None)` is returned. On subsequent runs a diff is computed and returned
170    /// as `Ok(Some(change))` when columns differ — but the stored snapshot is
171    /// **not** updated automatically. Callers must call [`store_schema`] explicitly
172    /// after deciding whether to accept the change (policy `warn`/`continue`) or
173    /// reject it (policy `fail`, which intentionally leaves the old snapshot so the
174    /// next run detects the same change again).
175    pub fn detect_schema_change(
176        &self,
177        export_name: &str,
178        current: &[SchemaColumn],
179    ) -> Result<Option<SchemaChange>> {
180        let stored = match self.get_stored_schema(export_name)? {
181            Some(s) => s,
182            None => {
183                self.store_schema(export_name, current)?;
184                return Ok(None);
185            }
186        };
187
188        let stored_map: std::collections::HashMap<&str, &str> = stored
189            .iter()
190            .map(|c| (c.name.as_str(), c.data_type.as_str()))
191            .collect();
192        let current_map: std::collections::HashMap<&str, &str> = current
193            .iter()
194            .map(|c| (c.name.as_str(), c.data_type.as_str()))
195            .collect();
196
197        let added: Vec<String> = current
198            .iter()
199            .filter(|c| !stored_map.contains_key(c.name.as_str()))
200            .map(|c| format!("{} ({})", c.name, c.data_type))
201            .collect();
202
203        let removed: Vec<String> = stored
204            .iter()
205            .filter(|c| !current_map.contains_key(c.name.as_str()))
206            .map(|c| c.name.clone())
207            .collect();
208
209        let type_changed: Vec<(String, String, String)> = current
210            .iter()
211            .filter_map(|c| {
212                stored_map.get(c.name.as_str()).and_then(|old_type| {
213                    if *old_type != c.data_type.as_str() {
214                        Some((c.name.clone(), old_type.to_string(), c.data_type.clone()))
215                    } else {
216                        None
217                    }
218                })
219            })
220            .collect();
221
222        let change = SchemaChange {
223            added,
224            removed,
225            type_changed,
226        };
227
228        if change.is_empty() {
229            Ok(None)
230        } else {
231            Ok(Some(change))
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn store() -> StateStore {
241        StateStore::open_in_memory().expect("in-memory store")
242    }
243
244    #[test]
245    fn first_schema_stored_no_change() {
246        let s = store();
247        let cols = vec![
248            SchemaColumn {
249                name: "id".into(),
250                data_type: "Int64".into(),
251            },
252            SchemaColumn {
253                name: "name".into(),
254                data_type: "Utf8".into(),
255            },
256        ];
257        let change = s.detect_schema_change("orders", &cols).unwrap();
258        assert!(change.is_none(), "first run should detect no change");
259        assert!(s.get_stored_schema("orders").unwrap().is_some());
260    }
261
262    #[test]
263    fn same_schema_no_change() {
264        let s = store();
265        let cols = vec![SchemaColumn {
266            name: "id".into(),
267            data_type: "Int64".into(),
268        }];
269        s.detect_schema_change("t", &cols).unwrap();
270        let change = s.detect_schema_change("t", &cols).unwrap();
271        assert!(change.is_none());
272    }
273
274    #[test]
275    fn store_schema_if_absent_preserves_the_drift_baseline() {
276        // Regression (v18 open-forensics, job.rs::capture_open_forensics): the
277        // forensics probe stored the schema at run OPEN, clobbering the previous
278        // run's baseline BEFORE the export's `detect_schema_change` read it — so a
279        // genuinely added/removed column compared equal to itself and
280        // `schema_changed` stayed false (6 live schema-drift tests went RED). The
281        // fix routes the open-time store through `store_schema_if_absent`.
282        // MUTANT: swap `store_schema_if_absent` → `store_schema` (unconditional) and
283        // the final `change.is_some()` goes RED (baseline overwritten with v2).
284        let s = store();
285        let v1 = vec![SchemaColumn {
286            name: "id".into(),
287            data_type: "Int64".into(),
288        }];
289        let v2 = vec![
290            SchemaColumn {
291                name: "id".into(),
292                data_type: "Int64".into(),
293            },
294            SchemaColumn {
295                name: "added".into(),
296                data_type: "Utf8".into(),
297            },
298        ];
299        // Run 1 OPEN: no baseline → stores.
300        assert!(
301            s.store_schema_if_absent("t", &v1).unwrap(),
302            "first open: baseline absent → stored",
303        );
304        // Run 2 OPEN: baseline present → must NOT overwrite (the fix).
305        assert!(
306            !s.store_schema_if_absent("t", &v2).unwrap(),
307            "baseline present → open-forensics must preserve it, not overwrite",
308        );
309        // Run 2 export: drift detection sees v1 (baseline) vs v2 (current) → change.
310        let change = s.detect_schema_change("t", &v2).unwrap();
311        assert!(
312            change.is_some(),
313            "an added column must be detected — a store-at-open must not clobber the baseline",
314        );
315    }
316
317    #[test]
318    fn added_column_detected() {
319        let s = store();
320        let v1 = vec![SchemaColumn {
321            name: "id".into(),
322            data_type: "Int64".into(),
323        }];
324        s.detect_schema_change("t", &v1).unwrap();
325
326        let v2 = vec![
327            SchemaColumn {
328                name: "id".into(),
329                data_type: "Int64".into(),
330            },
331            SchemaColumn {
332                name: "email".into(),
333                data_type: "Utf8".into(),
334            },
335        ];
336        let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
337        assert_eq!(change.added.len(), 1);
338        assert!(change.added[0].contains("email"));
339    }
340
341    #[test]
342    fn removed_column_detected() {
343        let s = store();
344        let v1 = vec![
345            SchemaColumn {
346                name: "id".into(),
347                data_type: "Int64".into(),
348            },
349            SchemaColumn {
350                name: "old_field".into(),
351                data_type: "Utf8".into(),
352            },
353        ];
354        s.detect_schema_change("t", &v1).unwrap();
355
356        let v2 = vec![SchemaColumn {
357            name: "id".into(),
358            data_type: "Int64".into(),
359        }];
360        let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
361        assert_eq!(change.removed, vec!["old_field"]);
362    }
363
364    #[test]
365    fn type_change_detected() {
366        let s = store();
367        let v1 = vec![SchemaColumn {
368            name: "price".into(),
369            data_type: "Float64".into(),
370        }];
371        s.detect_schema_change("t", &v1).unwrap();
372
373        let v2 = vec![SchemaColumn {
374            name: "price".into(),
375            data_type: "Utf8".into(),
376        }];
377        let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
378        assert_eq!(change.type_changed.len(), 1);
379        assert_eq!(
380            change.type_changed[0],
381            ("price".into(), "Float64".into(), "Utf8".into())
382        );
383    }
384
385    #[test]
386    fn fail_policy_does_not_store_new_schema() {
387        let s = store();
388        let v1 = vec![SchemaColumn {
389            name: "id".into(),
390            data_type: "Int64".into(),
391        }];
392        s.detect_schema_change("t", &v1).unwrap();
393
394        let v2 = vec![
395            SchemaColumn {
396                name: "id".into(),
397                data_type: "Int64".into(),
398            },
399            SchemaColumn {
400                name: "new_col".into(),
401                data_type: "Utf8".into(),
402            },
403        ];
404        let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
405        assert_eq!(change.added.len(), 1);
406
407        let stored = s.get_stored_schema("t").unwrap().unwrap();
408        assert_eq!(stored.len(), 1);
409        assert_eq!(stored[0].name, "id");
410
411        let change2 = s.detect_schema_change("t", &v2).unwrap().unwrap();
412        assert_eq!(
413            change2.added.len(),
414            1,
415            "fail policy must re-detect on next run"
416        );
417    }
418
419    #[test]
420    fn warn_policy_stores_new_schema_after_change() {
421        let s = store();
422        let v1 = vec![SchemaColumn {
423            name: "id".into(),
424            data_type: "Int64".into(),
425        }];
426        s.detect_schema_change("t", &v1).unwrap();
427
428        let v2 = vec![
429            SchemaColumn {
430                name: "id".into(),
431                data_type: "Int64".into(),
432            },
433            SchemaColumn {
434                name: "extra".into(),
435                data_type: "Utf8".into(),
436            },
437        ];
438        let change = s.detect_schema_change("t", &v2).unwrap().unwrap();
439        assert_eq!(change.added.len(), 1);
440
441        s.store_schema("t", &v2).unwrap();
442
443        let no_change = s.detect_schema_change("t", &v2).unwrap();
444        assert!(
445            no_change.is_none(),
446            "after store, same schema must produce no change"
447        );
448    }
449
450    // ── schema_fingerprint ───────────────────────────────────────────────────
451
452    fn col(name: &str, ty: &str) -> SchemaColumn {
453        SchemaColumn {
454            name: name.into(),
455            data_type: ty.into(),
456        }
457    }
458
459    #[test]
460    fn fingerprint_format_is_xxh3_prefix_plus_16_hex() {
461        let fp = schema_fingerprint(&[col("id", "Int64")]);
462        assert!(fp.starts_with("xxh3:"), "fp = {fp}");
463        let hex = &fp["xxh3:".len()..];
464        assert_eq!(hex.len(), 16, "fp = {fp}");
465        assert!(
466            hex.chars()
467                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
468            "fp = {fp}"
469        );
470    }
471
472    #[test]
473    fn fingerprint_is_order_independent() {
474        let a = vec![col("id", "Int64"), col("name", "Utf8")];
475        let b = vec![col("name", "Utf8"), col("id", "Int64")];
476        assert_eq!(schema_fingerprint(&a), schema_fingerprint(&b));
477    }
478
479    #[test]
480    fn fingerprint_changes_on_rename() {
481        let a = vec![col("id", "Int64")];
482        let b = vec![col("user_id", "Int64")];
483        assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
484    }
485
486    #[test]
487    fn fingerprint_changes_on_retype() {
488        let a = vec![col("price", "Int64")];
489        let b = vec![col("price", "Float64")];
490        assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
491    }
492
493    #[test]
494    fn fingerprint_changes_on_column_add_or_remove() {
495        let a = vec![col("id", "Int64")];
496        let b = vec![col("id", "Int64"), col("email", "Utf8")];
497        assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
498    }
499
500    #[test]
501    fn fingerprint_is_stable_across_invocations() {
502        // Guards against accidental non-determinism (HashMap iteration order,
503        // process-local randomness in a future xxh3 update, etc).  This is
504        // the value written to manifests — it MUST be reproducible.
505        let cols = vec![col("a", "Int64"), col("b", "Utf8"), col("c", "Float64")];
506        let fp1 = schema_fingerprint(&cols);
507        let fp2 = schema_fingerprint(&cols);
508        let fp3 = schema_fingerprint(&cols);
509        assert_eq!(fp1, fp2);
510        assert_eq!(fp2, fp3);
511    }
512
513    #[test]
514    fn fingerprint_distinguishes_split_columns() {
515        // Defends against a naive concat-without-separator implementation:
516        // "ab" + "c" must hash differently from "a" + "bc".
517        let a = vec![col("ab", "Int64"), col("c", "Utf8")];
518        let b = vec![col("a", "Int64"), col("bc", "Utf8")];
519        assert_ne!(schema_fingerprint(&a), schema_fingerprint(&b));
520    }
521
522    #[test]
523    fn fingerprint_empty_input_is_well_defined() {
524        // Empty schema (no columns) must produce a deterministic value
525        // rather than panicking; the manifest writer may need to emit a
526        // placeholder fingerprint for degenerate plans.
527        let fp = schema_fingerprint(&[]);
528        assert!(fp.starts_with("xxh3:"));
529    }
530}