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 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 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 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 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 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 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(); 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 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}