Skip to main content

rivet/state/
run_status_store.rs

1use rusqlite::OptionalExtension;
2
3use crate::error::Result;
4
5use super::{StateConn, StateStore};
6
7/// The `run_status` ledger — a best-effort ADVISORY record of each export run's
8/// lifecycle: written `running` at run START (before any part lands) and
9/// transitioned to a terminal status at finalize. The bucket manifest is a
10/// companion PROJECTION (its status written FROM the same lifecycle), so a
11/// cross-boundary reader (Airflow over the bucket) and a rivet process over a
12/// shared state DB read the same signal. Row columns: `run_id` PK, `export_name`,
13/// `prefix` (the run's write URI — the key `gc_orphans` matches at-or-under
14/// `plan.gcs_prefix`), `status` (`running`|`success`|`failed`|`interrupted`),
15/// `started_at`, `finished_at`.
16///
17/// NOT authoritative, by design: every write is best-effort (a miss only warns)
18/// and `gc_orphans` reads it FAIL-OPEN — an unwritten or unreadable ledger makes
19/// gc `active` (spare everything), the same safe behaviour as no ledger at all.
20/// It only ever REFINES gc toward deleting a dead orphan; it never risks deleting
21/// a live one. `gc_orphans` uses it to tell a LIVE extract (a `running` run NOT
22/// superseded by a newer run of the same export) from a crash orphan — no
23/// wall-clock heuristic. A hard-crashed run leaves a stale `running` row; a later
24/// run of the same export SUPERSEDES it (higher `started_at`), so it stops
25/// counting as active without any age/lease timer.
26impl StateStore {
27    /// Record an export run as `running` at its START. Upsert on `run_id` so a
28    /// RESUMED run reuses its row and re-arms `running` (clearing any prior
29    /// terminal status / `finished_at` from an earlier attempt).
30    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    /// Transition a run to a terminal status (`success` | `failed` |
71    /// `interrupted`) at finalize. A no-op if the row is absent (a run that
72    /// never called `begin_run` — e.g. a legacy/in-memory path).
73    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    /// Is a LIVE extract writing `prefix`? True iff some `running` run on that
92    /// prefix is NOT superseded by a newer run of the SAME export (a newer
93    /// `started_at` means the old run crashed and its successor already re-ran,
94    /// so the stale `running` no longer protects anything). Supersession, not a
95    /// clock — the reconciliation is record-vs-record, never record-vs-`now`.
96    pub fn has_active_run_on_prefix(&self, prefix: &str) -> Result<bool> {
97        // A running, non-superseded run whose write prefix OVERLAPS this prefix —
98        // either direction, so a query at ANY granularity matches:
99        //   * equal (batch: run and load prefix coincide),
100        //   * run AT-OR-UNDER query (a partitioned run's `…/created_at=…/` vs the
101        //     load's base, truncated at `{partition}`), and
102        //   * query AT-OR-UNDER run (a CDC run records its BASE `…/`, but the load
103        //     gc's a per-TABLE child `…/<table>/`).
104        // Over-matching (a broad run covering an unrelated child) only makes gc
105        // SPARE — the safe direction (defer, never wrong-delete). `rtrim(x,'/')` +
106        // `||` + `LIKE` are all portable across the SQLite and Postgres backends.
107        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        // The clock-free staleness contract. r1 hard-crashed (still `running`,
174        // never finished). Its successor r2 (newer started_at, SAME export) ran
175        // and SUCCEEDED. r1 must stop counting as active — WITHOUT any age timer.
176        let s = StateStore::open_in_memory().unwrap();
177        s.begin_run("r1", "orders", P, "2026-01-01T00:00:00Z")
178            .unwrap(); // crashed, left running
179        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        // The documented residual: a hard-crash with NO successor leaves a
196        // `running` row that keeps the prefix active — gc defers (safe: never
197        // deletes) until the export re-runs and supersedes it.
198        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        // A resume reuses the run_id → re-arm running (upsert).
216        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        // A run records its FULL write URI (partition replaced); the load asks
235        // about the BASE (truncated at `{partition}`). The child must still match.
236        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        // A CDC run records its BASE prefix; the load gc's a per-table CHILD under
250        // it. The overlap match (query-under-run direction) must still see it live.
251        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        // `exports/orders` must NOT match a run under `exports/orders_archive/`
268        // (the classic string-prefix-vs-path-boundary trap).
269        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}