1use std::collections::HashSet;
2
3use crate::error::Result;
4
5use super::{StateConn, StateStore};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct LoadRecord {
12 pub load_id: String,
13 pub export_name: String,
14 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 pub fn store_load(&self, rec: &LoadRecord) -> Result<()> {
35 let run_ids_json = serde_json::to_string(&rec.source_run_ids)?;
36 let mark_loaded = rec.status == "success";
40 match &self.conn {
41 StateConn::Sqlite(c) => {
42 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 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 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 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 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 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 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(); 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 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}