1use crate::error::Result;
16
17use super::{StateRef, StateStore, open_connection, pg_sql};
18
19#[derive(Debug, Clone)]
21pub struct KeysetRangeRow {
22 pub range_index: i64,
23 pub lo: Option<String>,
25 pub hi: Option<String>,
27 pub done: bool,
28}
29
30pub struct KeysetRangePart {
33 pub file_name: String,
34 pub rows: i64,
35 pub bytes: i64,
36}
37
38impl StateStore {
39 pub fn persist_keyset_ranges(
43 &self,
44 export_name: &str,
45 run_id: &str,
46 ranges: &[(Option<String>, Option<String>)],
47 ) -> Result<()> {
48 let now = chrono::Utc::now().to_rfc3339();
49 self.execute(
50 "DELETE FROM keyset_range WHERE export_name = ?1",
51 &[export_name.into()],
52 )?;
53 for (idx, (lo, hi)) in ranges.iter().enumerate() {
54 self.execute(
55 "INSERT INTO keyset_range \
56 (export_name, run_id, range_index, lo, hi, done, updated_at) \
57 VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6)",
58 &[
59 export_name.into(),
60 run_id.into(),
61 (idx as i64).into(),
62 lo.clone().into(),
63 hi.clone().into(),
64 now.as_str().into(),
65 ],
66 )?;
67 }
68 Ok(())
69 }
70
71 pub fn load_keyset_ranges(
74 &self,
75 export_name: &str,
76 run_id: &str,
77 ) -> Result<Vec<KeysetRangeRow>> {
78 let sql = "SELECT range_index, lo, hi, done FROM keyset_range \
79 WHERE export_name = ?1 AND run_id = ?2 ORDER BY range_index";
80 self.query(sql, &[export_name.into(), run_id.into()], |r| {
81 KeysetRangeRow {
82 range_index: r.i64(0),
83 lo: r.opt_text(1),
84 hi: r.opt_text(2),
85 done: r.i64(3) != 0,
86 }
87 })
88 }
89
90 pub fn clear_keyset_ranges(&self, export_name: &str) -> Result<()> {
94 self.execute(
95 "DELETE FROM keyset_range WHERE export_name = ?1",
96 &[export_name.into()],
97 )?;
98 Ok(())
99 }
100
101 pub fn commit_keyset_range_at_ref(
110 state_ref: &StateRef,
111 run_id: &str,
112 export_name: &str,
113 range_index: i64,
114 parts: &[KeysetRangePart],
115 format: &str,
116 compression: Option<&str>,
117 ) -> Result<()> {
118 let now = chrono::Utc::now().to_rfc3339();
119 let file_sql = "INSERT INTO file_log \
120 (run_id, export_name, file_name, row_count, bytes, format, compression, created_at) \
121 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)";
122 let done_sql = "UPDATE keyset_range SET done = 1, updated_at = ?1 \
126 WHERE export_name = ?2 AND range_index = ?3 AND run_id = ?4";
127 match state_ref {
128 StateRef::Sqlite(db_path) => {
129 let mut conn = open_connection(db_path)?;
130 let tx = conn.transaction()?;
131 for p in parts {
132 tx.execute(
133 file_sql,
134 rusqlite::params![
135 run_id,
136 export_name,
137 p.file_name,
138 p.rows,
139 p.bytes,
140 format,
141 compression,
142 now
143 ],
144 )?;
145 }
146 tx.execute(
147 done_sql,
148 rusqlite::params![now, export_name, range_index, run_id],
149 )?;
150 tx.commit()?;
151 }
152 StateRef::Postgres(url) => {
153 let mut client = super::connect_pg(url)?;
154 let mut tx = client.transaction()?;
155 for p in parts {
156 tx.execute(
157 &pg_sql(file_sql),
158 &[
159 &run_id,
160 &export_name,
161 &p.file_name,
162 &p.rows,
163 &p.bytes,
164 &format,
165 &compression,
166 &now,
167 ],
168 )?;
169 }
170 tx.execute(
171 &pg_sql(done_sql),
172 &[&now, &export_name, &range_index, &run_id],
173 )?;
174 tx.commit()?;
175 }
176 }
177 Ok(())
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 fn store() -> StateStore {
186 StateStore::open_in_memory().expect("in-memory store")
187 }
188
189 #[test]
190 fn persist_then_load_round_trips_ranges_all_not_done() {
191 let s = store();
192 let ranges = vec![
193 (None, Some("k0500".to_string())),
194 (Some("k0500".to_string()), Some("k1000".to_string())),
195 (Some("k1000".to_string()), None),
196 ];
197 s.persist_keyset_ranges("exp", "run-1", &ranges).unwrap();
198 let loaded = s.load_keyset_ranges("exp", "run-1").unwrap();
199 assert_eq!(loaded.len(), 3);
200 assert_eq!(loaded[0].lo, None);
201 assert_eq!(loaded[0].hi.as_deref(), Some("k0500"));
202 assert_eq!(loaded[2].hi, None);
203 assert!(loaded.iter().all(|r| !r.done), "fresh ranges are not done");
204 }
205
206 #[test]
207 fn load_with_a_different_run_id_returns_nothing() {
208 let s = store();
210 s.persist_keyset_ranges("exp", "run-1", &[(None, None)])
211 .unwrap();
212 assert!(s.load_keyset_ranges("exp", "run-2").unwrap().is_empty());
213 }
214
215 #[test]
216 fn commit_range_marks_only_its_own_row_done_and_records_parts() {
217 let dir = tempfile::tempdir().unwrap();
220 let cfg = dir.path().join("rivet.yaml");
221 std::fs::write(&cfg, "# test").unwrap();
222 let s = StateStore::open(cfg.to_str().unwrap()).unwrap();
223 let ranges = vec![
224 (None, Some("k5".to_string())),
225 (Some("k5".to_string()), None),
226 ];
227 s.persist_keyset_ranges("exp", "run-1", &ranges).unwrap();
228
229 StateStore::commit_keyset_range_at_ref(
230 s.state_ref(),
231 "run-1",
232 "exp",
233 1,
234 &[KeysetRangePart {
235 file_name: "exp_run-1_pk_w1_0.parquet".to_string(),
236 rows: 42,
237 bytes: 100,
238 }],
239 "parquet",
240 Some("zstd"),
241 )
242 .unwrap();
243
244 let loaded = s.load_keyset_ranges("exp", "run-1").unwrap();
245 assert!(!loaded[0].done, "range 0 untouched");
246 assert!(loaded[1].done, "range 1 committed → done");
247 let files = s.list_files_for_run("run-1").unwrap();
249 assert_eq!(files.len(), 1);
250 assert_eq!(files[0].file_name, "exp_run-1_pk_w1_0.parquet");
251 assert_eq!(files[0].row_count, 42);
252 }
253
254 #[test]
255 fn clear_removes_all_ranges_for_the_export() {
256 let s = store();
257 s.persist_keyset_ranges("exp", "run-1", &[(None, None)])
258 .unwrap();
259 s.clear_keyset_ranges("exp").unwrap();
260 assert!(s.load_keyset_ranges("exp", "run-1").unwrap().is_empty());
261 }
262}