Skip to main content

rivet/state/
cursor.rs

1use crate::error::Result;
2use crate::types::CursorState;
3
4use super::{StateConn, StateStore, pg_sql};
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        match &self.conn {
14            StateConn::Sqlite(c) => {
15                let mut stmt = c.prepare(
16                    "SELECT last_cursor_value, last_run_at FROM export_state WHERE export_name = ?1",
17                )?;
18                let result = stmt.query_row([export_name], |row| {
19                    Ok(CursorState {
20                        export_name: export_name.to_string(),
21                        last_cursor_value: row.get(0)?,
22                        last_run_at: row.get(1)?,
23                    })
24                });
25                match result {
26                    Ok(state) => Ok(state),
27                    Err(rusqlite::Error::QueryReturnedNoRows) => Ok(CursorState {
28                        export_name: export_name.to_string(),
29                        last_cursor_value: None,
30                        last_run_at: None,
31                    }),
32                    Err(e) => Err(e.into()),
33                }
34            }
35            StateConn::Postgres(client) => {
36                let mut c = client.borrow_mut();
37                match c.query_opt(
38                    "SELECT last_cursor_value, last_run_at FROM export_state WHERE export_name = $1",
39                    &[&export_name],
40                )? {
41                    Some(row) => Ok(CursorState {
42                        export_name: export_name.to_string(),
43                        last_cursor_value: row.get(0),
44                        last_run_at: row.get(1),
45                    }),
46                    None => Ok(CursorState {
47                        export_name: export_name.to_string(),
48                        last_cursor_value: None,
49                        last_run_at: None,
50                    }),
51                }
52            }
53        }
54    }
55
56    pub fn update(&self, export_name: &str, cursor_value: &str) -> Result<()> {
57        let now = chrono::Utc::now().to_rfc3339();
58        let sql = "INSERT INTO export_state (export_name, last_cursor_value, last_run_at)
59             VALUES (?1, ?2, ?3)
60             ON CONFLICT(export_name) DO UPDATE SET
61                last_cursor_value = excluded.last_cursor_value,
62                last_run_at = excluded.last_run_at";
63        match &self.conn {
64            StateConn::Sqlite(c) => {
65                c.execute(sql, rusqlite::params![export_name, cursor_value, now])?;
66            }
67            StateConn::Postgres(client) => {
68                let mut c = client.borrow_mut();
69                c.execute(&pg_sql(sql), &[&export_name, &cursor_value, &now])?;
70            }
71        }
72        Ok(())
73    }
74
75    /// Round-5 (keyset checkpoint-resume manifest completeness): persist the
76    /// in-progress keyset run_id beside the resume cursor, so a crash+resume reuses
77    /// it and reconstructs every committed page's manifest part from file_log. Set on
78    /// the first checkpointed run, read on resume, cleared when the run finalizes.
79    pub fn set_resume_run_id(&self, export_name: &str, run_id: &str) -> Result<()> {
80        let now = chrono::Utc::now().to_rfc3339();
81        let sql = "INSERT INTO export_state (export_name, resume_run_id, last_run_at)
82             VALUES (?1, ?2, ?3)
83             ON CONFLICT(export_name) DO UPDATE SET resume_run_id = excluded.resume_run_id";
84        match &self.conn {
85            StateConn::Sqlite(c) => {
86                c.execute(sql, rusqlite::params![export_name, run_id, now])?;
87            }
88            StateConn::Postgres(client) => {
89                let mut c = client.borrow_mut();
90                c.execute(&pg_sql(sql), &[&export_name, &run_id, &now])?;
91            }
92        }
93        Ok(())
94    }
95
96    /// The persisted in-progress keyset run_id, or None when no run is in progress.
97    pub fn get_resume_run_id(&self, export_name: &str) -> Result<Option<String>> {
98        let sql = "SELECT resume_run_id FROM export_state WHERE export_name = ?1";
99        match &self.conn {
100            StateConn::Sqlite(c) => {
101                let mut stmt = c.prepare(sql)?;
102                let mut rows = stmt.query_map([export_name], |r| r.get::<_, Option<String>>(0))?;
103                Ok(rows.next().transpose()?.flatten())
104            }
105            StateConn::Postgres(client) => {
106                let mut c = client.borrow_mut();
107                let rows = c.query(&pg_sql(sql), &[&export_name])?;
108                Ok(rows.first().and_then(|r| r.get::<_, Option<String>>(0)))
109            }
110        }
111    }
112
113    /// Clear the in-progress run_id once a keyset run has finalized its manifest.
114    pub fn clear_resume_run_id(&self, export_name: &str) -> Result<()> {
115        let sql = "UPDATE export_state SET resume_run_id = NULL WHERE export_name = ?1";
116        match &self.conn {
117            StateConn::Sqlite(c) => {
118                c.execute(sql, [export_name])?;
119            }
120            StateConn::Postgres(client) => {
121                let mut c = client.borrow_mut();
122                c.execute(&pg_sql(sql), &[&export_name])?;
123            }
124        }
125        Ok(())
126    }
127
128    /// Return an export to a "never ran" state.
129    ///
130    /// Clears the incremental cursor (`export_state`) **and** the committed /
131    /// verified boundary (`export_progression`). Both must go: a surviving
132    /// progression row would make `rivet state progression` report a stale
133    /// committed boundary after `state show` is already empty.
134    pub fn reset(&self, export_name: &str) -> Result<()> {
135        let sql = "DELETE FROM export_state WHERE export_name = ?1";
136        match &self.conn {
137            StateConn::Sqlite(c) => {
138                c.execute(sql, [export_name])?;
139            }
140            StateConn::Postgres(client) => {
141                let mut c = client.borrow_mut();
142                c.execute(&pg_sql(sql), &[&export_name])?;
143            }
144        }
145        self.delete_progression(export_name)?;
146        Ok(())
147    }
148
149    pub fn list_all(&self) -> Result<Vec<CursorState>> {
150        let sql = "SELECT export_name, last_cursor_value, last_run_at FROM export_state ORDER BY export_name";
151        match &self.conn {
152            StateConn::Sqlite(c) => {
153                let mut stmt = c.prepare(sql)?;
154                let rows = stmt.query_map([], |row| {
155                    Ok(CursorState {
156                        export_name: row.get(0)?,
157                        last_cursor_value: row.get(1)?,
158                        last_run_at: row.get(2)?,
159                    })
160                })?;
161                rows.collect::<std::result::Result<Vec<_>, _>>()
162                    .map_err(Into::into)
163            }
164            StateConn::Postgres(client) => {
165                let mut c = client.borrow_mut();
166                let rows = c.query(sql, &[])?;
167                Ok(rows
168                    .iter()
169                    .map(|row| CursorState {
170                        export_name: row.get(0),
171                        last_cursor_value: row.get(1),
172                        last_run_at: row.get(2),
173                    })
174                    .collect())
175            }
176        }
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    fn store() -> StateStore {
185        StateStore::open_in_memory().expect("in-memory store")
186    }
187
188    #[test]
189    fn get_unknown_returns_empty_state() {
190        let s = store();
191        let state = s.get("nonexistent").unwrap();
192        assert!(state.last_cursor_value.is_none());
193    }
194
195    #[test]
196    fn update_then_get_returns_stored_cursor() {
197        let s = store();
198        s.update("orders", "2024-06-01").unwrap();
199        assert_eq!(
200            s.get("orders").unwrap().last_cursor_value.as_deref(),
201            Some("2024-06-01")
202        );
203    }
204
205    #[test]
206    fn update_overwrites_previous_cursor() {
207        let s = store();
208        s.update("orders", "100").unwrap();
209        s.update("orders", "200").unwrap();
210        assert_eq!(
211            s.get("orders").unwrap().last_cursor_value.as_deref(),
212            Some("200")
213        );
214    }
215
216    #[test]
217    fn reset_clears_cursor_state() {
218        let s = store();
219        s.update("orders", "100").unwrap();
220        s.reset("orders").unwrap();
221        assert!(s.get("orders").unwrap().last_cursor_value.is_none());
222    }
223
224    #[test]
225    fn list_all_on_empty_store_returns_empty() {
226        assert!(store().list_all().unwrap().is_empty());
227    }
228
229    #[test]
230    fn list_all_returns_entries_sorted_by_name() {
231        let s = store();
232        s.update("gamma", "3").unwrap();
233        s.update("alpha", "1").unwrap();
234        s.update("beta", "2").unwrap();
235        let all = s.list_all().unwrap();
236        assert_eq!(all[0].export_name, "alpha");
237        assert_eq!(all[2].export_name, "gamma");
238    }
239
240    // ─── Cursor round-trip / monotonicity (QA backlog Task 3.1) ─────────────
241    //
242    // ADR-0001 I3 makes monotonicity a pipeline responsibility, not a storage
243    // one.  These tests pin the *value-preservation* contract on the state
244    // side — the subset the pipeline relies on when reading the stored cursor
245    // back on resume.
246
247    /// Duplicate cursor values across runs are common when the cursor column
248    /// is a low-precision timestamp with ties.  The store must return each
249    /// written value verbatim.
250    #[test]
251    fn duplicate_cursor_values_are_stored_as_written() {
252        let s = store();
253        s.update("orders", "2024-06-01T00:00:00Z").unwrap();
254        s.update("orders", "2024-06-01T00:00:00Z").unwrap();
255        assert_eq!(
256            s.get("orders").unwrap().last_cursor_value.as_deref(),
257            Some("2024-06-01T00:00:00Z")
258        );
259    }
260
261    /// Microsecond/nanosecond precision must not be rounded or truncated on
262    /// round-trip — otherwise the pipeline's strict-greater-than boundary
263    /// check would re-export rows on the microsecond edge.
264    #[test]
265    fn high_precision_timestamp_is_preserved_byte_for_byte() {
266        let s = store();
267        let ts = "2024-06-01T12:34:56.123456789+02:00";
268        s.update("events", ts).unwrap();
269        assert_eq!(
270            s.get("events").unwrap().last_cursor_value.as_deref(),
271            Some(ts)
272        );
273    }
274
275    /// Cursor values can be arbitrary UTF-8: UUID v7, version tokens,
276    /// Cyrillic names, multiline strings, the empty string.
277    #[test]
278    fn unicode_and_binary_like_cursor_values_round_trip() {
279        let s = store();
280        let values = [
281            "2024-06-01",
282            "018f1c0b-7a34-7b54-8e16-1c5a9b3f1c2d", // UUID v7
283            "ελληνικά 🚀 cursor",
284            "v\n\t with whitespace",
285            "",
286        ];
287        for v in values {
288            s.update("t", v).unwrap();
289            assert_eq!(
290                s.get("t").unwrap().last_cursor_value.as_deref(),
291                Some(v),
292                "cursor value {v:?} must round-trip exactly"
293            );
294        }
295    }
296
297    /// Resume-from-zero tooling depends on `reset` producing a state
298    /// indistinguishable from "never ran": both cursor and last_run_at gone.
299    #[test]
300    fn reset_clears_cursor_state_completely() {
301        let s = store();
302        s.update("orders", "2024-06-01").unwrap();
303        s.reset("orders").unwrap();
304        let after = s.get("orders").unwrap();
305        assert!(after.last_cursor_value.is_none());
306        assert!(
307            after.last_run_at.is_none(),
308            "reset must clear last_run_at as well"
309        );
310    }
311
312    /// #22 (0.9.x audit): `reset` left `export_progression` behind, so
313    /// `rivet state progression` reported a stale committed boundary after
314    /// `state show` was already empty. Reset must clear progression too.
315    #[test]
316    fn reset_clears_committed_progression() {
317        let s = store();
318        s.update("orders", "100").unwrap();
319        s.record_committed_incremental("orders", "100", "run-1")
320            .unwrap();
321        // Other exports' progression must survive — reset is per-export.
322        s.record_committed_incremental("users", "9", "run-u")
323            .unwrap();
324
325        s.reset("orders").unwrap();
326
327        let p = s.get_progression("orders").unwrap();
328        assert!(
329            p.committed.is_none() && p.verified.is_none(),
330            "reset must clear the export's committed/verified boundary"
331        );
332        assert!(
333            s.get_progression("users").unwrap().committed.is_some(),
334            "reset must not touch another export's progression"
335        );
336    }
337}