Skip to main content

rivet/state/
progression.rs

1//! Committed / verified export progression (Epic G — ADR-0008).
2
3use chrono::{DateTime, Utc};
4
5use super::StateStore;
6use crate::error::Result;
7
8/// One export's progression record.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ExportProgression {
11    pub export_name: String,
12    pub committed: Option<Boundary>,
13    pub verified: Option<Boundary>,
14}
15
16/// A single boundary snapshot (committed or verified).
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Boundary {
19    pub strategy: String,
20    pub run_id: Option<String>,
21    pub cursor: Option<String>,
22    pub chunk_index: Option<i64>,
23    pub at: DateTime<Utc>,
24}
25
26impl StateStore {
27    /// Record a successful incremental commit: `cursor` is the max value written
28    /// to destination in this run.
29    ///
30    /// Monotonic guard: the boundary only moves forward; a non-advancing commit
31    /// leaves the row untouched. The comparison happens in Rust (see
32    /// `cursor_advances`) because the column is TEXT and SQL `<` would order
33    /// numeric cursors lexicographically ("1000" < "999"), freezing the
34    /// boundary. Read-then-write is not atomic, but — like the single-statement
35    /// guard it replaces — this is regression protection, not a lock; rivet
36    /// never runs two commits for the same export concurrently.
37    pub fn record_committed_incremental(
38        &self,
39        export_name: &str,
40        cursor: &str,
41        run_id: &str,
42    ) -> Result<()> {
43        if let Some(stored) = self.committed_cursor(export_name)?
44            && !cursor_advances(&stored, cursor)
45        {
46            return Ok(());
47        }
48        let now = Utc::now().to_rfc3339();
49        let sql = "INSERT INTO export_progression (
50                export_name,
51                last_committed_strategy, last_committed_cursor, last_committed_chunk_index,
52                last_committed_run_id, last_committed_at
53             ) VALUES (?1, 'incremental', ?2, NULL, ?3, ?4)
54             ON CONFLICT(export_name) DO UPDATE SET
55                last_committed_strategy = 'incremental',
56                last_committed_cursor = excluded.last_committed_cursor,
57                last_committed_chunk_index = NULL,
58                last_committed_run_id = excluded.last_committed_run_id,
59                last_committed_at = excluded.last_committed_at";
60        self.execute(
61            sql,
62            &[export_name.into(), cursor.into(), run_id.into(), now.into()],
63        )?;
64        Ok(())
65    }
66
67    /// Stored committed cursor for `export_name` — `None` when the export has
68    /// no progression row or its committed boundary is chunked (cursor NULL).
69    fn committed_cursor(&self, export_name: &str) -> Result<Option<String>> {
70        let sql = "SELECT last_committed_cursor FROM export_progression WHERE export_name = ?1";
71        // query_opt → Option<row>; the row's cursor is itself Option<String> → flatten.
72        Ok(self
73            .query_opt(sql, &[export_name.into()], |r| r.opt_text(0))?
74            .flatten())
75    }
76
77    /// Record a successful chunked-run commit: the highest completed `chunk_index` for this run.
78    pub fn record_committed_chunked(
79        &self,
80        export_name: &str,
81        highest_chunk_index: i64,
82        run_id: &str,
83    ) -> Result<()> {
84        let now = Utc::now().to_rfc3339();
85        let sql = "INSERT INTO export_progression (
86                export_name,
87                last_committed_strategy, last_committed_cursor, last_committed_chunk_index,
88                last_committed_run_id, last_committed_at
89             ) VALUES (?1, 'chunked', NULL, ?2, ?3, ?4)
90             ON CONFLICT(export_name) DO UPDATE SET
91                last_committed_strategy = 'chunked',
92                last_committed_cursor = NULL,
93                last_committed_chunk_index = excluded.last_committed_chunk_index,
94                last_committed_run_id = excluded.last_committed_run_id,
95                last_committed_at = excluded.last_committed_at";
96        self.execute(
97            sql,
98            &[
99                export_name.into(),
100                highest_chunk_index.into(),
101                run_id.into(),
102                now.into(),
103            ],
104        )?;
105        Ok(())
106    }
107
108    /// Record a successful reconcile: all partitions in `run_id` matched.
109    pub fn record_verified_chunked(
110        &self,
111        export_name: &str,
112        highest_chunk_index: i64,
113        run_id: &str,
114    ) -> Result<()> {
115        let now = Utc::now().to_rfc3339();
116        let sql = "INSERT INTO export_progression (
117                export_name,
118                last_verified_strategy, last_verified_cursor, last_verified_chunk_index,
119                last_verified_run_id, last_verified_at
120             ) VALUES (?1, 'chunked', NULL, ?2, ?3, ?4)
121             ON CONFLICT(export_name) DO UPDATE SET
122                last_verified_strategy = 'chunked',
123                last_verified_cursor = NULL,
124                last_verified_chunk_index = excluded.last_verified_chunk_index,
125                last_verified_run_id = excluded.last_verified_run_id,
126                last_verified_at = excluded.last_verified_at";
127        self.execute(
128            sql,
129            &[
130                export_name.into(),
131                highest_chunk_index.into(),
132                run_id.into(),
133                now.into(),
134            ],
135        )?;
136        Ok(())
137    }
138
139    pub fn get_progression(&self, export_name: &str) -> Result<ExportProgression> {
140        let sql = "SELECT
141                last_committed_strategy, last_committed_cursor, last_committed_chunk_index,
142                last_committed_run_id, last_committed_at,
143                last_verified_strategy, last_verified_cursor, last_verified_chunk_index,
144                last_verified_run_id, last_verified_at
145             FROM export_progression WHERE export_name = ?1";
146        Ok(self
147            .query_opt(sql, &[export_name.into()], |r| ExportProgression {
148                export_name: export_name.to_string(),
149                committed: boundary_from_row(
150                    r.opt_text(0),
151                    r.opt_text(1),
152                    r.opt_i64(2),
153                    r.opt_text(3),
154                    r.opt_text(4),
155                ),
156                verified: boundary_from_row(
157                    r.opt_text(5),
158                    r.opt_text(6),
159                    r.opt_i64(7),
160                    r.opt_text(8),
161                    r.opt_text(9),
162                ),
163            })?
164            .unwrap_or_else(|| ExportProgression {
165                export_name: export_name.to_string(),
166                committed: None,
167                verified: None,
168            }))
169    }
170
171    /// Delete the progression row for an export (committed + verified boundary).
172    ///
173    /// Called from `StateStore::reset` / `reset_chunk_checkpoint` so a reset
174    /// returns the export to a "never ran" state across *all* state tables. A
175    /// surviving `export_progression` row would make `rivet state progression`
176    /// report a stale committed boundary after `state show` is already empty —
177    /// a silent inconsistency that masks the reset.
178    ///
179    /// Returns the number of rows deleted (0 or 1).
180    pub fn delete_progression(&self, export_name: &str) -> Result<usize> {
181        self.execute(
182            "DELETE FROM export_progression WHERE export_name = ?1",
183            &[export_name.into()],
184        )
185    }
186
187    pub fn list_progression(&self) -> Result<Vec<ExportProgression>> {
188        // One query + one projection for both backends (was a per-name loop on
189        // SQLite and a duplicated 11-column extraction on Postgres).
190        let sql = "SELECT export_name,
191                        last_committed_strategy, last_committed_cursor, last_committed_chunk_index,
192                        last_committed_run_id, last_committed_at,
193                        last_verified_strategy, last_verified_cursor, last_verified_chunk_index,
194                        last_verified_run_id, last_verified_at
195                 FROM export_progression ORDER BY export_name";
196        self.query(sql, &[], |r| ExportProgression {
197            export_name: r.text(0),
198            committed: boundary_from_row(
199                r.opt_text(1),
200                r.opt_text(2),
201                r.opt_i64(3),
202                r.opt_text(4),
203                r.opt_text(5),
204            ),
205            verified: boundary_from_row(
206                r.opt_text(6),
207                r.opt_text(7),
208                r.opt_i64(8),
209                r.opt_text(9),
210                r.opt_text(10),
211            ),
212        })
213    }
214}
215
216/// True when `new` advances strictly past `stored` under cursor ordering.
217///
218/// Cursors are stored as TEXT but are often numeric (integer PKs, Float64
219/// columns stringified by the sink). Integers compare as i128 first — exact
220/// past f64's 2^53 mantissa — then floats as f64; when either side has no
221/// numeric reading (or the f64s are unordered, e.g. NaN) the guard falls back
222/// to byte-wise string order, which is correct for RFC3339 timestamps,
223/// `YYYY-MM-DD` dates, and UUIDv7 keys.
224fn cursor_advances(stored: &str, new: &str) -> bool {
225    if let (Ok(a), Ok(b)) = (stored.parse::<i128>(), new.parse::<i128>()) {
226        return b > a;
227    }
228    if let (Ok(a), Ok(b)) = (stored.parse::<f64>(), new.parse::<f64>())
229        && let Some(ord) = b.partial_cmp(&a)
230    {
231        return ord.is_gt();
232    }
233    new > stored
234}
235
236fn boundary_from_row(
237    strategy: Option<String>,
238    cursor: Option<String>,
239    chunk_index: Option<i64>,
240    run_id: Option<String>,
241    at: Option<String>,
242) -> Option<Boundary> {
243    let strategy = strategy?;
244    let at = at
245        .as_deref()
246        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
247        .map(|dt| dt.with_timezone(&Utc))?;
248    Some(Boundary {
249        strategy,
250        run_id,
251        cursor,
252        chunk_index,
253        at,
254    })
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    fn store() -> StateStore {
262        StateStore::open_in_memory().expect("in-memory store")
263    }
264
265    #[test]
266    fn progression_unknown_export_returns_empty() {
267        let s = store();
268        let p = s.get_progression("orders").unwrap();
269        assert!(p.committed.is_none());
270        assert!(p.verified.is_none());
271    }
272
273    #[test]
274    fn committed_incremental_records_cursor_and_run() {
275        let s = store();
276        s.record_committed_incremental("orders", "2024-06-01", "run-1")
277            .unwrap();
278        let b = s.get_progression("orders").unwrap().committed.unwrap();
279        assert_eq!(b.strategy, "incremental");
280        assert_eq!(b.cursor.as_deref(), Some("2024-06-01"));
281        assert_eq!(b.chunk_index, None);
282        assert_eq!(b.run_id.as_deref(), Some("run-1"));
283    }
284
285    #[test]
286    fn committed_cursor_does_not_regress_lexicographically() {
287        let s = store();
288        s.record_committed_incremental("orders", "2024-06-10", "run-10")
289            .unwrap();
290        s.record_committed_incremental("orders", "2024-01-01", "run-01")
291            .unwrap();
292        let b = s.get_progression("orders").unwrap().committed.unwrap();
293        assert_eq!(b.cursor.as_deref(), Some("2024-06-10"));
294    }
295
296    #[test]
297    fn committed_chunked_records_chunk_index() {
298        let s = store();
299        s.record_committed_chunked("orders", 41, "run-A").unwrap();
300        let b = s.get_progression("orders").unwrap().committed.unwrap();
301        assert_eq!(b.strategy, "chunked");
302        assert_eq!(b.chunk_index, Some(41));
303        assert_eq!(b.cursor, None);
304    }
305
306    #[test]
307    fn verified_and_committed_are_independent() {
308        let s = store();
309        s.record_committed_chunked("orders", 10, "run-A").unwrap();
310        s.record_verified_chunked("orders", 5, "run-A").unwrap();
311        let p = s.get_progression("orders").unwrap();
312        assert_eq!(p.committed.as_ref().unwrap().chunk_index, Some(10));
313        assert_eq!(p.verified.as_ref().unwrap().chunk_index, Some(5));
314    }
315
316    #[test]
317    fn switching_strategy_updates_committed_row() {
318        let s = store();
319        s.record_committed_incremental("orders", "2024-01-01", "inc-1")
320            .unwrap();
321        s.record_committed_chunked("orders", 7, "chunk-1").unwrap();
322        let b = s.get_progression("orders").unwrap().committed.unwrap();
323        assert_eq!(b.strategy, "chunked");
324        assert_eq!(b.chunk_index, Some(7));
325        assert_eq!(b.cursor, None);
326    }
327
328    // ROAST-RED progression-numeric-cursor: committed-boundary guard compares cursors
329    // with SQL string '<' on a TEXT column, so numeric cursors regress ("1000" < "999"
330    // lexicographically) and the boundary freezes at the shorter value.
331    // Asserts CORRECT behavior; expected to FAIL until the fix lands.
332    #[test]
333    fn roast_committed_numeric_cursor_advances_past_lexicographic_boundary() {
334        let s = store();
335        s.record_committed_incremental("orders", "999", "run-999")
336            .unwrap();
337        s.record_committed_incremental("orders", "1000", "run-1000")
338            .unwrap();
339        let b = s.get_progression("orders").unwrap().committed.unwrap();
340        assert_eq!(
341            b.cursor.as_deref(),
342            Some("1000"),
343            "numeric cursor must advance from 999 to 1000, but the lexicographic \
344             TEXT comparison froze the committed boundary at {:?}",
345            b.cursor
346        );
347    }
348
349    #[test]
350    fn committed_numeric_cursor_does_not_regress() {
351        let s = store();
352        s.record_committed_incremental("orders", "1000", "run-1000")
353            .unwrap();
354        s.record_committed_incremental("orders", "999", "run-999")
355            .unwrap();
356        let b = s.get_progression("orders").unwrap().committed.unwrap();
357        assert_eq!(b.cursor.as_deref(), Some("1000"));
358        assert_eq!(
359            b.run_id.as_deref(),
360            Some("run-1000"),
361            "non-advancing commit must leave the boundary row untouched"
362        );
363    }
364
365    #[test]
366    fn committed_float_cursor_advances_across_integer_boundary() {
367        // "10" < "9.9" lexicographically; the sink stringifies Float64 cursors
368        // as "10" (no trailing .0), so once the i128 parse fails on "9.9" the
369        // guard must compare as f64.
370        let s = store();
371        s.record_committed_incremental("scores", "9.9", "run-1")
372            .unwrap();
373        s.record_committed_incremental("scores", "10", "run-2")
374            .unwrap();
375        let b = s.get_progression("scores").unwrap().committed.unwrap();
376        assert_eq!(b.cursor.as_deref(), Some("10"));
377        s.record_committed_incremental("scores", "9.95", "run-3")
378            .unwrap();
379        let b = s.get_progression("scores").unwrap().committed.unwrap();
380        assert_eq!(b.cursor.as_deref(), Some("10"), "9.95 must not regress 10");
381    }
382
383    #[test]
384    fn committed_equal_cursor_is_a_no_op() {
385        let s = store();
386        s.record_committed_incremental("orders", "100", "run-1")
387            .unwrap();
388        s.record_committed_incremental("orders", "100", "run-2")
389            .unwrap();
390        let b = s.get_progression("orders").unwrap().committed.unwrap();
391        assert_eq!(b.cursor.as_deref(), Some("100"));
392        assert_eq!(
393            b.run_id.as_deref(),
394            Some("run-1"),
395            "an equal cursor does not advance; the row must stay untouched"
396        );
397    }
398
399    #[test]
400    fn committed_rfc3339_cursor_advances_and_does_not_regress() {
401        let s = store();
402        s.record_committed_incremental("orders", "2024-06-01T00:00:00Z", "run-1")
403            .unwrap();
404        s.record_committed_incremental("orders", "2024-06-02T00:00:00Z", "run-2")
405            .unwrap();
406        let b = s.get_progression("orders").unwrap().committed.unwrap();
407        assert_eq!(b.cursor.as_deref(), Some("2024-06-02T00:00:00Z"));
408        s.record_committed_incremental("orders", "2024-05-31T00:00:00Z", "run-3")
409            .unwrap();
410        let b = s.get_progression("orders").unwrap().committed.unwrap();
411        assert_eq!(b.cursor.as_deref(), Some("2024-06-02T00:00:00Z"));
412    }
413
414    #[test]
415    fn committed_mixed_kind_cursor_falls_back_to_string_order() {
416        // Old stored cursor is non-numeric, new one is numeric: there is no
417        // shared numeric domain, so the guard keeps plain string order —
418        // "123" < "abc" byte-wise, so the boundary holds.
419        let s = store();
420        s.record_committed_incremental("orders", "abc", "run-1")
421            .unwrap();
422        s.record_committed_incremental("orders", "123", "run-2")
423            .unwrap();
424        let b = s.get_progression("orders").unwrap().committed.unwrap();
425        assert_eq!(b.cursor.as_deref(), Some("abc"));
426    }
427
428    #[test]
429    fn committed_large_integer_cursor_compares_exactly() {
430        // 2^53 and 2^53 + 1 collapse to the same f64; the i128 path must
431        // compare them exactly so the boundary still advances by one.
432        let s = store();
433        s.record_committed_incremental("orders", "9007199254740992", "run-1")
434            .unwrap();
435        s.record_committed_incremental("orders", "9007199254740993", "run-2")
436            .unwrap();
437        let b = s.get_progression("orders").unwrap().committed.unwrap();
438        assert_eq!(b.cursor.as_deref(), Some("9007199254740993"));
439    }
440
441    #[test]
442    fn switching_chunked_to_incremental_writes_cursor() {
443        // The progression row exists but its committed cursor is NULL
444        // (chunked); an incremental commit must write unconditionally.
445        let s = store();
446        s.record_committed_chunked("orders", 7, "chunk-1").unwrap();
447        s.record_committed_incremental("orders", "100", "inc-1")
448            .unwrap();
449        let b = s.get_progression("orders").unwrap().committed.unwrap();
450        assert_eq!(b.strategy, "incremental");
451        assert_eq!(b.cursor.as_deref(), Some("100"));
452        assert_eq!(b.chunk_index, None);
453    }
454
455    #[test]
456    fn cursor_advances_orders_numbers_strings_and_nan() {
457        assert!(cursor_advances("999", "1000"));
458        assert!(!cursor_advances("1000", "999"));
459        assert!(!cursor_advances("100", "100"));
460        assert!(cursor_advances("9.9", "10"));
461        assert!(cursor_advances("-5", "-4"));
462        assert!(cursor_advances("2024-01-01", "2024-06-10"));
463        // NaN has no f64 order; fall back to string order instead of
464        // freezing the boundary forever.
465        assert!(cursor_advances("NaN", "inf"));
466        assert!(!cursor_advances("inf", "NaN"));
467    }
468
469    #[test]
470    fn delete_progression_removes_only_the_named_export() {
471        let s = store();
472        s.record_committed_incremental("orders", "100", "run-o")
473            .unwrap();
474        s.record_committed_incremental("users", "9", "run-u")
475            .unwrap();
476
477        assert_eq!(
478            s.delete_progression("orders").unwrap(),
479            1,
480            "deleting an existing progression row reports one row removed"
481        );
482        assert!(s.get_progression("orders").unwrap().committed.is_none());
483        assert!(
484            s.get_progression("users").unwrap().committed.is_some(),
485            "delete must be scoped to the named export"
486        );
487        assert_eq!(
488            s.delete_progression("orders").unwrap(),
489            0,
490            "deleting an absent progression row is a no-op (zero rows)"
491        );
492    }
493
494    #[test]
495    fn list_progression_sorted_by_name() {
496        let s = store();
497        s.record_committed_incremental("gamma", "3", "r").unwrap();
498        s.record_committed_incremental("alpha", "1", "r").unwrap();
499        s.record_committed_incremental("beta", "2", "r").unwrap();
500        let all = s.list_progression().unwrap();
501        let names: Vec<_> = all.iter().map(|p| p.export_name.as_str()).collect();
502        assert_eq!(names, vec!["alpha", "beta", "gamma"]);
503    }
504}