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        match &self.conn {
130            StateConn::Sqlite(c) => {
131                let mut stmt = c.prepare(
132                    "SELECT source_run_id FROM loaded_source_run WHERE target_table = ?1",
133                )?;
134                let rows =
135                    stmt.query_map(rusqlite::params![target_table], |r| r.get::<_, String>(0))?;
136                let mut out = HashSet::new();
137                for r in rows {
138                    out.insert(r?);
139                }
140                Ok(out)
141            }
142            StateConn::Postgres(client) => {
143                let mut c = client.borrow_mut();
144                let rows = c.query(
145                    "SELECT source_run_id FROM loaded_source_run WHERE target_table = $1",
146                    &[&target_table],
147                )?;
148                Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect())
149            }
150        }
151    }
152
153    /// Recent `load_run` rows, newest first; optionally filtered to one target.
154    pub fn recent_loads(
155        &self,
156        target_table: Option<&str>,
157        limit: usize,
158    ) -> Result<Vec<LoadRecord>> {
159        const COLS: &str = "load_id, export_name, target_table, warehouse, mode, \
160                            source_run_ids, rows_loaded, status, finished_at";
161        // (load_id, export, target, warehouse, mode, run_ids_json, rows, status, finished_at)
162        type Raw = (
163            String,
164            String,
165            String,
166            String,
167            String,
168            String,
169            i64,
170            String,
171            String,
172        );
173        let build = |r: Raw| LoadRecord {
174            load_id: r.0,
175            export_name: r.1,
176            target_table: r.2,
177            warehouse: r.3,
178            mode: r.4,
179            source_run_ids: serde_json::from_str(&r.5).unwrap_or_default(),
180            rows_loaded: r.6,
181            status: r.7,
182            finished_at: r.8,
183        };
184        match &self.conn {
185            StateConn::Sqlite(c) => {
186                let map = |row: &rusqlite::Row| -> rusqlite::Result<Raw> {
187                    Ok((
188                        row.get(0)?,
189                        row.get(1)?,
190                        row.get(2)?,
191                        row.get(3)?,
192                        row.get(4)?,
193                        row.get(5)?,
194                        row.get(6)?,
195                        row.get(7)?,
196                        row.get(8)?,
197                    ))
198                };
199                let mut out = Vec::new();
200                match target_table {
201                    Some(t) => {
202                        let mut stmt = c.prepare(&format!(
203                            "SELECT {COLS} FROM load_run WHERE target_table = ?1 \
204                             ORDER BY finished_at DESC LIMIT ?2"
205                        ))?;
206                        for r in stmt.query_map(rusqlite::params![t, limit as i64], map)? {
207                            out.push(build(r?));
208                        }
209                    }
210                    None => {
211                        let mut stmt = c.prepare(&format!(
212                            "SELECT {COLS} FROM load_run ORDER BY finished_at DESC LIMIT ?1"
213                        ))?;
214                        for r in stmt.query_map(rusqlite::params![limit as i64], map)? {
215                            out.push(build(r?));
216                        }
217                    }
218                }
219                Ok(out)
220            }
221            StateConn::Postgres(client) => {
222                let mut c = client.borrow_mut();
223                let rows = match target_table {
224                    Some(t) => c.query(
225                        &format!(
226                            "SELECT {COLS} FROM load_run WHERE target_table = $1 \
227                             ORDER BY finished_at DESC LIMIT {limit}"
228                        ),
229                        &[&t],
230                    )?,
231                    None => c.query(
232                        &format!(
233                            "SELECT {COLS} FROM load_run ORDER BY finished_at DESC LIMIT {limit}"
234                        ),
235                        &[],
236                    )?,
237                };
238                Ok(rows
239                    .iter()
240                    .map(|row| {
241                        build((
242                            row.get(0),
243                            row.get(1),
244                            row.get(2),
245                            row.get(3),
246                            row.get(4),
247                            row.get(5),
248                            row.get(6),
249                            row.get(7),
250                            row.get(8),
251                        ))
252                    })
253                    .collect())
254            }
255        }
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn rec(load_id: &str, target: &str, runs: &[&str], rows: i64, status: &str) -> LoadRecord {
264        LoadRecord {
265            load_id: load_id.into(),
266            export_name: "customers".into(),
267            target_table: target.into(),
268            warehouse: "bigquery".into(),
269            mode: "cdc".into(),
270            source_run_ids: runs.iter().map(|s| s.to_string()).collect(),
271            rows_loaded: rows,
272            status: status.into(),
273            finished_at: format!("2026-01-01T00:00:0{}Z", load_id.len() % 10),
274        }
275    }
276
277    #[test]
278    fn store_load_records_run_and_marks_source_runs() {
279        let s = StateStore::open_in_memory().unwrap();
280        s.store_load(&rec("L1", "p.d.customers", &["r1", "r2"], 100, "success"))
281            .unwrap();
282
283        let loaded = s.loaded_source_run_ids("p.d.customers").unwrap();
284        assert_eq!(loaded, HashSet::from(["r1".to_string(), "r2".to_string()]));
285        // A different target has its own (empty) skip set.
286        assert!(s.loaded_source_run_ids("p.d.other").unwrap().is_empty());
287
288        let loads = s.recent_loads(Some("p.d.customers"), 10).unwrap();
289        assert_eq!(loads.len(), 1);
290        assert_eq!(loads[0].rows_loaded, 100);
291        assert_eq!(loads[0].source_run_ids, vec!["r1", "r2"]);
292    }
293
294    #[test]
295    fn failed_load_leaves_its_source_runs_retryable() {
296        let s = StateStore::open_in_memory().unwrap();
297        // A failed load records its audit row (with the attempted runs) but must
298        // NOT mark them loaded — else the next load skips their never-loaded data.
299        s.store_load(&rec("L1", "p.d.t", &["r1", "r2"], 0, "failed"))
300            .unwrap();
301        assert!(
302            s.loaded_source_run_ids("p.d.t").unwrap().is_empty(),
303            "a failed load must leave its runs retryable, never mark them loaded"
304        );
305        let loads = s.recent_loads(Some("p.d.t"), 10).unwrap();
306        assert_eq!(loads.len(), 1);
307        assert_eq!(loads[0].status, "failed");
308        assert_eq!(
309            loads[0].source_run_ids,
310            vec!["r1", "r2"],
311            "the audit row still records what the failed load attempted"
312        );
313
314        // A later SUCCESS over the same runs marks them loaded (the retry landed).
315        s.store_load(&rec("L2", "p.d.t", &["r1", "r2"], 100, "success"))
316            .unwrap();
317        assert_eq!(
318            s.loaded_source_run_ids("p.d.t").unwrap(),
319            HashSet::from(["r1".to_string(), "r2".to_string()])
320        );
321    }
322
323    #[test]
324    fn store_load_is_idempotent_on_load_id_and_source_run() {
325        let s = StateStore::open_in_memory().unwrap();
326        let r = rec("L1", "p.d.t", &["r1"], 10, "success");
327        s.store_load(&r).unwrap();
328        s.store_load(&r).unwrap(); // replay
329        assert_eq!(s.recent_loads(None, 10).unwrap().len(), 1);
330        assert_eq!(s.loaded_source_run_ids("p.d.t").unwrap().len(), 1);
331    }
332
333    #[test]
334    fn recent_loads_filters_by_target_and_orders_newest_first() {
335        let s = StateStore::open_in_memory().unwrap();
336        s.store_load(&rec("La", "p.d.a", &["r1"], 1, "success"))
337            .unwrap();
338        s.store_load(&rec("Lbb", "p.d.b", &["r2"], 2, "success"))
339            .unwrap();
340        s.store_load(&rec("Lccc", "p.d.b", &["r3"], 3, "failed"))
341            .unwrap();
342
343        let all = s.recent_loads(None, 10).unwrap();
344        assert_eq!(all.len(), 3);
345        // finished_at is keyed off load_id length in the fixture (3 > 2 > …).
346        assert_eq!(all[0].load_id, "Lccc");
347
348        let only_b = s.recent_loads(Some("p.d.b"), 10).unwrap();
349        assert_eq!(only_b.len(), 2);
350        assert!(only_b.iter().all(|l| l.target_table == "p.d.b"));
351    }
352}