1use rusqlite::OptionalExtension;
2
3use crate::error::Result;
4
5use super::{StateConn, StateStore};
6
7impl StateStore {
27 pub fn begin_run(
31 &self,
32 run_id: &str,
33 export_name: &str,
34 prefix: &str,
35 started_at: &str,
36 ) -> Result<()> {
37 match &self.conn {
38 StateConn::Sqlite(c) => {
39 c.execute(
40 "INSERT INTO run_status
41 (run_id, export_name, prefix, status, started_at, finished_at)
42 VALUES (?1, ?2, ?3, 'running', ?4, NULL)
43 ON CONFLICT(run_id) DO UPDATE SET
44 export_name = excluded.export_name,
45 prefix = excluded.prefix,
46 status = 'running',
47 started_at = excluded.started_at,
48 finished_at = NULL",
49 rusqlite::params![run_id, export_name, prefix, started_at],
50 )?;
51 }
52 StateConn::Postgres(client) => {
53 client.borrow_mut().execute(
54 "INSERT INTO run_status
55 (run_id, export_name, prefix, status, started_at, finished_at)
56 VALUES ($1, $2, $3, 'running', $4, NULL)
57 ON CONFLICT (run_id) DO UPDATE SET
58 export_name = excluded.export_name,
59 prefix = excluded.prefix,
60 status = 'running',
61 started_at = excluded.started_at,
62 finished_at = NULL",
63 &[&run_id, &export_name, &prefix, &started_at],
64 )?;
65 }
66 }
67 Ok(())
68 }
69
70 pub fn finish_run(&self, run_id: &str, status: &str, finished_at: &str) -> Result<()> {
74 match &self.conn {
75 StateConn::Sqlite(c) => {
76 c.execute(
77 "UPDATE run_status SET status = ?2, finished_at = ?3 WHERE run_id = ?1",
78 rusqlite::params![run_id, status, finished_at],
79 )?;
80 }
81 StateConn::Postgres(client) => {
82 client.borrow_mut().execute(
83 "UPDATE run_status SET status = $2, finished_at = $3 WHERE run_id = $1",
84 &[&run_id, &status, &finished_at],
85 )?;
86 }
87 }
88 Ok(())
89 }
90
91 pub fn has_active_run_on_prefix(&self, prefix: &str) -> Result<bool> {
97 let sql = "SELECT 1 FROM run_status r
108 WHERE (rtrim(r.prefix, '/') = rtrim(?1, '/')
109 OR r.prefix LIKE rtrim(?1, '/') || '/%'
110 OR rtrim(?1, '/') LIKE rtrim(r.prefix, '/') || '/%')
111 AND r.status = 'running'
112 AND NOT EXISTS (
113 SELECT 1 FROM run_status r2
114 WHERE r2.export_name = r.export_name
115 AND r2.started_at > r.started_at)
116 LIMIT 1";
117 match &self.conn {
118 StateConn::Sqlite(c) => Ok(c
119 .query_row(sql, rusqlite::params![prefix], |_| Ok(()))
120 .optional()?
121 .is_some()),
122 StateConn::Postgres(client) => {
123 let pg_sql = sql.replace("?1", "$1");
124 Ok(client
125 .borrow_mut()
126 .query_opt(&pg_sql, &[&prefix])?
127 .is_some())
128 }
129 }
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 const P: &str = "gs://b/exports/orders/";
138
139 #[test]
140 fn begin_marks_active_then_finish_clears_it() {
141 let s = StateStore::open_in_memory().unwrap();
142 assert!(
143 !s.has_active_run_on_prefix(P).unwrap(),
144 "empty → not active"
145 );
146 s.begin_run("r1", "orders", P, "2026-01-01T00:00:00Z")
147 .unwrap();
148 assert!(
149 s.has_active_run_on_prefix(P).unwrap(),
150 "a running run makes the prefix active"
151 );
152 s.finish_run("r1", "success", "2026-01-01T00:01:00Z")
153 .unwrap();
154 assert!(
155 !s.has_active_run_on_prefix(P).unwrap(),
156 "a finished run leaves the prefix inactive"
157 );
158 }
159
160 #[test]
161 fn active_is_scoped_to_the_prefix() {
162 let s = StateStore::open_in_memory().unwrap();
163 s.begin_run("r1", "orders", P, "2026-01-01T00:00:00Z")
164 .unwrap();
165 assert!(
166 !s.has_active_run_on_prefix("gs://b/exports/users/").unwrap(),
167 "a run on a DIFFERENT prefix does not make this one active"
168 );
169 }
170
171 #[test]
172 fn a_superseded_running_row_no_longer_counts_as_active() {
173 let s = StateStore::open_in_memory().unwrap();
177 s.begin_run("r1", "orders", P, "2026-01-01T00:00:00Z")
178 .unwrap(); s.begin_run("r2", "orders", P, "2026-01-01T00:00:05Z")
180 .unwrap();
181 assert!(
182 s.has_active_run_on_prefix(P).unwrap(),
183 "r2 is running and newest → active"
184 );
185 s.finish_run("r2", "success", "2026-01-01T00:01:00Z")
186 .unwrap();
187 assert!(
188 !s.has_active_run_on_prefix(P).unwrap(),
189 "r1 is `running` but SUPERSEDED by the finished r2 → NOT active (no clock)"
190 );
191 }
192
193 #[test]
194 fn a_lone_crashed_running_row_still_counts_as_active() {
195 let s = StateStore::open_in_memory().unwrap();
199 s.begin_run("r1", "orders", P, "2026-01-01T00:00:00Z")
200 .unwrap();
201 assert!(
202 s.has_active_run_on_prefix(P).unwrap(),
203 "a lone crashed running run keeps the prefix active (deferred, not deleted)"
204 );
205 }
206
207 #[test]
208 fn begin_rearms_running_on_a_resumed_run_id() {
209 let s = StateStore::open_in_memory().unwrap();
210 s.begin_run("r1", "orders", P, "2026-01-01T00:00:00Z")
211 .unwrap();
212 s.finish_run("r1", "failed", "2026-01-01T00:01:00Z")
213 .unwrap();
214 assert!(!s.has_active_run_on_prefix(P).unwrap());
215 s.begin_run("r1", "orders", P, "2026-01-01T00:02:00Z")
217 .unwrap();
218 assert!(
219 s.has_active_run_on_prefix(P).unwrap(),
220 "re-begin on the same run_id re-arms running"
221 );
222 }
223
224 #[test]
225 fn finish_on_an_absent_run_is_a_noop() {
226 let s = StateStore::open_in_memory().unwrap();
227 s.finish_run("ghost", "success", "2026-01-01T00:00:00Z")
228 .unwrap();
229 assert!(!s.has_active_run_on_prefix(P).unwrap());
230 }
231
232 #[test]
233 fn active_matches_a_partitioned_child_prefix() {
234 let s = StateStore::open_in_memory().unwrap();
237 let child = "gs://b/exports/orders/created_at=2023-01-01/";
238 s.begin_run("r1", "orders", child, "2026-01-01T00:00:00Z")
239 .unwrap();
240 assert!(
241 s.has_active_run_on_prefix("gs://b/exports/orders/")
242 .unwrap(),
243 "a partitioned child run makes its base prefix active"
244 );
245 }
246
247 #[test]
248 fn active_matches_a_load_prefix_under_a_cdc_base() {
249 let s = StateStore::open_in_memory().unwrap();
252 s.begin_run("cdc1", "cdc", "gs://b/exports/", "2026-01-01T00:00:00Z")
253 .unwrap();
254 assert!(
255 s.has_active_run_on_prefix("gs://b/exports/orders/")
256 .unwrap(),
257 "a per-table child of a live CDC base prefix counts as active"
258 );
259 assert!(
260 !s.has_active_run_on_prefix("gs://b/other/orders/").unwrap(),
261 "an unrelated prefix outside the CDC base is NOT active"
262 );
263 }
264
265 #[test]
266 fn active_is_boundary_safe_against_a_string_sibling() {
267 let s = StateStore::open_in_memory().unwrap();
270 s.begin_run(
271 "r1",
272 "arch",
273 "gs://b/exports/orders_archive/p.parquet",
274 "2026-01-01T00:00:00Z",
275 )
276 .unwrap();
277 assert!(
278 !s.has_active_run_on_prefix("gs://b/exports/orders").unwrap(),
279 "a sibling prefix that merely string-starts-with must not count as active"
280 );
281 }
282}