1use crate::error::Result;
2
3use super::StateStore;
4
5impl StateStore {
25 pub fn begin_run(
29 &self,
30 run_id: &str,
31 export_name: &str,
32 prefix: &str,
33 started_at: &str,
34 ) -> Result<()> {
35 self.execute(
36 "INSERT INTO run_status
37 (run_id, export_name, prefix, status, started_at, finished_at)
38 VALUES (?1, ?2, ?3, 'running', ?4, NULL)
39 ON CONFLICT(run_id) DO UPDATE SET
40 export_name = excluded.export_name,
41 prefix = excluded.prefix,
42 status = 'running',
43 started_at = excluded.started_at,
44 finished_at = NULL",
45 &[
46 run_id.into(),
47 export_name.into(),
48 prefix.into(),
49 started_at.into(),
50 ],
51 )?;
52 Ok(())
53 }
54
55 pub fn finish_run(&self, run_id: &str, status: &str, finished_at: &str) -> Result<()> {
59 self.execute(
60 "UPDATE run_status SET status = ?2, finished_at = ?3 WHERE run_id = ?1",
61 &[run_id.into(), status.into(), finished_at.into()],
62 )?;
63 Ok(())
64 }
65
66 pub fn has_active_run_on_prefix(&self, prefix: &str) -> Result<bool> {
72 let sql = "SELECT 1 FROM run_status r
83 WHERE (rtrim(r.prefix, '/') = rtrim(?1, '/')
84 OR r.prefix LIKE rtrim(?1, '/') || '/%'
85 OR rtrim(?1, '/') LIKE rtrim(r.prefix, '/') || '/%')
86 AND r.status = 'running'
87 AND NOT EXISTS (
88 SELECT 1 FROM run_status r2
89 WHERE r2.export_name = r.export_name
90 AND r2.started_at > r.started_at)
91 LIMIT 1";
92 Ok(self.query_opt(sql, &[prefix.into()], |_| ())?.is_some())
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 const P: &str = "gs://b/exports/orders/";
101
102 #[test]
111 fn pg_shared_state_cross_connection_visibility_and_supersession() {
112 let Ok(url) = std::env::var("RIVET_TEST_STATE_URL") else {
113 return;
114 };
115 if !url.starts_with("postgres") {
116 return;
117 }
118 unsafe { std::env::set_var("RIVET_STATE_URL", &url) };
120 let a = StateStore::open(":memory:").expect("conn A");
121 let b = StateStore::open(":memory:").expect("conn B");
122 unsafe { std::env::remove_var("RIVET_STATE_URL") };
123
124 let pid = std::process::id();
125 let exp = format!("conc_test_{pid}");
126 let (r1, r2) = (format!("r1_{pid}"), format!("r2_{pid}"));
127 let pa = format!("gs://b/{exp}/runA/");
128 let pb = format!("gs://b/{exp}/runB/");
129
130 a.begin_run(&r1, &exp, &pa, "2026-01-01T00:00:01Z").unwrap();
132 assert!(
133 b.has_active_run_on_prefix(&pa).unwrap(),
134 "conn B must see conn A's active run on the shared Postgres state (multi-process gc signal)"
135 );
136
137 b.begin_run(&r2, &exp, &pb, "2026-01-01T00:00:02Z").unwrap();
139 assert!(
140 !a.has_active_run_on_prefix(&pa).unwrap(),
141 "run1 is superseded by the newer run2 (by started_at, no age timer) → its prefix is no longer active"
142 );
143 assert!(
144 a.has_active_run_on_prefix(&pb).unwrap(),
145 "run2 (newest, same export) is the active run"
146 );
147
148 b.finish_run(&r2, "success", "2026-01-01T00:00:03Z")
150 .unwrap();
151 assert!(
152 !a.has_active_run_on_prefix(&pb).unwrap(),
153 "a finished run must not count as active"
154 );
155 a.finish_run(&r1, "success", "2026-01-01T00:00:04Z").ok();
156 }
157
158 #[test]
159 fn begin_marks_active_then_finish_clears_it() {
160 let s = StateStore::open_in_memory().unwrap();
161 assert!(
162 !s.has_active_run_on_prefix(P).unwrap(),
163 "empty → not active"
164 );
165 s.begin_run("r1", "orders", P, "2026-01-01T00:00:00Z")
166 .unwrap();
167 assert!(
168 s.has_active_run_on_prefix(P).unwrap(),
169 "a running run makes the prefix active"
170 );
171 s.finish_run("r1", "success", "2026-01-01T00:01:00Z")
172 .unwrap();
173 assert!(
174 !s.has_active_run_on_prefix(P).unwrap(),
175 "a finished run leaves the prefix inactive"
176 );
177 }
178
179 #[test]
180 fn active_is_scoped_to_the_prefix() {
181 let s = StateStore::open_in_memory().unwrap();
182 s.begin_run("r1", "orders", P, "2026-01-01T00:00:00Z")
183 .unwrap();
184 assert!(
185 !s.has_active_run_on_prefix("gs://b/exports/users/").unwrap(),
186 "a run on a DIFFERENT prefix does not make this one active"
187 );
188 }
189
190 #[test]
191 fn a_superseded_running_row_no_longer_counts_as_active() {
192 let s = StateStore::open_in_memory().unwrap();
196 s.begin_run("r1", "orders", P, "2026-01-01T00:00:00Z")
197 .unwrap(); s.begin_run("r2", "orders", P, "2026-01-01T00:00:05Z")
199 .unwrap();
200 assert!(
201 s.has_active_run_on_prefix(P).unwrap(),
202 "r2 is running and newest → active"
203 );
204 s.finish_run("r2", "success", "2026-01-01T00:01:00Z")
205 .unwrap();
206 assert!(
207 !s.has_active_run_on_prefix(P).unwrap(),
208 "r1 is `running` but SUPERSEDED by the finished r2 → NOT active (no clock)"
209 );
210 }
211
212 #[test]
213 fn a_lone_crashed_running_row_still_counts_as_active() {
214 let s = StateStore::open_in_memory().unwrap();
218 s.begin_run("r1", "orders", P, "2026-01-01T00:00:00Z")
219 .unwrap();
220 assert!(
221 s.has_active_run_on_prefix(P).unwrap(),
222 "a lone crashed running run keeps the prefix active (deferred, not deleted)"
223 );
224 }
225
226 #[test]
227 fn begin_rearms_running_on_a_resumed_run_id() {
228 let s = StateStore::open_in_memory().unwrap();
229 s.begin_run("r1", "orders", P, "2026-01-01T00:00:00Z")
230 .unwrap();
231 s.finish_run("r1", "failed", "2026-01-01T00:01:00Z")
232 .unwrap();
233 assert!(!s.has_active_run_on_prefix(P).unwrap());
234 s.begin_run("r1", "orders", P, "2026-01-01T00:02:00Z")
236 .unwrap();
237 assert!(
238 s.has_active_run_on_prefix(P).unwrap(),
239 "re-begin on the same run_id re-arms running"
240 );
241 }
242
243 #[test]
244 fn finish_on_an_absent_run_is_a_noop() {
245 let s = StateStore::open_in_memory().unwrap();
246 s.finish_run("ghost", "success", "2026-01-01T00:00:00Z")
247 .unwrap();
248 assert!(!s.has_active_run_on_prefix(P).unwrap());
249 }
250
251 #[test]
252 fn active_matches_a_partitioned_child_prefix() {
253 let s = StateStore::open_in_memory().unwrap();
256 let child = "gs://b/exports/orders/created_at=2023-01-01/";
257 s.begin_run("r1", "orders", child, "2026-01-01T00:00:00Z")
258 .unwrap();
259 assert!(
260 s.has_active_run_on_prefix("gs://b/exports/orders/")
261 .unwrap(),
262 "a partitioned child run makes its base prefix active"
263 );
264 }
265
266 #[test]
267 fn active_matches_a_load_prefix_under_a_cdc_base() {
268 let s = StateStore::open_in_memory().unwrap();
271 s.begin_run("cdc1", "cdc", "gs://b/exports/", "2026-01-01T00:00:00Z")
272 .unwrap();
273 assert!(
274 s.has_active_run_on_prefix("gs://b/exports/orders/")
275 .unwrap(),
276 "a per-table child of a live CDC base prefix counts as active"
277 );
278 assert!(
279 !s.has_active_run_on_prefix("gs://b/other/orders/").unwrap(),
280 "an unrelated prefix outside the CDC base is NOT active"
281 );
282 }
283
284 #[test]
285 fn active_is_boundary_safe_against_a_string_sibling() {
286 let s = StateStore::open_in_memory().unwrap();
289 s.begin_run(
290 "r1",
291 "arch",
292 "gs://b/exports/orders_archive/p.parquet",
293 "2026-01-01T00:00:00Z",
294 )
295 .unwrap();
296 assert!(
297 !s.has_active_run_on_prefix("gs://b/exports/orders").unwrap(),
298 "a sibling prefix that merely string-starts-with must not count as active"
299 );
300 }
301}