Skip to main content

rivet/state/
cursor.rs

1use crate::error::Result;
2use crate::types::CursorState;
3
4use super::StateStore;
5
6/// Incremental cursor store — reads and writes `export_state`.
7///
8/// The cursor records the last extracted value so incremental runs can pick up
9/// where the previous run left off.  Invariant I3 (Write Before Cursor) governs
10/// the ordering of cursor updates relative to destination writes.
11impl StateStore {
12    pub fn get(&self, export_name: &str) -> Result<CursorState> {
13        Ok(self
14            .query_opt(
15                "SELECT last_cursor_value, last_run_at FROM export_state WHERE export_name = ?1",
16                &[export_name.into()],
17                |r| CursorState {
18                    export_name: export_name.to_string(),
19                    last_cursor_value: r.opt_text(0),
20                    last_run_at: r.opt_text(1),
21                },
22            )?
23            .unwrap_or_else(|| CursorState {
24                export_name: export_name.to_string(),
25                last_cursor_value: None,
26                last_run_at: None,
27            }))
28    }
29
30    pub fn update(&self, export_name: &str, cursor_value: &str) -> Result<()> {
31        let now = chrono::Utc::now().to_rfc3339();
32        let sql = "INSERT INTO export_state (export_name, last_cursor_value, last_run_at)
33             VALUES (?1, ?2, ?3)
34             ON CONFLICT(export_name) DO UPDATE SET
35                last_cursor_value = excluded.last_cursor_value,
36                last_run_at = excluded.last_run_at";
37        self.execute(sql, &[export_name.into(), cursor_value.into(), now.into()])?;
38        Ok(())
39    }
40
41    /// Round-5 (keyset checkpoint-resume manifest completeness): persist the
42    /// in-progress keyset run_id beside the resume cursor, so a crash+resume reuses
43    /// it and reconstructs every committed page's manifest part from file_log. Set on
44    /// the first checkpointed run, read on resume, cleared when the run finalizes.
45    pub fn set_resume_run_id(&self, export_name: &str, run_id: &str) -> Result<()> {
46        let now = chrono::Utc::now().to_rfc3339();
47        let sql = "INSERT INTO export_state (export_name, resume_run_id, last_run_at)
48             VALUES (?1, ?2, ?3)
49             ON CONFLICT(export_name) DO UPDATE SET resume_run_id = excluded.resume_run_id";
50        self.execute(sql, &[export_name.into(), run_id.into(), now.into()])?;
51        Ok(())
52    }
53
54    /// The persisted in-progress keyset run_id, or None when no run is in progress.
55    pub fn get_resume_run_id(&self, export_name: &str) -> Result<Option<String>> {
56        let sql = "SELECT resume_run_id FROM export_state WHERE export_name = ?1";
57        Ok(self
58            .query_opt(sql, &[export_name.into()], |r| r.opt_text(0))?
59            .flatten())
60    }
61
62    /// Clear the in-progress run_id once a keyset run has finalized its manifest.
63    pub fn clear_resume_run_id(&self, export_name: &str) -> Result<()> {
64        self.execute(
65            "UPDATE export_state SET resume_run_id = NULL WHERE export_name = ?1",
66            &[export_name.into()],
67        )?;
68        Ok(())
69    }
70
71    /// Null ONLY the persisted keyset high-water mark (`last_cursor_value`),
72    /// leaving `resume_run_id` and the progression boundary intact.
73    ///
74    /// Crash-recovery-only (non-incremental) keyset needs this at the start of a
75    /// FRESH run: `last_cursor_value` is a run-independent persistent field, so a
76    /// prior COMPLETED run leaves its final high-water mark behind. If this fresh
77    /// run then crashes BEFORE its first page commits, the recovery run would load
78    /// that stale mark as this run's "resume point" and skip the whole table
79    /// (`WHERE key > <prior-max>` → 0 rows → a successful empty manifest). Clearing
80    /// it here ties crash-recovery to THIS run's committed progress only.
81    /// Incremental keyset deliberately does NOT clear it — continuing from the
82    /// prior high-water mark is the whole point of `keyset_incremental`.
83    pub fn clear_cursor_value(&self, export_name: &str) -> Result<()> {
84        self.execute(
85            "UPDATE export_state SET last_cursor_value = NULL WHERE export_name = ?1",
86            &[export_name.into()],
87        )?;
88        Ok(())
89    }
90
91    /// Return an export to a "never ran" state.
92    ///
93    /// Clears the incremental cursor (`export_state`) **and** the committed /
94    /// verified boundary (`export_progression`). Both must go: a surviving
95    /// progression row would make `rivet state progression` report a stale
96    /// committed boundary after `state show` is already empty.
97    pub fn reset(&self, export_name: &str) -> Result<()> {
98        self.execute(
99            "DELETE FROM export_state WHERE export_name = ?1",
100            &[export_name.into()],
101        )?;
102        self.delete_progression(export_name)?;
103        Ok(())
104    }
105
106    pub fn list_all(&self) -> Result<Vec<CursorState>> {
107        self.query(
108            "SELECT export_name, last_cursor_value, last_run_at FROM export_state ORDER BY export_name",
109            &[],
110            |r| CursorState {
111                export_name: r.text(0),
112                last_cursor_value: r.opt_text(1),
113                last_run_at: r.opt_text(2),
114            },
115        )
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn store() -> StateStore {
124        StateStore::open_in_memory().expect("in-memory store")
125    }
126
127    #[test]
128    fn get_unknown_returns_empty_state() {
129        let s = store();
130        let state = s.get("nonexistent").unwrap();
131        assert!(state.last_cursor_value.is_none());
132    }
133
134    #[test]
135    fn update_then_get_returns_stored_cursor() {
136        let s = store();
137        s.update("orders", "2024-06-01").unwrap();
138        assert_eq!(
139            s.get("orders").unwrap().last_cursor_value.as_deref(),
140            Some("2024-06-01")
141        );
142    }
143
144    #[test]
145    fn update_overwrites_previous_cursor() {
146        let s = store();
147        s.update("orders", "100").unwrap();
148        s.update("orders", "200").unwrap();
149        assert_eq!(
150            s.get("orders").unwrap().last_cursor_value.as_deref(),
151            Some("200")
152        );
153    }
154
155    #[test]
156    fn reset_clears_cursor_state() {
157        let s = store();
158        s.update("orders", "100").unwrap();
159        s.reset("orders").unwrap();
160        assert!(s.get("orders").unwrap().last_cursor_value.is_none());
161    }
162
163    #[test]
164    fn clear_cursor_value_nulls_the_cursor_but_keeps_the_resume_run_id() {
165        // The keyset stale-cursor fix: a fresh non-incremental run must null the
166        // prior COMPLETED run's high-water mark so a pre-first-commit crash cannot
167        // resume from it (skipping the whole table) — WITHOUT dropping the fresh
168        // resume_run_id it is about to set (crash-recovery needs that).
169        let s = store();
170        s.update("orders", "9000000").unwrap();
171        s.set_resume_run_id("orders", "run_2").unwrap();
172        s.clear_cursor_value("orders").unwrap();
173        assert!(
174            s.get("orders").unwrap().last_cursor_value.is_none(),
175            "cursor must be nulled"
176        );
177        assert_eq!(
178            s.get_resume_run_id("orders").unwrap().as_deref(),
179            Some("run_2"),
180            "resume_run_id must survive"
181        );
182    }
183
184    #[test]
185    fn list_all_on_empty_store_returns_empty() {
186        assert!(store().list_all().unwrap().is_empty());
187    }
188
189    #[test]
190    fn list_all_returns_entries_sorted_by_name() {
191        let s = store();
192        s.update("gamma", "3").unwrap();
193        s.update("alpha", "1").unwrap();
194        s.update("beta", "2").unwrap();
195        let all = s.list_all().unwrap();
196        assert_eq!(all[0].export_name, "alpha");
197        assert_eq!(all[2].export_name, "gamma");
198    }
199
200    // ─── Cursor round-trip / monotonicity (QA backlog Task 3.1) ─────────────
201    //
202    // ADR-0001 I3 makes monotonicity a pipeline responsibility, not a storage
203    // one.  These tests pin the *value-preservation* contract on the state
204    // side — the subset the pipeline relies on when reading the stored cursor
205    // back on resume.
206
207    /// Duplicate cursor values across runs are common when the cursor column
208    /// is a low-precision timestamp with ties.  The store must return each
209    /// written value verbatim.
210    #[test]
211    fn duplicate_cursor_values_are_stored_as_written() {
212        let s = store();
213        s.update("orders", "2024-06-01T00:00:00Z").unwrap();
214        s.update("orders", "2024-06-01T00:00:00Z").unwrap();
215        assert_eq!(
216            s.get("orders").unwrap().last_cursor_value.as_deref(),
217            Some("2024-06-01T00:00:00Z")
218        );
219    }
220
221    /// Microsecond/nanosecond precision must not be rounded or truncated on
222    /// round-trip — otherwise the pipeline's strict-greater-than boundary
223    /// check would re-export rows on the microsecond edge.
224    #[test]
225    fn high_precision_timestamp_is_preserved_byte_for_byte() {
226        let s = store();
227        let ts = "2024-06-01T12:34:56.123456789+02:00";
228        s.update("events", ts).unwrap();
229        assert_eq!(
230            s.get("events").unwrap().last_cursor_value.as_deref(),
231            Some(ts)
232        );
233    }
234
235    /// Cursor values can be arbitrary UTF-8: UUID v7, version tokens,
236    /// Cyrillic names, multiline strings, the empty string.
237    #[test]
238    fn unicode_and_binary_like_cursor_values_round_trip() {
239        let s = store();
240        let values = [
241            "2024-06-01",
242            "018f1c0b-7a34-7b54-8e16-1c5a9b3f1c2d", // UUID v7
243            "ελληνικά 🚀 cursor",
244            "v\n\t with whitespace",
245            "",
246        ];
247        for v in values {
248            s.update("t", v).unwrap();
249            assert_eq!(
250                s.get("t").unwrap().last_cursor_value.as_deref(),
251                Some(v),
252                "cursor value {v:?} must round-trip exactly"
253            );
254        }
255    }
256
257    /// Resume-from-zero tooling depends on `reset` producing a state
258    /// indistinguishable from "never ran": both cursor and last_run_at gone.
259    #[test]
260    fn reset_clears_cursor_state_completely() {
261        let s = store();
262        s.update("orders", "2024-06-01").unwrap();
263        s.reset("orders").unwrap();
264        let after = s.get("orders").unwrap();
265        assert!(after.last_cursor_value.is_none());
266        assert!(
267            after.last_run_at.is_none(),
268            "reset must clear last_run_at as well"
269        );
270    }
271
272    /// #22 (0.9.x audit): `reset` left `export_progression` behind, so
273    /// `rivet state progression` reported a stale committed boundary after
274    /// `state show` was already empty. Reset must clear progression too.
275    #[test]
276    fn reset_clears_committed_progression() {
277        let s = store();
278        s.update("orders", "100").unwrap();
279        s.record_committed_incremental("orders", "100", "run-1")
280            .unwrap();
281        // Other exports' progression must survive — reset is per-export.
282        s.record_committed_incremental("users", "9", "run-u")
283            .unwrap();
284
285        s.reset("orders").unwrap();
286
287        let p = s.get_progression("orders").unwrap();
288        assert!(
289            p.committed.is_none() && p.verified.is_none(),
290            "reset must clear the export's committed/verified boundary"
291        );
292        assert!(
293            s.get_progression("users").unwrap().committed.is_some(),
294            "reset must not touch another export's progression"
295        );
296    }
297}