Skip to main content

rivet/state/
shape.rs

1//! Data shape drift tracking (Epic 8).
2//!
3//! Tracks the maximum observed byte length per string/binary column across runs.
4//! On each run the current-run maxima are compared against the stored maxima;
5//! columns that grew beyond `warn_factor × stored_max` are returned as warnings.
6//! Stored maxima are always updated to `max(stored, current)` — shape drift
7//! tracking is advisory and never blocks a run.
8
9use std::collections::HashMap;
10
11use crate::error::Result;
12
13use super::StateStore;
14
15/// One column whose observed max byte length grew beyond the configured threshold.
16pub struct ShapeWarning {
17    pub column: String,
18    pub stored_max_bytes: u64,
19    pub current_max_bytes: u64,
20    /// `current_max_bytes / stored_max_bytes` — always > `warn_factor`.
21    pub growth_factor: f64,
22}
23
24impl StateStore {
25    /// Return the stored per-column max byte lengths for `export_name`.
26    pub fn get_shape_stats(&self, export_name: &str) -> Result<HashMap<String, u64>> {
27        Ok(self
28            .query(
29                "SELECT column_name, max_byte_len FROM export_shape WHERE export_name = ?1",
30                &[export_name.into()],
31                |r| (r.text(0), r.i64(1) as u64),
32            )?
33            .into_iter()
34            .collect())
35    }
36
37    /// Upsert per-column max byte lengths, keeping the running maximum.
38    pub fn store_shape_stats(&self, export_name: &str, stats: &HashMap<String, u64>) -> Result<()> {
39        let now = chrono::Utc::now().to_rfc3339();
40        let sql = "INSERT INTO export_shape (export_name, column_name, max_byte_len, updated_at)
41                 VALUES (?1, ?2, ?3, ?4)
42                 ON CONFLICT(export_name, column_name) DO UPDATE SET
43                     max_byte_len = MAX(max_byte_len, excluded.max_byte_len),
44                     updated_at   = excluded.updated_at";
45        for (col, &max_bytes) in stats {
46            self.execute(
47                sql,
48                &[
49                    export_name.into(),
50                    col.as_str().into(),
51                    (max_bytes as i64).into(),
52                    now.as_str().into(),
53                ],
54            )?;
55        }
56        Ok(())
57    }
58
59    /// Compare `current` run's per-column maxima against stored history.
60    ///
61    /// Returns a warning for every column whose `current_max > stored_max * warn_factor`.
62    /// The stored maxima are updated to `max(stored, current)` unconditionally so that
63    /// the running high-water mark is always current.
64    ///
65    /// First-run columns (no stored record) are silently accepted.
66    pub fn detect_shape_drift(
67        &self,
68        export_name: &str,
69        current: &HashMap<String, u64>,
70        warn_factor: f64,
71    ) -> Result<Vec<ShapeWarning>> {
72        let stored = self.get_shape_stats(export_name)?;
73        let mut warnings = Vec::new();
74
75        for (col, &current_max) in current {
76            if let Some(&stored_max) = stored.get(col)
77                && stored_max > 0
78                && (current_max as f64) > stored_max as f64 * warn_factor
79            {
80                warnings.push(ShapeWarning {
81                    column: col.clone(),
82                    stored_max_bytes: stored_max,
83                    current_max_bytes: current_max,
84                    growth_factor: current_max as f64 / stored_max as f64,
85                });
86            }
87        }
88
89        self.store_shape_stats(export_name, current)?;
90        Ok(warnings)
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn store() -> StateStore {
99        StateStore::open_in_memory().expect("in-memory store")
100    }
101
102    #[test]
103    fn first_run_no_warnings() {
104        let s = store();
105        let stats: HashMap<String, u64> =
106            [("notes".into(), 512u64), ("description".into(), 1024u64)].into();
107        let warnings = s.detect_shape_drift("orders", &stats, 2.0).unwrap();
108        assert!(warnings.is_empty(), "first run must not warn");
109    }
110
111    #[test]
112    fn growth_below_threshold_no_warning() {
113        let s = store();
114        let v1: HashMap<String, u64> = [("body".into(), 1000u64)].into();
115        s.detect_shape_drift("t", &v1, 2.0).unwrap();
116
117        let v2: HashMap<String, u64> = [("body".into(), 1800u64)].into();
118        let warnings = s.detect_shape_drift("t", &v2, 2.0).unwrap();
119        assert!(warnings.is_empty());
120    }
121
122    #[test]
123    fn growth_above_threshold_warns() {
124        let s = store();
125        let v1: HashMap<String, u64> = [("body".into(), 1000u64)].into();
126        s.detect_shape_drift("t", &v1, 2.0).unwrap();
127
128        let v2: HashMap<String, u64> = [("body".into(), 2500u64)].into();
129        let warnings = s.detect_shape_drift("t", &v2, 2.0).unwrap();
130        assert_eq!(warnings.len(), 1);
131        assert_eq!(warnings[0].column, "body");
132        assert_eq!(warnings[0].stored_max_bytes, 1000);
133        assert_eq!(warnings[0].current_max_bytes, 2500);
134        assert!((warnings[0].growth_factor - 2.5).abs() < 0.01);
135    }
136
137    #[test]
138    fn high_water_mark_advances_after_warning() {
139        let s = store();
140        let v1: HashMap<String, u64> = [("text".into(), 100u64)].into();
141        s.detect_shape_drift("t", &v1, 2.0).unwrap();
142
143        let v2: HashMap<String, u64> = [("text".into(), 300u64)].into();
144        s.detect_shape_drift("t", &v2, 2.0).unwrap();
145
146        let v3: HashMap<String, u64> = [("text".into(), 450u64)].into();
147        let warnings = s.detect_shape_drift("t", &v3, 2.0).unwrap();
148        assert!(
149            warnings.is_empty(),
150            "must not re-warn after high-water mark advanced"
151        );
152    }
153
154    #[test]
155    fn new_column_in_later_run_no_warning() {
156        let s = store();
157        let v1: HashMap<String, u64> = [("id_str".into(), 36u64)].into();
158        s.detect_shape_drift("t", &v1, 2.0).unwrap();
159
160        let v2: HashMap<String, u64> =
161            [("id_str".into(), 36u64), ("new_col".into(), 9999u64)].into();
162        let warnings = s.detect_shape_drift("t", &v2, 2.0).unwrap();
163        assert!(
164            warnings.is_empty(),
165            "new columns with no history must not warn"
166        );
167    }
168}