Skip to main content

rivet/state/
load_journal_store.rs

1use std::collections::HashSet;
2
3use crate::error::Result;
4
5use super::{StateConn, StateStore};
6
7/// One `rivet load` invocation's ledger record: the `load_run` audit row plus
8/// the extraction `source_run_ids` it consumed (written into `loaded_source_run`
9/// so a later load skips them). `status` ∈ `success` | `failed`.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct LoadRecord {
12    pub load_id: String,
13    pub export_name: String,
14    /// Fully-qualified target the rows landed in (`proj.ds.table` /
15    /// `db.schema.table`) — the ledger key, so two configs targeting different
16    /// datasets never share a skip set.
17    pub target_table: String,
18    pub warehouse: String,
19    pub mode: String,
20    pub source_run_ids: Vec<String>,
21    pub rows_loaded: i64,
22    pub status: String,
23    pub finished_at: String,
24}
25
26impl StateStore {
27    /// Log one load into `load_run` and — only for a **successful** load — mark
28    /// each consumed extraction run in `loaded_source_run` (the skip set). A
29    /// FAILED load still records its audit row with the attempted run_ids but
30    /// marks none loaded, so the next load RETRIES those runs instead of
31    /// skipping their never-loaded data forever (silent loss). Idempotent:
32    /// `load_id` and `(target_table, source_run_id)` upsert, so a retried load
33    /// never double-inserts.
34    pub fn store_load(&self, rec: &LoadRecord) -> Result<()> {
35        let run_ids_json = serde_json::to_string(&rec.source_run_ids)?;
36        // A run is "loaded" only when its load succeeded; a failed load must
37        // leave its runs retryable, never mark them loaded (else their data is
38        // skipped on every subsequent load).
39        let mark_loaded = rec.status == "success";
40        match &self.conn {
41            StateConn::Sqlite(c) => {
42                // One transaction: the audit row + the skip-set rows commit
43                // together, so a crash mid-write never leaves a `success` load_run
44                // with a PARTIAL loaded_source_run (which would re-select the
45                // unmarked runs next load).
46                let tx = c.unchecked_transaction()?;
47                tx.execute(
48                    "INSERT OR REPLACE INTO load_run
49                       (load_id, export_name, target_table, warehouse, mode,
50                        source_run_ids, rows_loaded, status, finished_at)
51                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
52                    rusqlite::params![
53                        rec.load_id,
54                        rec.export_name,
55                        rec.target_table,
56                        rec.warehouse,
57                        rec.mode,
58                        run_ids_json,
59                        rec.rows_loaded,
60                        rec.status,
61                        rec.finished_at,
62                    ],
63                )?;
64                if mark_loaded {
65                    for rid in &rec.source_run_ids {
66                        tx.execute(
67                            "INSERT OR REPLACE INTO loaded_source_run
68                               (target_table, source_run_id, load_id, loaded_at)
69                             VALUES (?1, ?2, ?3, ?4)",
70                            rusqlite::params![rec.target_table, rid, rec.load_id, rec.finished_at],
71                        )?;
72                    }
73                }
74                tx.commit()?;
75            }
76            StateConn::Postgres(client) => {
77                // One transaction (mirrors the SQLite arm): the audit row and the
78                // skip-set rows commit atomically.
79                let mut c = client.borrow_mut();
80                let mut tx = c.transaction()?;
81                tx.execute(
82                    "INSERT INTO load_run
83                       (load_id, export_name, target_table, warehouse, mode,
84                        source_run_ids, rows_loaded, status, finished_at)
85                     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
86                     ON CONFLICT (load_id) DO UPDATE SET
87                         export_name    = excluded.export_name,
88                         target_table   = excluded.target_table,
89                         warehouse      = excluded.warehouse,
90                         mode           = excluded.mode,
91                         source_run_ids = excluded.source_run_ids,
92                         rows_loaded    = excluded.rows_loaded,
93                         status         = excluded.status,
94                         finished_at    = excluded.finished_at",
95                    &[
96                        &rec.load_id,
97                        &rec.export_name,
98                        &rec.target_table,
99                        &rec.warehouse,
100                        &rec.mode,
101                        &run_ids_json,
102                        &rec.rows_loaded,
103                        &rec.status,
104                        &rec.finished_at,
105                    ],
106                )?;
107                if mark_loaded {
108                    for rid in &rec.source_run_ids {
109                        tx.execute(
110                            "INSERT INTO loaded_source_run
111                               (target_table, source_run_id, load_id, loaded_at)
112                             VALUES ($1, $2, $3, $4)
113                             ON CONFLICT (target_table, source_run_id) DO UPDATE SET
114                                 load_id   = excluded.load_id,
115                                 loaded_at = excluded.loaded_at",
116                            &[&rec.target_table, rid, &rec.load_id, &rec.finished_at],
117                        )?;
118                    }
119                }
120                tx.commit()?;
121            }
122        }
123        Ok(())
124    }
125
126    /// The extraction run_ids already loaded into `target_table` — the skip set
127    /// an incremental load filters its candidate manifests against.
128    pub fn loaded_source_run_ids(&self, target_table: &str) -> Result<HashSet<String>> {
129        Ok(self
130            .query(
131                "SELECT source_run_id FROM loaded_source_run WHERE target_table = ?1",
132                &[target_table.into()],
133                |r| r.text(0),
134            )?
135            .into_iter()
136            .collect())
137    }
138
139    /// Recent `load_run` rows, newest first; optionally filtered to one target.
140    pub fn recent_loads(
141        &self,
142        target_table: Option<&str>,
143        limit: usize,
144    ) -> Result<Vec<LoadRecord>> {
145        const COLS: &str = "load_id, export_name, target_table, warehouse, mode, \
146                            source_run_ids, rows_loaded, status, finished_at";
147        let build = |r: &dyn super::row::StateRow| LoadRecord {
148            load_id: r.text(0),
149            export_name: r.text(1),
150            target_table: r.text(2),
151            warehouse: r.text(3),
152            mode: r.text(4),
153            source_run_ids: serde_json::from_str(&r.text(5)).unwrap_or_default(),
154            rows_loaded: r.i64(6),
155            status: r.text(7),
156            finished_at: r.text(8),
157        };
158        match target_table {
159            Some(t) => self.query(
160                &format!(
161                    "SELECT {COLS} FROM load_run WHERE target_table = ?1 \
162                     ORDER BY finished_at DESC LIMIT ?2"
163                ),
164                &[t.into(), (limit as i64).into()],
165                build,
166            ),
167            None => self.query(
168                &format!("SELECT {COLS} FROM load_run ORDER BY finished_at DESC LIMIT ?1"),
169                &[(limit as i64).into()],
170                build,
171            ),
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn rec(load_id: &str, target: &str, runs: &[&str], rows: i64, status: &str) -> LoadRecord {
181        LoadRecord {
182            load_id: load_id.into(),
183            export_name: "customers".into(),
184            target_table: target.into(),
185            warehouse: "bigquery".into(),
186            mode: "cdc".into(),
187            source_run_ids: runs.iter().map(|s| s.to_string()).collect(),
188            rows_loaded: rows,
189            status: status.into(),
190            finished_at: format!("2026-01-01T00:00:0{}Z", load_id.len() % 10),
191        }
192    }
193
194    #[test]
195    fn store_load_records_run_and_marks_source_runs() {
196        let s = StateStore::open_in_memory().unwrap();
197        s.store_load(&rec("L1", "p.d.customers", &["r1", "r2"], 100, "success"))
198            .unwrap();
199
200        let loaded = s.loaded_source_run_ids("p.d.customers").unwrap();
201        assert_eq!(loaded, HashSet::from(["r1".to_string(), "r2".to_string()]));
202        // A different target has its own (empty) skip set.
203        assert!(s.loaded_source_run_ids("p.d.other").unwrap().is_empty());
204
205        let loads = s.recent_loads(Some("p.d.customers"), 10).unwrap();
206        assert_eq!(loads.len(), 1);
207        assert_eq!(loads[0].rows_loaded, 100);
208        assert_eq!(loads[0].source_run_ids, vec!["r1", "r2"]);
209    }
210
211    #[test]
212    fn failed_load_leaves_its_source_runs_retryable() {
213        let s = StateStore::open_in_memory().unwrap();
214        // A failed load records its audit row (with the attempted runs) but must
215        // NOT mark them loaded — else the next load skips their never-loaded data.
216        s.store_load(&rec("L1", "p.d.t", &["r1", "r2"], 0, "failed"))
217            .unwrap();
218        assert!(
219            s.loaded_source_run_ids("p.d.t").unwrap().is_empty(),
220            "a failed load must leave its runs retryable, never mark them loaded"
221        );
222        let loads = s.recent_loads(Some("p.d.t"), 10).unwrap();
223        assert_eq!(loads.len(), 1);
224        assert_eq!(loads[0].status, "failed");
225        assert_eq!(
226            loads[0].source_run_ids,
227            vec!["r1", "r2"],
228            "the audit row still records what the failed load attempted"
229        );
230
231        // A later SUCCESS over the same runs marks them loaded (the retry landed).
232        s.store_load(&rec("L2", "p.d.t", &["r1", "r2"], 100, "success"))
233            .unwrap();
234        assert_eq!(
235            s.loaded_source_run_ids("p.d.t").unwrap(),
236            HashSet::from(["r1".to_string(), "r2".to_string()])
237        );
238    }
239
240    #[test]
241    fn store_load_is_idempotent_on_load_id_and_source_run() {
242        let s = StateStore::open_in_memory().unwrap();
243        let r = rec("L1", "p.d.t", &["r1"], 10, "success");
244        s.store_load(&r).unwrap();
245        s.store_load(&r).unwrap(); // replay
246        assert_eq!(s.recent_loads(None, 10).unwrap().len(), 1);
247        assert_eq!(s.loaded_source_run_ids("p.d.t").unwrap().len(), 1);
248    }
249
250    #[test]
251    fn recent_loads_filters_by_target_and_orders_newest_first() {
252        let s = StateStore::open_in_memory().unwrap();
253        s.store_load(&rec("La", "p.d.a", &["r1"], 1, "success"))
254            .unwrap();
255        s.store_load(&rec("Lbb", "p.d.b", &["r2"], 2, "success"))
256            .unwrap();
257        s.store_load(&rec("Lccc", "p.d.b", &["r3"], 3, "failed"))
258            .unwrap();
259
260        let all = s.recent_loads(None, 10).unwrap();
261        assert_eq!(all.len(), 3);
262        // finished_at is keyed off load_id length in the fixture (3 > 2 > …).
263        assert_eq!(all[0].load_id, "Lccc");
264
265        let only_b = s.recent_loads(Some("p.d.b"), 10).unwrap();
266        assert_eq!(only_b.len(), 2);
267        assert!(only_b.iter().all(|l| l.target_table == "p.d.b"));
268    }
269}