1use chrono::{DateTime, Utc};
4
5use super::StateStore;
6use crate::error::Result;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ExportProgression {
11 pub export_name: String,
12 pub committed: Option<Boundary>,
13 pub verified: Option<Boundary>,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Boundary {
19 pub strategy: String,
20 pub run_id: Option<String>,
21 pub cursor: Option<String>,
22 pub chunk_index: Option<i64>,
23 pub at: DateTime<Utc>,
24}
25
26impl StateStore {
27 pub fn record_committed_incremental(
38 &self,
39 export_name: &str,
40 cursor: &str,
41 run_id: &str,
42 ) -> Result<()> {
43 if let Some(stored) = self.committed_cursor(export_name)?
44 && !cursor_advances(&stored, cursor)
45 {
46 return Ok(());
47 }
48 let now = Utc::now().to_rfc3339();
49 let sql = "INSERT INTO export_progression (
50 export_name,
51 last_committed_strategy, last_committed_cursor, last_committed_chunk_index,
52 last_committed_run_id, last_committed_at
53 ) VALUES (?1, 'incremental', ?2, NULL, ?3, ?4)
54 ON CONFLICT(export_name) DO UPDATE SET
55 last_committed_strategy = 'incremental',
56 last_committed_cursor = excluded.last_committed_cursor,
57 last_committed_chunk_index = NULL,
58 last_committed_run_id = excluded.last_committed_run_id,
59 last_committed_at = excluded.last_committed_at";
60 self.execute(
61 sql,
62 &[export_name.into(), cursor.into(), run_id.into(), now.into()],
63 )?;
64 Ok(())
65 }
66
67 fn committed_cursor(&self, export_name: &str) -> Result<Option<String>> {
70 let sql = "SELECT last_committed_cursor FROM export_progression WHERE export_name = ?1";
71 Ok(self
73 .query_opt(sql, &[export_name.into()], |r| r.opt_text(0))?
74 .flatten())
75 }
76
77 pub fn record_committed_chunked(
79 &self,
80 export_name: &str,
81 highest_chunk_index: i64,
82 run_id: &str,
83 ) -> Result<()> {
84 let now = Utc::now().to_rfc3339();
85 let sql = "INSERT INTO export_progression (
86 export_name,
87 last_committed_strategy, last_committed_cursor, last_committed_chunk_index,
88 last_committed_run_id, last_committed_at
89 ) VALUES (?1, 'chunked', NULL, ?2, ?3, ?4)
90 ON CONFLICT(export_name) DO UPDATE SET
91 last_committed_strategy = 'chunked',
92 last_committed_cursor = NULL,
93 last_committed_chunk_index = excluded.last_committed_chunk_index,
94 last_committed_run_id = excluded.last_committed_run_id,
95 last_committed_at = excluded.last_committed_at";
96 self.execute(
97 sql,
98 &[
99 export_name.into(),
100 highest_chunk_index.into(),
101 run_id.into(),
102 now.into(),
103 ],
104 )?;
105 Ok(())
106 }
107
108 pub fn record_verified_chunked(
110 &self,
111 export_name: &str,
112 highest_chunk_index: i64,
113 run_id: &str,
114 ) -> Result<()> {
115 let now = Utc::now().to_rfc3339();
116 let sql = "INSERT INTO export_progression (
117 export_name,
118 last_verified_strategy, last_verified_cursor, last_verified_chunk_index,
119 last_verified_run_id, last_verified_at
120 ) VALUES (?1, 'chunked', NULL, ?2, ?3, ?4)
121 ON CONFLICT(export_name) DO UPDATE SET
122 last_verified_strategy = 'chunked',
123 last_verified_cursor = NULL,
124 last_verified_chunk_index = excluded.last_verified_chunk_index,
125 last_verified_run_id = excluded.last_verified_run_id,
126 last_verified_at = excluded.last_verified_at";
127 self.execute(
128 sql,
129 &[
130 export_name.into(),
131 highest_chunk_index.into(),
132 run_id.into(),
133 now.into(),
134 ],
135 )?;
136 Ok(())
137 }
138
139 pub fn get_progression(&self, export_name: &str) -> Result<ExportProgression> {
140 let sql = "SELECT
141 last_committed_strategy, last_committed_cursor, last_committed_chunk_index,
142 last_committed_run_id, last_committed_at,
143 last_verified_strategy, last_verified_cursor, last_verified_chunk_index,
144 last_verified_run_id, last_verified_at
145 FROM export_progression WHERE export_name = ?1";
146 Ok(self
147 .query_opt(sql, &[export_name.into()], |r| ExportProgression {
148 export_name: export_name.to_string(),
149 committed: boundary_from_row(
150 r.opt_text(0),
151 r.opt_text(1),
152 r.opt_i64(2),
153 r.opt_text(3),
154 r.opt_text(4),
155 ),
156 verified: boundary_from_row(
157 r.opt_text(5),
158 r.opt_text(6),
159 r.opt_i64(7),
160 r.opt_text(8),
161 r.opt_text(9),
162 ),
163 })?
164 .unwrap_or_else(|| ExportProgression {
165 export_name: export_name.to_string(),
166 committed: None,
167 verified: None,
168 }))
169 }
170
171 pub fn delete_progression(&self, export_name: &str) -> Result<usize> {
181 self.execute(
182 "DELETE FROM export_progression WHERE export_name = ?1",
183 &[export_name.into()],
184 )
185 }
186
187 pub fn list_progression(&self) -> Result<Vec<ExportProgression>> {
188 let sql = "SELECT export_name,
191 last_committed_strategy, last_committed_cursor, last_committed_chunk_index,
192 last_committed_run_id, last_committed_at,
193 last_verified_strategy, last_verified_cursor, last_verified_chunk_index,
194 last_verified_run_id, last_verified_at
195 FROM export_progression ORDER BY export_name";
196 self.query(sql, &[], |r| ExportProgression {
197 export_name: r.text(0),
198 committed: boundary_from_row(
199 r.opt_text(1),
200 r.opt_text(2),
201 r.opt_i64(3),
202 r.opt_text(4),
203 r.opt_text(5),
204 ),
205 verified: boundary_from_row(
206 r.opt_text(6),
207 r.opt_text(7),
208 r.opt_i64(8),
209 r.opt_text(9),
210 r.opt_text(10),
211 ),
212 })
213 }
214}
215
216fn cursor_advances(stored: &str, new: &str) -> bool {
225 if let (Ok(a), Ok(b)) = (stored.parse::<i128>(), new.parse::<i128>()) {
226 return b > a;
227 }
228 if let (Ok(a), Ok(b)) = (stored.parse::<f64>(), new.parse::<f64>())
229 && let Some(ord) = b.partial_cmp(&a)
230 {
231 return ord.is_gt();
232 }
233 new > stored
234}
235
236fn boundary_from_row(
237 strategy: Option<String>,
238 cursor: Option<String>,
239 chunk_index: Option<i64>,
240 run_id: Option<String>,
241 at: Option<String>,
242) -> Option<Boundary> {
243 let strategy = strategy?;
244 let at = at
245 .as_deref()
246 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
247 .map(|dt| dt.with_timezone(&Utc))?;
248 Some(Boundary {
249 strategy,
250 run_id,
251 cursor,
252 chunk_index,
253 at,
254 })
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 fn store() -> StateStore {
262 StateStore::open_in_memory().expect("in-memory store")
263 }
264
265 #[test]
266 fn progression_unknown_export_returns_empty() {
267 let s = store();
268 let p = s.get_progression("orders").unwrap();
269 assert!(p.committed.is_none());
270 assert!(p.verified.is_none());
271 }
272
273 #[test]
274 fn committed_incremental_records_cursor_and_run() {
275 let s = store();
276 s.record_committed_incremental("orders", "2024-06-01", "run-1")
277 .unwrap();
278 let b = s.get_progression("orders").unwrap().committed.unwrap();
279 assert_eq!(b.strategy, "incremental");
280 assert_eq!(b.cursor.as_deref(), Some("2024-06-01"));
281 assert_eq!(b.chunk_index, None);
282 assert_eq!(b.run_id.as_deref(), Some("run-1"));
283 }
284
285 #[test]
286 fn committed_cursor_does_not_regress_lexicographically() {
287 let s = store();
288 s.record_committed_incremental("orders", "2024-06-10", "run-10")
289 .unwrap();
290 s.record_committed_incremental("orders", "2024-01-01", "run-01")
291 .unwrap();
292 let b = s.get_progression("orders").unwrap().committed.unwrap();
293 assert_eq!(b.cursor.as_deref(), Some("2024-06-10"));
294 }
295
296 #[test]
297 fn committed_chunked_records_chunk_index() {
298 let s = store();
299 s.record_committed_chunked("orders", 41, "run-A").unwrap();
300 let b = s.get_progression("orders").unwrap().committed.unwrap();
301 assert_eq!(b.strategy, "chunked");
302 assert_eq!(b.chunk_index, Some(41));
303 assert_eq!(b.cursor, None);
304 }
305
306 #[test]
307 fn verified_and_committed_are_independent() {
308 let s = store();
309 s.record_committed_chunked("orders", 10, "run-A").unwrap();
310 s.record_verified_chunked("orders", 5, "run-A").unwrap();
311 let p = s.get_progression("orders").unwrap();
312 assert_eq!(p.committed.as_ref().unwrap().chunk_index, Some(10));
313 assert_eq!(p.verified.as_ref().unwrap().chunk_index, Some(5));
314 }
315
316 #[test]
317 fn switching_strategy_updates_committed_row() {
318 let s = store();
319 s.record_committed_incremental("orders", "2024-01-01", "inc-1")
320 .unwrap();
321 s.record_committed_chunked("orders", 7, "chunk-1").unwrap();
322 let b = s.get_progression("orders").unwrap().committed.unwrap();
323 assert_eq!(b.strategy, "chunked");
324 assert_eq!(b.chunk_index, Some(7));
325 assert_eq!(b.cursor, None);
326 }
327
328 #[test]
333 fn roast_committed_numeric_cursor_advances_past_lexicographic_boundary() {
334 let s = store();
335 s.record_committed_incremental("orders", "999", "run-999")
336 .unwrap();
337 s.record_committed_incremental("orders", "1000", "run-1000")
338 .unwrap();
339 let b = s.get_progression("orders").unwrap().committed.unwrap();
340 assert_eq!(
341 b.cursor.as_deref(),
342 Some("1000"),
343 "numeric cursor must advance from 999 to 1000, but the lexicographic \
344 TEXT comparison froze the committed boundary at {:?}",
345 b.cursor
346 );
347 }
348
349 #[test]
350 fn committed_numeric_cursor_does_not_regress() {
351 let s = store();
352 s.record_committed_incremental("orders", "1000", "run-1000")
353 .unwrap();
354 s.record_committed_incremental("orders", "999", "run-999")
355 .unwrap();
356 let b = s.get_progression("orders").unwrap().committed.unwrap();
357 assert_eq!(b.cursor.as_deref(), Some("1000"));
358 assert_eq!(
359 b.run_id.as_deref(),
360 Some("run-1000"),
361 "non-advancing commit must leave the boundary row untouched"
362 );
363 }
364
365 #[test]
366 fn committed_float_cursor_advances_across_integer_boundary() {
367 let s = store();
371 s.record_committed_incremental("scores", "9.9", "run-1")
372 .unwrap();
373 s.record_committed_incremental("scores", "10", "run-2")
374 .unwrap();
375 let b = s.get_progression("scores").unwrap().committed.unwrap();
376 assert_eq!(b.cursor.as_deref(), Some("10"));
377 s.record_committed_incremental("scores", "9.95", "run-3")
378 .unwrap();
379 let b = s.get_progression("scores").unwrap().committed.unwrap();
380 assert_eq!(b.cursor.as_deref(), Some("10"), "9.95 must not regress 10");
381 }
382
383 #[test]
384 fn committed_equal_cursor_is_a_no_op() {
385 let s = store();
386 s.record_committed_incremental("orders", "100", "run-1")
387 .unwrap();
388 s.record_committed_incremental("orders", "100", "run-2")
389 .unwrap();
390 let b = s.get_progression("orders").unwrap().committed.unwrap();
391 assert_eq!(b.cursor.as_deref(), Some("100"));
392 assert_eq!(
393 b.run_id.as_deref(),
394 Some("run-1"),
395 "an equal cursor does not advance; the row must stay untouched"
396 );
397 }
398
399 #[test]
400 fn committed_rfc3339_cursor_advances_and_does_not_regress() {
401 let s = store();
402 s.record_committed_incremental("orders", "2024-06-01T00:00:00Z", "run-1")
403 .unwrap();
404 s.record_committed_incremental("orders", "2024-06-02T00:00:00Z", "run-2")
405 .unwrap();
406 let b = s.get_progression("orders").unwrap().committed.unwrap();
407 assert_eq!(b.cursor.as_deref(), Some("2024-06-02T00:00:00Z"));
408 s.record_committed_incremental("orders", "2024-05-31T00:00:00Z", "run-3")
409 .unwrap();
410 let b = s.get_progression("orders").unwrap().committed.unwrap();
411 assert_eq!(b.cursor.as_deref(), Some("2024-06-02T00:00:00Z"));
412 }
413
414 #[test]
415 fn committed_mixed_kind_cursor_falls_back_to_string_order() {
416 let s = store();
420 s.record_committed_incremental("orders", "abc", "run-1")
421 .unwrap();
422 s.record_committed_incremental("orders", "123", "run-2")
423 .unwrap();
424 let b = s.get_progression("orders").unwrap().committed.unwrap();
425 assert_eq!(b.cursor.as_deref(), Some("abc"));
426 }
427
428 #[test]
429 fn committed_large_integer_cursor_compares_exactly() {
430 let s = store();
433 s.record_committed_incremental("orders", "9007199254740992", "run-1")
434 .unwrap();
435 s.record_committed_incremental("orders", "9007199254740993", "run-2")
436 .unwrap();
437 let b = s.get_progression("orders").unwrap().committed.unwrap();
438 assert_eq!(b.cursor.as_deref(), Some("9007199254740993"));
439 }
440
441 #[test]
442 fn switching_chunked_to_incremental_writes_cursor() {
443 let s = store();
446 s.record_committed_chunked("orders", 7, "chunk-1").unwrap();
447 s.record_committed_incremental("orders", "100", "inc-1")
448 .unwrap();
449 let b = s.get_progression("orders").unwrap().committed.unwrap();
450 assert_eq!(b.strategy, "incremental");
451 assert_eq!(b.cursor.as_deref(), Some("100"));
452 assert_eq!(b.chunk_index, None);
453 }
454
455 #[test]
456 fn cursor_advances_orders_numbers_strings_and_nan() {
457 assert!(cursor_advances("999", "1000"));
458 assert!(!cursor_advances("1000", "999"));
459 assert!(!cursor_advances("100", "100"));
460 assert!(cursor_advances("9.9", "10"));
461 assert!(cursor_advances("-5", "-4"));
462 assert!(cursor_advances("2024-01-01", "2024-06-10"));
463 assert!(cursor_advances("NaN", "inf"));
466 assert!(!cursor_advances("inf", "NaN"));
467 }
468
469 #[test]
470 fn delete_progression_removes_only_the_named_export() {
471 let s = store();
472 s.record_committed_incremental("orders", "100", "run-o")
473 .unwrap();
474 s.record_committed_incremental("users", "9", "run-u")
475 .unwrap();
476
477 assert_eq!(
478 s.delete_progression("orders").unwrap(),
479 1,
480 "deleting an existing progression row reports one row removed"
481 );
482 assert!(s.get_progression("orders").unwrap().committed.is_none());
483 assert!(
484 s.get_progression("users").unwrap().committed.is_some(),
485 "delete must be scoped to the named export"
486 );
487 assert_eq!(
488 s.delete_progression("orders").unwrap(),
489 0,
490 "deleting an absent progression row is a no-op (zero rows)"
491 );
492 }
493
494 #[test]
495 fn list_progression_sorted_by_name() {
496 let s = store();
497 s.record_committed_incremental("gamma", "3", "r").unwrap();
498 s.record_committed_incremental("alpha", "1", "r").unwrap();
499 s.record_committed_incremental("beta", "2", "r").unwrap();
500 let all = s.list_progression().unwrap();
501 let names: Vec<_> = all.iter().map(|p| p.export_name.as_str()).collect();
502 assert_eq!(names, vec!["alpha", "beta", "gamma"]);
503 }
504}