Skip to main content

faucet_cli/serve/history/
sql.rs

1//! Shared SQL run-history machinery for the Postgres and SQLite backends
2//! (Phase 5, #127). Both backends are identical except for the connection setup
3//! and the placeholder dialect (`$n` vs `?`), so the schema, prepared-statement
4//! text, pure helpers, and the entire `RunHistory` impl live here once and are
5//! instantiated for each concrete `sqlx` pool via the `impl_sql_history!` macro.
6//!
7//! **Portability:** every column is `TEXT` (timestamps are stored as fixed-width
8//! RFC3339 with nanosecond precision + `Z`, which sorts lexicographically in
9//! chronological order, so keyset pagination and expiry comparisons work without
10//! any database date type — and thus without the `sqlx` `chrono` feature). The
11//! whole `RunRecord` is serialized into the `body` column (the source of truth on
12//! read); the dedicated columns exist only for filtering, ordering, and expiry.
13//!
14//! **Idempotency** lives in a separate `faucet_serve_idem` table whose `key`
15//! primary key is the required unique index (spec §10/§11). The claim is atomic
16//! via `INSERT … ON CONFLICT DO NOTHING` plus an optimistic, expiry-guarded
17//! takeover `UPDATE`, mirroring the memory backend's shard-locked semantics.
18
19use super::{HistoryError, RunRecord, RunStatus};
20use chrono::{DateTime, Utc};
21use std::time::Duration;
22
23/// DDL run at connect time. Valid verbatim on both Postgres and SQLite (only
24/// `TEXT` columns, `IF NOT EXISTS`, and standard indexes).
25pub const DDL: &[&str] = &[
26    // `owner` is the id of the serve instance that owns the run; `lease_expires_at`
27    // is the RFC3339 instant past which that ownership is presumed dead. Together
28    // they fence orphan recovery: an instance only fails a non-terminal run whose
29    // lease has expired, never another live instance's heartbeated runs (#146 H7).
30    "CREATE TABLE IF NOT EXISTS faucet_serve_runs (\
31        run_id TEXT PRIMARY KEY,\
32        name TEXT,\
33        status TEXT NOT NULL,\
34        submitted_at TEXT NOT NULL,\
35        finished_at TEXT,\
36        idempotency_key TEXT,\
37        owner TEXT,\
38        lease_expires_at TEXT,\
39        cancel_requested TEXT,\
40        body TEXT NOT NULL)",
41    "CREATE INDEX IF NOT EXISTS faucet_serve_runs_submitted_idx \
42        ON faucet_serve_runs (submitted_at)",
43    // Speeds the per-tick orphan scan / lease renewal, which filter on
44    // (status, owner, lease_expires_at).
45    "CREATE INDEX IF NOT EXISTS faucet_serve_runs_status_lease_idx \
46        ON faucet_serve_runs (status, lease_expires_at)",
47    // Speeds the cluster dispatcher's pending-run query (ordered by submitted_at).
48    "CREATE INDEX IF NOT EXISTS faucet_serve_runs_pending_idx \
49        ON faucet_serve_runs (status, submitted_at)",
50    "CREATE TABLE IF NOT EXISTS faucet_serve_instances (\
51        instance_id TEXT PRIMARY KEY,\
52        started_at TEXT NOT NULL,\
53        last_heartbeat TEXT NOT NULL,\
54        listen TEXT,\
55        max_concurrent TEXT,\
56        in_flight TEXT)",
57    "CREATE INDEX IF NOT EXISTS faucet_serve_instances_hb_idx \
58        ON faucet_serve_instances (last_heartbeat)",
59    "CREATE TABLE IF NOT EXISTS faucet_serve_idem (\
60        key TEXT PRIMARY KEY,\
61        run_id TEXT NOT NULL,\
62        fingerprint TEXT NOT NULL,\
63        claimed_at TEXT NOT NULL)",
64];
65
66/// SQL placeholder dialect.
67#[derive(Clone, Copy, Debug)]
68pub enum Dialect {
69    Postgres,
70    Sqlite,
71}
72
73/// Prepared-statement text for a backend, built once per dialect at connect time.
74pub struct Stmts {
75    /// (`cancel_requested` is intentionally NOT written by `upsert` — it is set
76    /// only via `request_cancel` and cleared by `reclaim_requeue`; it defaults to
77    /// NULL on insert.)
78    pub upsert: String,
79    pub select_body: String,
80    pub select_status: String,
81    pub select_submitted: String,
82    pub delete: String,
83    pub list: String,
84    pub purge_runs: String,
85    pub purge_idem: String,
86    /// Select non-terminal runs whose owning instance's lease has expired (or
87    /// is unset) — the orphans this instance may safely fail. Param: `now`.
88    pub select_orphans: String,
89    /// Extend the lease of this instance's own non-terminal runs (heartbeat).
90    /// Params: `new_lease_expiry`, `instance_id`.
91    pub renew_leases: String,
92    pub insert_idem: String,
93    pub select_idem: String,
94    pub takeover_idem: String,
95    /// Delete the idempotency claim(s) that point at a given run — used when a
96    /// run is deleted so a replay of the key starts fresh rather than 404-ing
97    /// on the missing record (#146 M8). Scoped by `run_id`, so a newer run that
98    /// re-claimed the same key keeps its claim.
99    pub delete_idem_by_run: String,
100    /// Cluster dispatcher: fetch oldest pending runs up to a given limit.
101    pub select_pending: String,
102    /// Cluster dispatcher: atomically claim a pending run (set owner + running).
103    pub claim_one: String,
104    /// Cluster reclaimer: select expired running runs for requeue/fail evaluation.
105    /// NOTE: `'queued'` is the single-instance status; cluster runs flow
106    /// `pending → running`, so the failover reclaimer covers `'running'` only.
107    pub reclaim_select: String,
108    /// Cluster reclaimer: requeue an expired running run back to pending.
109    pub reclaim_requeue: String,
110    /// Cluster reclaimer: fail an expired running run that cannot be requeued.
111    pub reclaim_fail: String,
112    /// Finalize a run owned by this instance (terminal status update).
113    pub finalize_owned: String,
114    /// Cancel a pending run directly (transition pending → cancelled).
115    pub cancel_pending: String,
116    /// Request cancellation of an in-flight run owned by another instance.
117    pub request_cancel: String,
118    /// List run IDs owned by this instance that have a pending cancellation request.
119    pub pending_cancellations: String,
120    /// Upsert this instance's membership heartbeat into `faucet_serve_instances`.
121    pub heartbeat_instance: String,
122    /// List instances whose last heartbeat is at or after a given threshold.
123    pub live_instances: String,
124    /// Prune instances whose last heartbeat is before a given threshold.
125    pub prune_instances: String,
126}
127
128impl Stmts {
129    pub fn new(dialect: Dialect) -> Self {
130        match dialect {
131            Dialect::Postgres => Self::postgres(),
132            Dialect::Sqlite => Self::sqlite(),
133        }
134    }
135
136    fn postgres() -> Self {
137        Self {
138            upsert: "INSERT INTO faucet_serve_runs \
139                (run_id,name,status,submitted_at,finished_at,idempotency_key,owner,lease_expires_at,body) \
140                VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) \
141                ON CONFLICT (run_id) DO UPDATE SET \
142                name=excluded.name,status=excluded.status,submitted_at=excluded.submitted_at,\
143                finished_at=excluded.finished_at,idempotency_key=excluded.idempotency_key,\
144                owner=excluded.owner,lease_expires_at=excluded.lease_expires_at,\
145                body=excluded.body"
146                .into(),
147            select_body: "SELECT body FROM faucet_serve_runs WHERE run_id=$1".into(),
148            select_status: "SELECT status FROM faucet_serve_runs WHERE run_id=$1".into(),
149            select_submitted: "SELECT submitted_at FROM faucet_serve_runs WHERE run_id=$1".into(),
150            delete: "DELETE FROM faucet_serve_runs WHERE run_id=$1".into(),
151            // Casts make the parameter types explicit so `$n IS NULL` cannot trip
152            // Postgres' "could not determine data type of parameter" check.
153            list: "SELECT body FROM faucet_serve_runs \
154                WHERE ($1::text IS NULL OR status = $2::text) \
155                AND ($3::text IS NULL OR name = $4::text) \
156                AND ($5::text IS NULL OR submitted_at >= $6::text) \
157                AND ($7::text IS NULL OR submitted_at <= $8::text) \
158                AND ($9::text IS NULL OR (submitted_at < $10::text \
159                    OR (submitted_at = $11::text AND run_id < $12::text))) \
160                ORDER BY submitted_at DESC, run_id DESC LIMIT $13"
161                .into(),
162            purge_runs: "DELETE FROM faucet_serve_runs \
163                WHERE status IN ('completed','failed','cancelled') \
164                AND finished_at IS NOT NULL AND finished_at < $1"
165                .into(),
166            purge_idem: "DELETE FROM faucet_serve_idem WHERE claimed_at < $1".into(),
167            select_orphans: "SELECT body FROM faucet_serve_runs \
168                WHERE status IN ('queued','running') \
169                AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
170                .into(),
171            renew_leases: "UPDATE faucet_serve_runs SET lease_expires_at = $1 \
172                WHERE owner = $2 AND status IN ('queued','running')"
173                .into(),
174            insert_idem: "INSERT INTO faucet_serve_idem (key,run_id,fingerprint,claimed_at) \
175                VALUES ($1,$2,$3,$4) ON CONFLICT (key) DO NOTHING"
176                .into(),
177            select_idem: "SELECT run_id,fingerprint,claimed_at FROM faucet_serve_idem WHERE key=$1"
178                .into(),
179            takeover_idem: "UPDATE faucet_serve_idem \
180                SET run_id=$1,fingerprint=$2,claimed_at=$3 WHERE key=$4 AND claimed_at=$5"
181                .into(),
182            delete_idem_by_run: "DELETE FROM faucet_serve_idem WHERE run_id=$1".into(),
183            select_pending: "SELECT run_id, body FROM faucet_serve_runs \
184                WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT $1"
185                .into(),
186            claim_one: "UPDATE faucet_serve_runs \
187                SET owner = $1, status = 'running', lease_expires_at = $2, body = $3 \
188                WHERE run_id = $4 AND status = 'pending'"
189                .into(),
190            reclaim_select: "SELECT body FROM faucet_serve_runs \
191                WHERE status = 'running' \
192                AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
193                .into(),
194            reclaim_requeue: "UPDATE faucet_serve_runs \
195                SET status = 'pending', owner = NULL, lease_expires_at = NULL, \
196                    cancel_requested = NULL, body = $1 \
197                WHERE run_id = $2 AND status = 'running' \
198                AND (lease_expires_at IS NULL OR lease_expires_at < $3)"
199                .into(),
200            reclaim_fail: "UPDATE faucet_serve_runs \
201                SET status = 'failed', finished_at = $1, body = $2, owner = NULL \
202                WHERE run_id = $3 AND status = 'running' \
203                AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
204                .into(),
205            finalize_owned: "UPDATE faucet_serve_runs \
206                SET status = $1, finished_at = $2, lease_expires_at = $3, body = $4 \
207                WHERE run_id = $5 AND owner = $6"
208                .into(),
209            cancel_pending: "UPDATE faucet_serve_runs \
210                SET status = 'cancelled', finished_at = $1, body = $2 \
211                WHERE run_id = $3 AND status = 'pending'"
212                .into(),
213            request_cancel: "UPDATE faucet_serve_runs \
214                SET cancel_requested = $1 WHERE run_id = $2 AND status = 'running'"
215                .into(),
216            pending_cancellations: "SELECT run_id FROM faucet_serve_runs \
217                WHERE status = 'running' AND owner = $1 AND cancel_requested IS NOT NULL"
218                .into(),
219            heartbeat_instance: "INSERT INTO faucet_serve_instances \
220                (instance_id, started_at, last_heartbeat, listen, max_concurrent, in_flight) \
221                VALUES ($1,$2,$3,$4,$5,$6) \
222                ON CONFLICT (instance_id) DO UPDATE SET \
223                last_heartbeat = excluded.last_heartbeat, listen = excluded.listen, \
224                max_concurrent = excluded.max_concurrent, in_flight = excluded.in_flight"
225                .into(),
226            live_instances: "SELECT instance_id, started_at, last_heartbeat, listen, \
227                max_concurrent, in_flight FROM faucet_serve_instances \
228                WHERE last_heartbeat >= $1"
229                .into(),
230            prune_instances: "DELETE FROM faucet_serve_instances WHERE last_heartbeat < $1".into(),
231        }
232    }
233
234    fn sqlite() -> Self {
235        Self {
236            upsert: "INSERT INTO faucet_serve_runs \
237                (run_id,name,status,submitted_at,finished_at,idempotency_key,owner,lease_expires_at,body) \
238                VALUES (?,?,?,?,?,?,?,?,?) \
239                ON CONFLICT (run_id) DO UPDATE SET \
240                name=excluded.name,status=excluded.status,submitted_at=excluded.submitted_at,\
241                finished_at=excluded.finished_at,idempotency_key=excluded.idempotency_key,\
242                owner=excluded.owner,lease_expires_at=excluded.lease_expires_at,\
243                body=excluded.body"
244                .into(),
245            select_body: "SELECT body FROM faucet_serve_runs WHERE run_id=?".into(),
246            select_status: "SELECT status FROM faucet_serve_runs WHERE run_id=?".into(),
247            select_submitted: "SELECT submitted_at FROM faucet_serve_runs WHERE run_id=?".into(),
248            delete: "DELETE FROM faucet_serve_runs WHERE run_id=?".into(),
249            list: "SELECT body FROM faucet_serve_runs \
250                WHERE (? IS NULL OR status = ?) \
251                AND (? IS NULL OR name = ?) \
252                AND (? IS NULL OR submitted_at >= ?) \
253                AND (? IS NULL OR submitted_at <= ?) \
254                AND (? IS NULL OR (submitted_at < ? \
255                    OR (submitted_at = ? AND run_id < ?))) \
256                ORDER BY submitted_at DESC, run_id DESC LIMIT ?"
257                .into(),
258            purge_runs: "DELETE FROM faucet_serve_runs \
259                WHERE status IN ('completed','failed','cancelled') \
260                AND finished_at IS NOT NULL AND finished_at < ?"
261                .into(),
262            purge_idem: "DELETE FROM faucet_serve_idem WHERE claimed_at < ?".into(),
263            select_orphans: "SELECT body FROM faucet_serve_runs \
264                WHERE status IN ('queued','running') \
265                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
266                .into(),
267            renew_leases: "UPDATE faucet_serve_runs SET lease_expires_at = ? \
268                WHERE owner = ? AND status IN ('queued','running')"
269                .into(),
270            insert_idem: "INSERT INTO faucet_serve_idem (key,run_id,fingerprint,claimed_at) \
271                VALUES (?,?,?,?) ON CONFLICT (key) DO NOTHING"
272                .into(),
273            select_idem: "SELECT run_id,fingerprint,claimed_at FROM faucet_serve_idem WHERE key=?"
274                .into(),
275            takeover_idem: "UPDATE faucet_serve_idem \
276                SET run_id=?,fingerprint=?,claimed_at=? WHERE key=? AND claimed_at=?"
277                .into(),
278            delete_idem_by_run: "DELETE FROM faucet_serve_idem WHERE run_id=?".into(),
279            select_pending: "SELECT run_id, body FROM faucet_serve_runs \
280                WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT ?"
281                .into(),
282            claim_one: "UPDATE faucet_serve_runs \
283                SET owner = ?, status = 'running', lease_expires_at = ?, body = ? \
284                WHERE run_id = ? AND status = 'pending'"
285                .into(),
286            reclaim_select: "SELECT body FROM faucet_serve_runs \
287                WHERE status = 'running' \
288                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
289                .into(),
290            reclaim_requeue: "UPDATE faucet_serve_runs \
291                SET status = 'pending', owner = NULL, lease_expires_at = NULL, \
292                    cancel_requested = NULL, body = ? \
293                WHERE run_id = ? AND status = 'running' \
294                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
295                .into(),
296            reclaim_fail: "UPDATE faucet_serve_runs \
297                SET status = 'failed', finished_at = ?, body = ?, owner = NULL \
298                WHERE run_id = ? AND status = 'running' \
299                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
300                .into(),
301            finalize_owned: "UPDATE faucet_serve_runs \
302                SET status = ?, finished_at = ?, lease_expires_at = ?, body = ? \
303                WHERE run_id = ? AND owner = ?"
304                .into(),
305            cancel_pending: "UPDATE faucet_serve_runs \
306                SET status = 'cancelled', finished_at = ?, body = ? \
307                WHERE run_id = ? AND status = 'pending'"
308                .into(),
309            request_cancel: "UPDATE faucet_serve_runs \
310                SET cancel_requested = ? WHERE run_id = ? AND status = 'running'"
311                .into(),
312            pending_cancellations: "SELECT run_id FROM faucet_serve_runs \
313                WHERE status = 'running' AND owner = ? AND cancel_requested IS NOT NULL"
314                .into(),
315            heartbeat_instance: "INSERT INTO faucet_serve_instances \
316                (instance_id, started_at, last_heartbeat, listen, max_concurrent, in_flight) \
317                VALUES (?,?,?,?,?,?) \
318                ON CONFLICT (instance_id) DO UPDATE SET \
319                last_heartbeat = excluded.last_heartbeat, listen = excluded.listen, \
320                max_concurrent = excluded.max_concurrent, in_flight = excluded.in_flight"
321                .into(),
322            live_instances: "SELECT instance_id, started_at, last_heartbeat, listen, \
323                max_concurrent, in_flight FROM faucet_serve_instances \
324                WHERE last_heartbeat >= ?"
325                .into(),
326            prune_instances: "DELETE FROM faucet_serve_instances WHERE last_heartbeat < ?".into(),
327        }
328    }
329}
330
331/// Bounded retry count for the atomic idempotency claim (handles a claim being
332/// purged concurrently between the insert attempt and the read-back).
333pub const CLAIM_ATTEMPTS: usize = 4;
334
335/// Fixed-width RFC3339 (nanoseconds + `Z`) — lexicographically sortable.
336pub fn fmt_ts(dt: DateTime<Utc>) -> String {
337    dt.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)
338}
339
340/// True when a claim timestamped `claimed_at` (RFC3339) is older than `window`.
341/// An unparseable or future timestamp is treated as **not** expired (safe: it
342/// won't be silently re-claimed).
343pub fn is_expired(claimed_at: &str, now: DateTime<Utc>, window: Duration) -> bool {
344    match DateTime::parse_from_rfc3339(claimed_at) {
345        Ok(t) => now
346            .signed_duration_since(t.with_timezone(&Utc))
347            .to_std()
348            .map(|age| age >= window)
349            .unwrap_or(false),
350        Err(_) => false,
351    }
352}
353
354/// RFC3339 timestamp `window` before `now` (the purge / expiry threshold).
355pub fn threshold(now: DateTime<Utc>, window: Duration) -> String {
356    let delta =
357        chrono::Duration::from_std(window).unwrap_or_else(|_| chrono::Duration::days(36_500));
358    fmt_ts(now - delta)
359}
360
361pub fn encode_body(rec: &RunRecord) -> Result<String, HistoryError> {
362    serde_json::to_string(rec).map_err(|e| HistoryError::Backend(format!("encode run record: {e}")))
363}
364
365pub fn decode_body(body: &str) -> Result<RunRecord, HistoryError> {
366    serde_json::from_str(body).map_err(|e| HistoryError::Backend(format!("decode run record: {e}")))
367}
368
369pub fn parse_status(s: &str) -> RunStatus {
370    match s {
371        "queued" => RunStatus::Queued,
372        "pending" => RunStatus::Pending,
373        "running" => RunStatus::Running,
374        "completed" => RunStatus::Completed,
375        "cancelled" => RunStatus::Cancelled,
376        _ => RunStatus::Failed,
377    }
378}
379
380/// Generate a concrete `RunHistory` implementation over a specific `sqlx` pool.
381/// `$name` is the backend struct, `$pool` its `sqlx` pool type. The struct holds
382/// the pool, the idempotency retention window, and the dialect's [`Stmts`].
383macro_rules! impl_sql_history {
384    ($name:ident, $pool:ty) => {
385        /// SQL-backed [`RunHistory`](crate::serve::history::RunHistory). See
386        /// [`crate::serve::history::sql`] for the shared schema + semantics.
387        pub struct $name {
388            pool: $pool,
389            idem_retention: std::time::Duration,
390            /// This serve instance's id, stamped as `owner` on every upsert.
391            instance_id: String,
392            /// How far ahead each upsert / heartbeat pushes a run's lease.
393            lease_ttl: std::time::Duration,
394            stmts: $crate::serve::history::sql::Stmts,
395        }
396
397        impl $name {
398            /// Assemble from an already-connected pool (used by `connect`).
399            pub fn from_parts(
400                pool: $pool,
401                idem_retention: std::time::Duration,
402                lease_ttl: std::time::Duration,
403                instance_id: String,
404                stmts: $crate::serve::history::sql::Stmts,
405            ) -> Self {
406                Self {
407                    pool,
408                    idem_retention,
409                    instance_id,
410                    lease_ttl,
411                    stmts,
412                }
413            }
414
415            /// Borrow the underlying pool (tests close it to exercise fallback).
416            pub fn pool(&self) -> &$pool {
417                &self.pool
418            }
419        }
420
421        #[async_trait::async_trait]
422        impl $crate::serve::history::RunHistory for $name {
423            async fn claim_idempotency(
424                &self,
425                key: &str,
426                fingerprint: &str,
427                run_id: &str,
428                window: std::time::Duration,
429            ) -> Result<$crate::serve::history::Claim, $crate::serve::history::HistoryError> {
430                use sqlx::Row as _;
431                use $crate::serve::history::Claim;
432                use $crate::serve::history::HistoryError;
433                use $crate::serve::history::sql;
434
435                let now = chrono::Utc::now();
436                let now_s = sql::fmt_ts(now);
437                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
438
439                for _ in 0..sql::CLAIM_ATTEMPTS {
440                    // 1) Atomic first-claim: the winner inserts exactly one row.
441                    let inserted = sqlx::query(&self.stmts.insert_idem)
442                        .bind(key)
443                        .bind(run_id)
444                        .bind(fingerprint)
445                        .bind(&now_s)
446                        .execute(&self.pool)
447                        .await
448                        .map_err(backend)?
449                        .rows_affected();
450                    if inserted == 1 {
451                        return Ok(Claim::Fresh);
452                    }
453                    // 2) Conflict: inspect the existing claim.
454                    let Some(row) = sqlx::query(&self.stmts.select_idem)
455                        .bind(key)
456                        .fetch_optional(&self.pool)
457                        .await
458                        .map_err(backend)?
459                    else {
460                        // Vanished between the insert and the read — retry.
461                        continue;
462                    };
463                    let existing_run: String = row.try_get("run_id").map_err(backend)?;
464                    let existing_fp: String = row.try_get("fingerprint").map_err(backend)?;
465                    let claimed_at: String = row.try_get("claimed_at").map_err(backend)?;
466
467                    if sql::is_expired(&claimed_at, now, window) {
468                        // 3) Optimistic, expiry-guarded takeover: only the request
469                        // that still sees `claimed_at` succeeds.
470                        let took = sqlx::query(&self.stmts.takeover_idem)
471                            .bind(run_id)
472                            .bind(fingerprint)
473                            .bind(&now_s)
474                            .bind(key)
475                            .bind(&claimed_at)
476                            .execute(&self.pool)
477                            .await
478                            .map_err(backend)?
479                            .rows_affected();
480                        if took == 1 {
481                            return Ok(Claim::Fresh);
482                        }
483                        continue; // lost the race; re-evaluate
484                    }
485                    return Ok(if existing_fp == fingerprint {
486                        Claim::Replay(existing_run)
487                    } else {
488                        Claim::Conflict
489                    });
490                }
491                // Pathological contention only. Conservative: a 409 is safer than
492                // risking a duplicate run.
493                tracing::warn!(
494                    key,
495                    "idempotency claim exhausted retries; reporting conflict"
496                );
497                Ok(Claim::Conflict)
498            }
499
500            async fn upsert(
501                &self,
502                rec: &$crate::serve::history::RunRecord,
503            ) -> Result<(), $crate::serve::history::HistoryError> {
504                use $crate::serve::history::HistoryError;
505                use $crate::serve::history::sql;
506                let body = sql::encode_body(rec)?;
507                let submitted = sql::fmt_ts(rec.submitted_at);
508                let finished = rec.finished_at.map(sql::fmt_ts);
509                // Stamp this instance as the owner and start/renew the lease.
510                // The owner/lease are SQL-column-only (never in the record body),
511                // so the heartbeat can extend a lease without a body read-modify-
512                // write race (#146 H7).
513                let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
514                sqlx::query(&self.stmts.upsert)
515                    .bind(&rec.run_id)
516                    .bind(rec.name.as_deref())
517                    .bind(rec.status.as_str())
518                    .bind(&submitted)
519                    .bind(finished.as_deref())
520                    .bind(rec.idempotency_key.as_deref())
521                    .bind(&self.instance_id)
522                    .bind(&lease)
523                    .bind(&body)
524                    .execute(&self.pool)
525                    .await
526                    .map_err(|e| HistoryError::Backend(e.to_string()))?;
527                Ok(())
528            }
529
530            async fn get(
531                &self,
532                id: &str,
533            ) -> Result<
534                Option<$crate::serve::history::RunRecord>,
535                $crate::serve::history::HistoryError,
536            > {
537                use sqlx::Row as _;
538                use $crate::serve::history::HistoryError;
539                use $crate::serve::history::sql;
540                let row = sqlx::query(&self.stmts.select_body)
541                    .bind(id)
542                    .fetch_optional(&self.pool)
543                    .await
544                    .map_err(|e| HistoryError::Backend(e.to_string()))?;
545                match row {
546                    None => Ok(None),
547                    Some(r) => {
548                        let body: String = r
549                            .try_get("body")
550                            .map_err(|e| HistoryError::Backend(e.to_string()))?;
551                        Ok(Some(sql::decode_body(&body)?))
552                    }
553                }
554            }
555
556            async fn list(
557                &self,
558                filter: &$crate::serve::history::ListFilter,
559            ) -> Result<$crate::serve::history::ListPage, $crate::serve::history::HistoryError>
560            {
561                use sqlx::Row as _;
562                use $crate::serve::history::HistoryError;
563                use $crate::serve::history::ListPage;
564                use $crate::serve::history::sql;
565                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
566
567                // Resolve the cursor's submitted_at for keyset pagination. An
568                // unknown cursor is ignored (page starts from the top), matching
569                // the memory backend.
570                let cursor_ts: Option<String> = match &filter.cursor {
571                    None => None,
572                    Some(c) => sqlx::query(&self.stmts.select_submitted)
573                        .bind(c)
574                        .fetch_optional(&self.pool)
575                        .await
576                        .map_err(backend)?
577                        .map(|r| r.try_get::<String, _>("submitted_at"))
578                        .transpose()
579                        .map_err(backend)?,
580                };
581                let cur_id = if cursor_ts.is_some() {
582                    filter.cursor.as_deref()
583                } else {
584                    None
585                };
586
587                let status_s = filter.status.map(|s| s.as_str());
588                let name_s = filter.name.as_deref();
589                let since_s = filter.since.map(sql::fmt_ts);
590                let until_s = filter.until.map(sql::fmt_ts);
591                let limit = filter.limit.max(1);
592                let fetch_n = limit as i64 + 1; // +1 to detect a next page
593
594                let rows = sqlx::query(&self.stmts.list)
595                    .bind(status_s)
596                    .bind(status_s)
597                    .bind(name_s)
598                    .bind(name_s)
599                    .bind(since_s.as_deref())
600                    .bind(since_s.as_deref())
601                    .bind(until_s.as_deref())
602                    .bind(until_s.as_deref())
603                    .bind(cursor_ts.as_deref())
604                    .bind(cursor_ts.as_deref())
605                    .bind(cursor_ts.as_deref())
606                    .bind(cur_id)
607                    .bind(fetch_n)
608                    .fetch_all(&self.pool)
609                    .await
610                    .map_err(backend)?;
611
612                let mut runs = Vec::with_capacity(rows.len());
613                for r in &rows {
614                    let body: String = r.try_get("body").map_err(backend)?;
615                    runs.push(sql::decode_body(&body)?);
616                }
617                let next_cursor = if runs.len() > limit {
618                    Some(runs[limit - 1].run_id.clone())
619                } else {
620                    None
621                };
622                runs.truncate(limit);
623                Ok(ListPage { runs, next_cursor })
624            }
625
626            async fn delete(
627                &self,
628                id: &str,
629            ) -> Result<$crate::serve::history::DeleteOutcome, $crate::serve::history::HistoryError>
630            {
631                use sqlx::Row as _;
632                use $crate::serve::history::DeleteOutcome;
633                use $crate::serve::history::HistoryError;
634                use $crate::serve::history::sql;
635                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
636                let status: Option<String> = sqlx::query(&self.stmts.select_status)
637                    .bind(id)
638                    .fetch_optional(&self.pool)
639                    .await
640                    .map_err(backend)?
641                    .map(|r| r.try_get::<String, _>("status"))
642                    .transpose()
643                    .map_err(backend)?;
644                match status {
645                    None => Ok(DeleteOutcome::NotFound),
646                    Some(s) if !sql::parse_status(&s).is_terminal() => {
647                        Ok(DeleteOutcome::StillRunning)
648                    }
649                    Some(_) => {
650                        sqlx::query(&self.stmts.delete)
651                            .bind(id)
652                            .execute(&self.pool)
653                            .await
654                            .map_err(backend)?;
655                        // Drop the run's idempotency claim too, so a replay of
656                        // the key starts fresh instead of 404-ing on the deleted
657                        // record until the claim self-expires (#146 M8). Scoped
658                        // by run_id, so a newer run that re-claimed the same key
659                        // keeps its claim.
660                        sqlx::query(&self.stmts.delete_idem_by_run)
661                            .bind(id)
662                            .execute(&self.pool)
663                            .await
664                            .map_err(backend)?;
665                        Ok(DeleteOutcome::Deleted)
666                    }
667                }
668            }
669
670            async fn purge_expired(
671                &self,
672                retain_for: std::time::Duration,
673            ) -> Result<usize, $crate::serve::history::HistoryError> {
674                use $crate::serve::history::HistoryError;
675                use $crate::serve::history::sql;
676                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
677                let now = chrono::Utc::now();
678                let removed = sqlx::query(&self.stmts.purge_runs)
679                    .bind(sql::threshold(now, retain_for))
680                    .execute(&self.pool)
681                    .await
682                    .map_err(backend)?
683                    .rows_affected() as usize;
684                // Drop expired idempotency claims too (best-effort).
685                let _ = sqlx::query(&self.stmts.purge_idem)
686                    .bind(sql::threshold(now, self.idem_retention))
687                    .execute(&self.pool)
688                    .await;
689                // Drop membership rows that have not heartbeated within the
690                // run-retention window (far longer than the lease, so this never
691                // prunes a live member — that's `live_instances(ttl)`'s job).
692                let _ = sqlx::query(&self.stmts.prune_instances)
693                    .bind(sql::threshold(now, retain_for))
694                    .execute(&self.pool)
695                    .await;
696                Ok(removed)
697            }
698
699            async fn recover_orphans(&self) -> Result<usize, $crate::serve::history::HistoryError> {
700                use sqlx::Row as _;
701                use $crate::serve::history::HistoryError;
702                use $crate::serve::history::RunStatus;
703                use $crate::serve::history::sql;
704                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
705                let now = chrono::Utc::now();
706                // Only non-terminal runs whose lease has expired (the owning
707                // instance is presumed dead). A live instance heartbeats its
708                // runs' leases into the future, so this never fails another
709                // healthy instance's in-flight runs (#146 H7).
710                let rows = sqlx::query(&self.stmts.select_orphans)
711                    .bind(sql::fmt_ts(now))
712                    .fetch_all(&self.pool)
713                    .await
714                    .map_err(backend)?;
715                let mut count = 0usize;
716                for r in &rows {
717                    let body: String = r.try_get("body").map_err(backend)?;
718                    let mut rec = sql::decode_body(&body)?;
719                    rec.status = RunStatus::Failed;
720                    rec.finished_at = Some(now);
721                    rec.error = Some(
722                        "owning serve instance's lease expired before the run finished".into(),
723                    );
724                    if rec.elapsed_secs.is_none()
725                        && let Some(started) = rec.started_at
726                    {
727                        rec.elapsed_secs = (now - started).to_std().ok().map(|d| d.as_secs_f64());
728                    }
729                    self.upsert(&rec).await?;
730                    count += 1;
731                }
732                Ok(count)
733            }
734
735            async fn renew_leases(&self) -> Result<usize, $crate::serve::history::HistoryError> {
736                use $crate::serve::history::HistoryError;
737                use $crate::serve::history::sql;
738                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
739                let new_lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
740                let renewed = sqlx::query(&self.stmts.renew_leases)
741                    .bind(&new_lease)
742                    .bind(&self.instance_id)
743                    .execute(&self.pool)
744                    .await
745                    .map_err(backend)?
746                    .rows_affected() as usize;
747                Ok(renewed)
748            }
749
750            async fn claim_pending(
751                &self,
752                limit: usize,
753            ) -> Result<Vec<$crate::serve::history::RunRecord>, $crate::serve::history::HistoryError>
754            {
755                use sqlx::Row as _;
756                use $crate::serve::history::HistoryError;
757                use $crate::serve::history::RunStatus;
758                use $crate::serve::history::sql;
759                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
760                if limit == 0 {
761                    return Ok(Vec::new());
762                }
763                let now = chrono::Utc::now();
764                let lease = sql::fmt_ts(now + self.lease_ttl);
765
766                // 1. Candidate pending runs (oldest first), with their bodies.
767                let rows = sqlx::query(&self.stmts.select_pending)
768                    .bind(limit as i64)
769                    .fetch_all(&self.pool)
770                    .await
771                    .map_err(backend)?;
772
773                // Per-row conditional claim (1 SELECT + N guarded UPDATEs). The
774                // batch is bounded by the caller's free permits (small), and this
775                // is portable across Postgres + SQLite — deliberately NOT a
776                // Postgres-only `FOR UPDATE SKIP LOCKED`.
777                let mut claimed = Vec::new();
778                for row in &rows {
779                    let run_id: String = row.try_get("run_id").map_err(backend)?;
780                    let body: String = row.try_get("body").map_err(backend)?;
781                    // Flip the record to Running and rewrite the body so the column
782                    // and the (source-of-truth) body stay consistent — a GET right
783                    // after the claim must not show a stale `pending`.
784                    let mut r = sql::decode_body(&body)?;
785                    r.status = RunStatus::Running;
786                    let new_body = sql::encode_body(&r)?;
787                    // 2. Conditional claim — only the first committer wins.
788                    let won = sqlx::query(&self.stmts.claim_one)
789                        .bind(&self.instance_id)
790                        .bind(&lease)
791                        .bind(&new_body)
792                        .bind(&run_id)
793                        .execute(&self.pool)
794                        .await
795                        .map_err(backend)?
796                        .rows_affected();
797                    if won == 1 {
798                        claimed.push(r);
799                    }
800                }
801                Ok(claimed)
802            }
803
804            async fn reclaim_orphans(
805                &self,
806                max_attempts: u32,
807            ) -> Result<$crate::serve::history::ReclaimReport, $crate::serve::history::HistoryError>
808            {
809                use sqlx::Row as _;
810                use $crate::serve::history::HistoryError;
811                use $crate::serve::history::ReclaimReport;
812                use $crate::serve::history::RunStatus;
813                use $crate::serve::history::sql;
814                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
815                let now = chrono::Utc::now();
816                let now_s = sql::fmt_ts(now);
817
818                let rows = sqlx::query(&self.stmts.reclaim_select)
819                    .bind(&now_s)
820                    .fetch_all(&self.pool)
821                    .await
822                    .map_err(backend)?;
823
824                let mut report = ReclaimReport::default();
825                for row in &rows {
826                    let body: String = row.try_get("body").map_err(backend)?;
827                    let mut rec = sql::decode_body(&body)?;
828                    let next_attempt = rec.attempt + 1;
829                    // Cap is on the attempts already made: a run that has been
830                    // reclaimed fewer than `max_attempts` times gets another try;
831                    // once it reaches the cap it is poisoned.
832                    if rec.attempt < max_attempts {
833                        // Re-queue for another instance to re-run.
834                        rec.attempt = next_attempt;
835                        rec.status = RunStatus::Pending;
836                        let new_body = sql::encode_body(&rec)?;
837                        let n = sqlx::query(&self.stmts.reclaim_requeue)
838                            .bind(&new_body)
839                            .bind(&rec.run_id)
840                            .bind(&now_s)
841                            .execute(&self.pool)
842                            .await
843                            .map_err(backend)?
844                            .rows_affected();
845                        if n == 1 {
846                            report.requeued += 1;
847                        }
848                    } else {
849                        // Poison: too many attempts.
850                        rec.attempt = next_attempt;
851                        rec.status = RunStatus::Failed;
852                        rec.finished_at = Some(now);
853                        rec.error = Some(format!(
854                            "run reclaimed {next_attempt} times after its owning instance's \
855                             lease expired; giving up (poison run)"
856                        ));
857                        if rec.elapsed_secs.is_none()
858                            && let Some(started) = rec.started_at
859                        {
860                            rec.elapsed_secs =
861                                (now - started).to_std().ok().map(|d| d.as_secs_f64());
862                        }
863                        let new_body = sql::encode_body(&rec)?;
864                        let n = sqlx::query(&self.stmts.reclaim_fail)
865                            .bind(&now_s)
866                            .bind(&new_body)
867                            .bind(&rec.run_id)
868                            .bind(&now_s)
869                            .execute(&self.pool)
870                            .await
871                            .map_err(backend)?
872                            .rows_affected();
873                        if n == 1 {
874                            report.failed += 1;
875                        }
876                    }
877                }
878                Ok(report)
879            }
880
881            async fn finalize_owned(
882                &self,
883                rec: &$crate::serve::history::RunRecord,
884            ) -> Result<bool, $crate::serve::history::HistoryError> {
885                use $crate::serve::history::HistoryError;
886                use $crate::serve::history::sql;
887                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
888                // Defensive: a terminal record must carry finished_at, or
889                // purge_runs (which requires finished_at IS NOT NULL) can never
890                // reclaim it. Stamp it if a caller left it unset.
891                let mut rec = rec.clone();
892                if rec.status.is_terminal() && rec.finished_at.is_none() {
893                    rec.finished_at = Some(chrono::Utc::now());
894                }
895                let body = sql::encode_body(&rec)?;
896                let finished = rec.finished_at.map(sql::fmt_ts);
897                let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
898                let n = sqlx::query(&self.stmts.finalize_owned)
899                    .bind(rec.status.as_str())
900                    .bind(finished.as_deref())
901                    .bind(&lease)
902                    .bind(&body)
903                    .bind(&rec.run_id)
904                    .bind(&self.instance_id)
905                    .execute(&self.pool)
906                    .await
907                    .map_err(backend)?
908                    .rows_affected();
909                Ok(n == 1)
910            }
911
912            async fn cancel_pending(
913                &self,
914                run_id: &str,
915            ) -> Result<bool, $crate::serve::history::HistoryError> {
916                use sqlx::Row as _;
917                use $crate::serve::history::HistoryError;
918                use $crate::serve::history::RunStatus;
919                use $crate::serve::history::sql;
920                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
921                // Read the pending run's body, flip it to Cancelled, and write back
922                // conditional on it still being pending (loses the race to a claim).
923                let Some(row) = sqlx::query(&self.stmts.select_body)
924                    .bind(run_id)
925                    .fetch_optional(&self.pool)
926                    .await
927                    .map_err(backend)?
928                else {
929                    return Ok(false);
930                };
931                let body: String = row.try_get("body").map_err(backend)?;
932                let mut rec = sql::decode_body(&body)?;
933                if rec.status != RunStatus::Pending {
934                    return Ok(false);
935                }
936                let now = chrono::Utc::now();
937                rec.status = RunStatus::Cancelled;
938                rec.finished_at = Some(now);
939                let new_body = sql::encode_body(&rec)?;
940                let n = sqlx::query(&self.stmts.cancel_pending)
941                    .bind(sql::fmt_ts(now))
942                    .bind(&new_body)
943                    .bind(run_id)
944                    .execute(&self.pool)
945                    .await
946                    .map_err(backend)?
947                    .rows_affected();
948                Ok(n == 1)
949            }
950
951            async fn request_cancel(
952                &self,
953                run_id: &str,
954            ) -> Result<(), $crate::serve::history::HistoryError> {
955                use $crate::serve::history::HistoryError;
956                use $crate::serve::history::sql;
957                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
958                sqlx::query(&self.stmts.request_cancel)
959                    .bind(sql::fmt_ts(chrono::Utc::now()))
960                    .bind(run_id)
961                    .execute(&self.pool)
962                    .await
963                    .map_err(backend)?;
964                Ok(())
965            }
966
967            async fn pending_cancellations(
968                &self,
969            ) -> Result<Vec<String>, $crate::serve::history::HistoryError> {
970                use sqlx::Row as _;
971                use $crate::serve::history::HistoryError;
972                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
973                let rows = sqlx::query(&self.stmts.pending_cancellations)
974                    .bind(&self.instance_id)
975                    .fetch_all(&self.pool)
976                    .await
977                    .map_err(backend)?;
978                let mut ids = Vec::with_capacity(rows.len());
979                for r in &rows {
980                    ids.push(r.try_get::<String, _>("run_id").map_err(backend)?);
981                }
982                Ok(ids)
983            }
984
985            async fn heartbeat_instance(
986                &self,
987                beat: &$crate::serve::history::InstanceHeartbeat,
988            ) -> Result<(), $crate::serve::history::HistoryError> {
989                use $crate::serve::history::HistoryError;
990                use $crate::serve::history::sql;
991                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
992                let now = sql::fmt_ts(chrono::Utc::now());
993                sqlx::query(&self.stmts.heartbeat_instance)
994                    .bind(&self.instance_id)
995                    .bind(sql::fmt_ts(beat.started_at))
996                    .bind(&now)
997                    .bind(beat.listen.as_deref())
998                    .bind(beat.max_concurrent.to_string())
999                    .bind(beat.in_flight.to_string())
1000                    .execute(&self.pool)
1001                    .await
1002                    .map_err(backend)?;
1003                Ok(())
1004            }
1005
1006            async fn live_instances(
1007                &self,
1008                ttl: std::time::Duration,
1009            ) -> Result<Vec<$crate::serve::history::InstanceRecord>, $crate::serve::history::HistoryError>
1010            {
1011                use sqlx::Row as _;
1012                use $crate::serve::history::HistoryError;
1013                use $crate::serve::history::InstanceRecord;
1014                use $crate::serve::history::sql;
1015                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1016                let now = chrono::Utc::now();
1017                let rows = sqlx::query(&self.stmts.live_instances)
1018                    .bind(sql::threshold(now, ttl))
1019                    .fetch_all(&self.pool)
1020                    .await
1021                    .map_err(backend)?;
1022                let parse_dt = |s: &str| {
1023                    chrono::DateTime::parse_from_rfc3339(s)
1024                        .map(|d| d.to_utc())
1025                        .unwrap_or(now)
1026                };
1027                let mut out = Vec::with_capacity(rows.len());
1028                for r in &rows {
1029                    let started: String = r.try_get("started_at").map_err(backend)?;
1030                    let hb: String = r.try_get("last_heartbeat").map_err(backend)?;
1031                    let mc: Option<String> = r.try_get("max_concurrent").map_err(backend)?;
1032                    let inf: Option<String> = r.try_get("in_flight").map_err(backend)?;
1033                    out.push(InstanceRecord {
1034                        instance_id: r.try_get("instance_id").map_err(backend)?,
1035                        started_at: parse_dt(&started),
1036                        last_heartbeat: parse_dt(&hb),
1037                        listen: r.try_get("listen").map_err(backend)?,
1038                        max_concurrent: mc.and_then(|s| s.parse().ok()).unwrap_or(0),
1039                        in_flight: inf.and_then(|s| s.parse().ok()).unwrap_or(0),
1040                    });
1041                }
1042                Ok(out)
1043            }
1044
1045            fn degraded(&self) -> bool {
1046                // A live SQL backend is never self-degraded; the FallbackHistory
1047                // wrapper owns degradation when the backend becomes unreachable.
1048                false
1049            }
1050        }
1051    };
1052}
1053
1054pub(crate) use impl_sql_history;
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059
1060    #[test]
1061    fn fmt_ts_is_fixed_width_and_sortable() {
1062        let a = fmt_ts(
1063            DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1064                .unwrap()
1065                .to_utc(),
1066        );
1067        let b = fmt_ts(
1068            DateTime::parse_from_rfc3339("2026-01-01T00:00:01Z")
1069                .unwrap()
1070                .to_utc(),
1071        );
1072        assert!(a.ends_with('Z'));
1073        assert_eq!(a.len(), b.len(), "fixed width");
1074        assert!(a < b, "lexicographic order matches chronological order");
1075    }
1076
1077    #[test]
1078    fn is_expired_respects_window() {
1079        let now = Utc::now();
1080        let old = fmt_ts(now - chrono::Duration::seconds(120));
1081        assert!(is_expired(&old, now, Duration::from_secs(60)));
1082        assert!(!is_expired(&old, now, Duration::from_secs(600)));
1083        // Unparseable → not expired (conservative).
1084        assert!(!is_expired("not-a-timestamp", now, Duration::ZERO));
1085    }
1086
1087    #[test]
1088    fn parse_status_round_trips_known_and_defaults_failed() {
1089        for s in [
1090            RunStatus::Queued,
1091            RunStatus::Pending,
1092            RunStatus::Running,
1093            RunStatus::Completed,
1094            RunStatus::Failed,
1095            RunStatus::Cancelled,
1096        ] {
1097            assert_eq!(parse_status(s.as_str()), s);
1098        }
1099        assert_eq!(parse_status("garbage"), RunStatus::Failed);
1100    }
1101
1102    #[test]
1103    fn body_round_trips() {
1104        let rec = RunRecord::queued(
1105            "r1".into(),
1106            Some("n".into()),
1107            Default::default(),
1108            Some("idem".into()),
1109            Utc::now(),
1110        );
1111        let encoded = encode_body(&rec).unwrap();
1112        let decoded = decode_body(&encoded).unwrap();
1113        assert_eq!(decoded.run_id, "r1");
1114        assert_eq!(decoded.idempotency_key.as_deref(), Some("idem"));
1115    }
1116
1117    #[test]
1118    fn postgres_and_sqlite_statements_differ_only_in_placeholders() {
1119        let pg = Stmts::new(Dialect::Postgres);
1120        let lite = Stmts::new(Dialect::Sqlite);
1121        assert!(pg.upsert.contains("$1") && lite.upsert.contains('?'));
1122        assert!(pg.list.contains("$13") && lite.list.contains('?'));
1123        // Both target the same tables / conflict targets.
1124        assert!(pg.insert_idem.contains("ON CONFLICT (key) DO NOTHING"));
1125        assert!(lite.insert_idem.contains("ON CONFLICT (key) DO NOTHING"));
1126        assert!(pg.claim_one.contains("$3") && lite.claim_one.contains('?'));
1127        assert!(pg.heartbeat_instance.contains("faucet_serve_instances"));
1128        assert!(lite.heartbeat_instance.contains("faucet_serve_instances"));
1129    }
1130}