Skip to main content

rivet/state/
run_status_store.rs

1use crate::error::Result;
2
3use super::StateStore;
4
5/// The `run_status` ledger — a best-effort ADVISORY record of each export run's
6/// lifecycle: written `running` at run START (before any part lands) and
7/// transitioned to a terminal status at finalize. The bucket manifest is a
8/// companion PROJECTION (its status written FROM the same lifecycle), so a
9/// cross-boundary reader (Airflow over the bucket) and a rivet process over a
10/// shared state DB read the same signal. Row columns: `run_id` PK, `export_name`,
11/// `prefix` (the run's write URI — the key `gc_orphans` matches at-or-under
12/// `plan.gcs_prefix`), `status` (`running`|`success`|`failed`|`interrupted`),
13/// `started_at`, `finished_at`.
14///
15/// NOT authoritative, by design: every write is best-effort (a miss only warns)
16/// and `gc_orphans` reads it FAIL-OPEN — an unwritten or unreadable ledger makes
17/// gc `active` (spare everything), the same safe behaviour as no ledger at all.
18/// It only ever REFINES gc toward deleting a dead orphan; it never risks deleting
19/// a live one. `gc_orphans` uses it to tell a LIVE extract (a `running` run NOT
20/// superseded by a newer run of the same export) from a crash orphan — no
21/// wall-clock heuristic. A hard-crashed run leaves a stale `running` row; a later
22/// run of the same export SUPERSEDES it (higher `started_at`), so it stops
23/// counting as active without any age/lease timer.
24impl StateStore {
25    /// Record an export run as `running` at its START. Upsert on `run_id` so a
26    /// RESUMED run reuses its row and re-arms `running` (clearing any prior
27    /// terminal status / `finished_at` from an earlier attempt).
28    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    /// Transition a run to a terminal status (`success` | `failed` |
56    /// `interrupted`) at finalize. A no-op if the row is absent (a run that
57    /// never called `begin_run` — e.g. a legacy/in-memory path).
58    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    /// Is a LIVE extract writing `prefix`? True iff some `running` run on that
67    /// prefix is NOT superseded by a newer run of the SAME export (a newer
68    /// `started_at` means the old run crashed and its successor already re-ran,
69    /// so the stale `running` no longer protects anything). Supersession, not a
70    /// clock — the reconciliation is record-vs-record, never record-vs-`now`.
71    pub fn has_active_run_on_prefix(&self, prefix: &str) -> Result<bool> {
72        // A running, non-superseded run whose write prefix OVERLAPS this prefix —
73        // either direction, so a query at ANY granularity matches:
74        //   * equal (batch: run and load prefix coincide),
75        //   * run AT-OR-UNDER query (a partitioned run's `…/created_at=…/` vs the
76        //     load's base, truncated at `{partition}`), and
77        //   * query AT-OR-UNDER run (a CDC run records its BASE `…/`, but the load
78        //     gc's a per-TABLE child `…/<table>/`).
79        // Over-matching (a broad run covering an unrelated child) only makes gc
80        // SPARE — the safe direction (defer, never wrong-delete). `rtrim(x,'/')` +
81        // `||` + `LIKE` are all portable across the SQLite and Postgres backends.
82        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    /// Two independent StateStore connections to ONE shared Postgres state db behave
103    /// like two rivet PROCESSES on a shared-Postgres deployment: a run begun on
104    /// connection A is immediately visible to connection B's has_active_run_on_prefix
105    /// (the gc_orphans concurrency signal — the whole reason PG state exists over the
106    /// per-host SQLite file), and a newer run of the same export SUPERSEDES the older
107    /// one CLOCK-FREE. The gate is otherwise single-process; gc_survival only simulates
108    /// a running manifest. Env-gated on RIVET_TEST_STATE_URL; unique names so the shared
109    /// public schema never collides with a sibling test.
110    #[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        // Two connections = two processes on the same shared Postgres state.
119        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 begins run1; B — a SEPARATE connection — must SEE it active.
131        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 begins run2 (newer started_at, same export) → supersedes run1 CLOCK-FREE.
138        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        // Finishing run2 clears it — a terminal run is never active.
149        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        // The clock-free staleness contract. r1 hard-crashed (still `running`,
193        // never finished). Its successor r2 (newer started_at, SAME export) ran
194        // and SUCCEEDED. r1 must stop counting as active — WITHOUT any age timer.
195        let s = StateStore::open_in_memory().unwrap();
196        s.begin_run("r1", "orders", P, "2026-01-01T00:00:00Z")
197            .unwrap(); // crashed, left running
198        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        // The documented residual: a hard-crash with NO successor leaves a
215        // `running` row that keeps the prefix active — gc defers (safe: never
216        // deletes) until the export re-runs and supersedes it.
217        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        // A resume reuses the run_id → re-arm running (upsert).
235        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        // A run records its FULL write URI (partition replaced); the load asks
254        // about the BASE (truncated at `{partition}`). The child must still match.
255        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        // A CDC run records its BASE prefix; the load gc's a per-table CHILD under
269        // it. The overlap match (query-under-run direction) must still see it live.
270        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        // `exports/orders` must NOT match a run under `exports/orders_archive/`
287        // (the classic string-prefix-vs-path-boundary trap).
288        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}