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    // Source shards for clustered Mode B (#230). One row per (run, shard);
65    // `owner`/`lease_expires_at`/`attempt` reuse Mode A's lease-fencing semantics
66    // at shard granularity. `size_estimate` (an integer stored as TEXT) drives
67    // skew-aware, largest-first claiming. `descriptor` is the opaque connector
68    // shard spec, replayed to the worker that claims the shard.
69    "CREATE TABLE IF NOT EXISTS faucet_serve_shards (\
70        run_id TEXT NOT NULL,\
71        shard_id TEXT NOT NULL,\
72        descriptor TEXT NOT NULL,\
73        size_estimate TEXT,\
74        status TEXT NOT NULL,\
75        owner TEXT,\
76        lease_expires_at TEXT,\
77        attempt TEXT NOT NULL,\
78        finished_at TEXT,\
79        PRIMARY KEY (run_id, shard_id))",
80    "CREATE INDEX IF NOT EXISTS faucet_serve_shards_claim_idx \
81        ON faucet_serve_shards (status, lease_expires_at)",
82];
83
84/// SQL placeholder dialect.
85#[derive(Clone, Copy, Debug)]
86pub enum Dialect {
87    Postgres,
88    Sqlite,
89}
90
91/// Prepared-statement text for a backend, built once per dialect at connect time.
92pub struct Stmts {
93    /// (`cancel_requested` is intentionally NOT written by `upsert` — it is set
94    /// only via `request_cancel` and cleared by `reclaim_requeue`; it defaults to
95    /// NULL on insert.)
96    pub upsert: String,
97    pub select_body: String,
98    pub select_status: String,
99    pub select_submitted: String,
100    pub delete: String,
101    pub list: String,
102    pub purge_runs: String,
103    pub purge_idem: String,
104    /// Select non-terminal runs whose owning instance's lease has expired (or
105    /// is unset) — the orphans this instance may safely fail. Param: `now`.
106    pub select_orphans: String,
107    /// Extend the lease of this instance's own non-terminal runs (heartbeat).
108    /// Params: `new_lease_expiry`, `instance_id`.
109    pub renew_leases: String,
110    pub insert_idem: String,
111    pub select_idem: String,
112    pub takeover_idem: String,
113    /// Delete the idempotency claim(s) that point at a given run — used when a
114    /// run is deleted so a replay of the key starts fresh rather than 404-ing
115    /// on the missing record (#146 M8). Scoped by `run_id`, so a newer run that
116    /// re-claimed the same key keeps its claim.
117    pub delete_idem_by_run: String,
118    /// Cluster dispatcher: fetch oldest pending runs up to a given limit.
119    pub select_pending: String,
120    /// Cluster dispatcher: atomically claim a pending run (set owner + running).
121    pub claim_one: String,
122    /// Cluster reclaimer: select expired running runs for requeue/fail evaluation.
123    /// NOTE: `'queued'` is the single-instance status; cluster runs flow
124    /// `pending → running`, so the failover reclaimer covers `'running'` only.
125    pub reclaim_select: String,
126    /// Cluster reclaimer: requeue an expired running run back to pending.
127    pub reclaim_requeue: String,
128    /// Cluster reclaimer: fail an expired running run that cannot be requeued.
129    pub reclaim_fail: String,
130    /// Finalize a run owned by this instance (terminal status update).
131    pub finalize_owned: String,
132    /// Cancel a pending run directly (transition pending → cancelled).
133    pub cancel_pending: String,
134    /// Request cancellation of an in-flight run owned by another instance.
135    pub request_cancel: String,
136    /// List run IDs owned by this instance that have a pending cancellation request.
137    pub pending_cancellations: String,
138    /// Upsert this instance's membership heartbeat into `faucet_serve_instances`.
139    pub heartbeat_instance: String,
140    /// List instances whose last heartbeat is at or after a given threshold.
141    pub live_instances: String,
142    /// Prune instances whose last heartbeat is before a given threshold.
143    pub prune_instances: String,
144    // ── Source shards (Mode B, #230) ─────────────────────────────────────────
145    /// Idempotent shard insert (`ON CONFLICT (run_id, shard_id) DO NOTHING`).
146    pub insert_shard: String,
147    /// Select claimable pending shards joined to their run body, largest first.
148    pub claim_shards_select: String,
149    /// Atomically claim one pending shard for this instance.
150    pub claim_shard_one: String,
151    /// Heartbeat this instance's running shards.
152    pub renew_shard_leases: String,
153    /// Select expired-lease running shards for requeue/fail evaluation.
154    pub reclaim_shards_select: String,
155    /// Requeue an expired running shard back to pending (attempt++).
156    pub reclaim_shard_requeue: String,
157    /// Fail an expired running shard that exhausted its attempts (poison).
158    pub reclaim_shard_fail: String,
159    /// Owner-fenced terminal write for one shard.
160    pub finalize_shard: String,
161    /// Status counts for a run's shards.
162    pub shard_progress: String,
163    /// Distinct run_ids for which THIS instance owns a `running` shard whose
164    /// parent run has a pending cancellation request (cross-instance shard
165    /// cancel, F10). Param: `instance_id`.
166    pub pending_shard_cancellations: String,
167    /// Select run_ids of `sharded` parents (candidates to finalize once all
168    /// their shards are terminal, F11).
169    pub select_sharded_parents: String,
170    /// Status-fenced terminal write for a `sharded` parent (F11). A benign
171    /// double-finalize across instances is a no-op: the guard requires the
172    /// parent to still be `sharded`. Does NOT re-arm owner/lease.
173    pub finalize_sharded_parent: String,
174    /// Delete a run's shard rows (paired with [`delete`](Self::delete) so a
175    /// deleted run leaves no orphaned shard rows behind, F25). Param: `run_id`.
176    pub delete_shards_by_run: String,
177    /// Purge shard rows whose parent run no longer exists (run-record purged by
178    /// retention, F25). No params — a set-difference against `faucet_serve_runs`.
179    pub purge_orphan_shards: String,
180}
181
182impl Stmts {
183    pub fn new(dialect: Dialect) -> Self {
184        match dialect {
185            Dialect::Postgres => Self::postgres(),
186            Dialect::Sqlite => Self::sqlite(),
187        }
188    }
189
190    fn postgres() -> Self {
191        Self {
192            upsert: "INSERT INTO faucet_serve_runs \
193                (run_id,name,status,submitted_at,finished_at,idempotency_key,owner,lease_expires_at,body) \
194                VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) \
195                ON CONFLICT (run_id) DO UPDATE SET \
196                name=excluded.name,status=excluded.status,submitted_at=excluded.submitted_at,\
197                finished_at=excluded.finished_at,idempotency_key=excluded.idempotency_key,\
198                owner=excluded.owner,lease_expires_at=excluded.lease_expires_at,\
199                body=excluded.body"
200                .into(),
201            select_body: "SELECT body FROM faucet_serve_runs WHERE run_id=$1".into(),
202            select_status: "SELECT status FROM faucet_serve_runs WHERE run_id=$1".into(),
203            select_submitted: "SELECT submitted_at FROM faucet_serve_runs WHERE run_id=$1".into(),
204            delete: "DELETE FROM faucet_serve_runs WHERE run_id=$1".into(),
205            // Casts make the parameter types explicit so `$n IS NULL` cannot trip
206            // Postgres' "could not determine data type of parameter" check.
207            list: "SELECT body FROM faucet_serve_runs \
208                WHERE ($1::text IS NULL OR status = $2::text) \
209                AND ($3::text IS NULL OR name = $4::text) \
210                AND ($5::text IS NULL OR submitted_at >= $6::text) \
211                AND ($7::text IS NULL OR submitted_at <= $8::text) \
212                AND ($9::text IS NULL OR (submitted_at < $10::text \
213                    OR (submitted_at = $11::text AND run_id < $12::text))) \
214                ORDER BY submitted_at DESC, run_id DESC LIMIT $13"
215                .into(),
216            purge_runs: "DELETE FROM faucet_serve_runs \
217                WHERE status IN ('completed','failed','cancelled') \
218                AND finished_at IS NOT NULL AND finished_at < $1"
219                .into(),
220            purge_idem: "DELETE FROM faucet_serve_idem WHERE claimed_at < $1".into(),
221            select_orphans: "SELECT body FROM faucet_serve_runs \
222                WHERE status IN ('queued','running') \
223                AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
224                .into(),
225            renew_leases: "UPDATE faucet_serve_runs SET lease_expires_at = $1 \
226                WHERE owner = $2 AND status IN ('queued','running')"
227                .into(),
228            insert_idem: "INSERT INTO faucet_serve_idem (key,run_id,fingerprint,claimed_at) \
229                VALUES ($1,$2,$3,$4) ON CONFLICT (key) DO NOTHING"
230                .into(),
231            select_idem: "SELECT run_id,fingerprint,claimed_at FROM faucet_serve_idem WHERE key=$1"
232                .into(),
233            takeover_idem: "UPDATE faucet_serve_idem \
234                SET run_id=$1,fingerprint=$2,claimed_at=$3 WHERE key=$4 AND claimed_at=$5"
235                .into(),
236            delete_idem_by_run: "DELETE FROM faucet_serve_idem WHERE run_id=$1".into(),
237            select_pending: "SELECT run_id, body FROM faucet_serve_runs \
238                WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT $1"
239                .into(),
240            claim_one: "UPDATE faucet_serve_runs \
241                SET owner = $1, status = 'running', lease_expires_at = $2, body = $3 \
242                WHERE run_id = $4 AND status = 'pending'"
243                .into(),
244            reclaim_select: "SELECT body FROM faucet_serve_runs \
245                WHERE status = 'running' \
246                AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
247                .into(),
248            reclaim_requeue: "UPDATE faucet_serve_runs \
249                SET status = 'pending', owner = NULL, lease_expires_at = NULL, \
250                    cancel_requested = NULL, body = $1 \
251                WHERE run_id = $2 AND status = 'running' \
252                AND (lease_expires_at IS NULL OR lease_expires_at < $3)"
253                .into(),
254            reclaim_fail: "UPDATE faucet_serve_runs \
255                SET status = 'failed', finished_at = $1, body = $2, owner = NULL \
256                WHERE run_id = $3 AND status = 'running' \
257                AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
258                .into(),
259            finalize_owned: "UPDATE faucet_serve_runs \
260                SET status = $1, finished_at = $2, lease_expires_at = $3, body = $4 \
261                WHERE run_id = $5 AND owner = $6"
262                .into(),
263            cancel_pending: "UPDATE faucet_serve_runs \
264                SET status = 'cancelled', finished_at = $1, body = $2 \
265                WHERE run_id = $3 AND status = 'pending'"
266                .into(),
267            request_cancel: "UPDATE faucet_serve_runs \
268                SET cancel_requested = $1 WHERE run_id = $2 AND status IN ('running','sharded')"
269                .into(),
270            pending_cancellations: "SELECT run_id FROM faucet_serve_runs \
271                WHERE status = 'running' AND owner = $1 AND cancel_requested IS NOT NULL"
272                .into(),
273            heartbeat_instance: "INSERT INTO faucet_serve_instances \
274                (instance_id, started_at, last_heartbeat, listen, max_concurrent, in_flight) \
275                VALUES ($1,$2,$3,$4,$5,$6) \
276                ON CONFLICT (instance_id) DO UPDATE SET \
277                last_heartbeat = excluded.last_heartbeat, listen = excluded.listen, \
278                max_concurrent = excluded.max_concurrent, in_flight = excluded.in_flight"
279                .into(),
280            live_instances: "SELECT instance_id, started_at, last_heartbeat, listen, \
281                max_concurrent, in_flight FROM faucet_serve_instances \
282                WHERE last_heartbeat >= $1"
283                .into(),
284            prune_instances: "DELETE FROM faucet_serve_instances WHERE last_heartbeat < $1".into(),
285            insert_shard: "INSERT INTO faucet_serve_shards \
286                (run_id, shard_id, descriptor, size_estimate, status, attempt) \
287                VALUES ($1,$2,$3,$4,'pending','0') \
288                ON CONFLICT (run_id, shard_id) DO NOTHING"
289                .into(),
290            claim_shards_select: "SELECT s.run_id, s.shard_id, s.descriptor, r.body \
291                FROM faucet_serve_shards s JOIN faucet_serve_runs r ON r.run_id = s.run_id \
292                WHERE s.status = 'pending' \
293                ORDER BY CAST(COALESCE(s.size_estimate, '0') AS BIGINT) DESC, s.run_id, s.shard_id \
294                LIMIT $1"
295                .into(),
296            claim_shard_one: "UPDATE faucet_serve_shards \
297                SET owner = $1, status = 'running', lease_expires_at = $2 \
298                WHERE run_id = $3 AND shard_id = $4 AND status = 'pending'"
299                .into(),
300            renew_shard_leases: "UPDATE faucet_serve_shards SET lease_expires_at = $1 \
301                WHERE owner = $2 AND status = 'running'"
302                .into(),
303            reclaim_shards_select: "SELECT run_id, shard_id, attempt FROM faucet_serve_shards \
304                WHERE status = 'running' \
305                AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
306                .into(),
307            reclaim_shard_requeue: "UPDATE faucet_serve_shards \
308                SET status = 'pending', owner = NULL, lease_expires_at = NULL, attempt = $1 \
309                WHERE run_id = $2 AND shard_id = $3 AND status = 'running' \
310                AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
311                .into(),
312            reclaim_shard_fail: "UPDATE faucet_serve_shards \
313                SET status = 'failed', finished_at = $1, owner = NULL \
314                WHERE run_id = $2 AND shard_id = $3 AND status = 'running' \
315                AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
316                .into(),
317            finalize_shard: "UPDATE faucet_serve_shards \
318                SET status = $1, finished_at = $2 \
319                WHERE run_id = $3 AND shard_id = $4 AND owner = $5 AND status = 'running'"
320                .into(),
321            shard_progress: "SELECT status, COUNT(*) AS n FROM faucet_serve_shards \
322                WHERE run_id = $1 GROUP BY status"
323                .into(),
324            pending_shard_cancellations: "SELECT DISTINCT s.run_id \
325                FROM faucet_serve_shards s \
326                JOIN faucet_serve_runs r ON r.run_id = s.run_id \
327                WHERE s.owner = $1 AND s.status = 'running' \
328                AND r.cancel_requested IS NOT NULL"
329                .into(),
330            select_sharded_parents: "SELECT run_id FROM faucet_serve_runs \
331                WHERE status = 'sharded'"
332                .into(),
333            finalize_sharded_parent: "UPDATE faucet_serve_runs \
334                SET status = $1, finished_at = $2, body = $3 \
335                WHERE run_id = $4 AND status = 'sharded'"
336                .into(),
337            delete_shards_by_run: "DELETE FROM faucet_serve_shards WHERE run_id = $1".into(),
338            purge_orphan_shards: "DELETE FROM faucet_serve_shards \
339                WHERE run_id NOT IN (SELECT run_id FROM faucet_serve_runs)"
340                .into(),
341        }
342    }
343
344    fn sqlite() -> Self {
345        Self {
346            upsert: "INSERT INTO faucet_serve_runs \
347                (run_id,name,status,submitted_at,finished_at,idempotency_key,owner,lease_expires_at,body) \
348                VALUES (?,?,?,?,?,?,?,?,?) \
349                ON CONFLICT (run_id) DO UPDATE SET \
350                name=excluded.name,status=excluded.status,submitted_at=excluded.submitted_at,\
351                finished_at=excluded.finished_at,idempotency_key=excluded.idempotency_key,\
352                owner=excluded.owner,lease_expires_at=excluded.lease_expires_at,\
353                body=excluded.body"
354                .into(),
355            select_body: "SELECT body FROM faucet_serve_runs WHERE run_id=?".into(),
356            select_status: "SELECT status FROM faucet_serve_runs WHERE run_id=?".into(),
357            select_submitted: "SELECT submitted_at FROM faucet_serve_runs WHERE run_id=?".into(),
358            delete: "DELETE FROM faucet_serve_runs WHERE run_id=?".into(),
359            list: "SELECT body FROM faucet_serve_runs \
360                WHERE (? IS NULL OR status = ?) \
361                AND (? IS NULL OR name = ?) \
362                AND (? IS NULL OR submitted_at >= ?) \
363                AND (? IS NULL OR submitted_at <= ?) \
364                AND (? IS NULL OR (submitted_at < ? \
365                    OR (submitted_at = ? AND run_id < ?))) \
366                ORDER BY submitted_at DESC, run_id DESC LIMIT ?"
367                .into(),
368            purge_runs: "DELETE FROM faucet_serve_runs \
369                WHERE status IN ('completed','failed','cancelled') \
370                AND finished_at IS NOT NULL AND finished_at < ?"
371                .into(),
372            purge_idem: "DELETE FROM faucet_serve_idem WHERE claimed_at < ?".into(),
373            select_orphans: "SELECT body FROM faucet_serve_runs \
374                WHERE status IN ('queued','running') \
375                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
376                .into(),
377            renew_leases: "UPDATE faucet_serve_runs SET lease_expires_at = ? \
378                WHERE owner = ? AND status IN ('queued','running')"
379                .into(),
380            insert_idem: "INSERT INTO faucet_serve_idem (key,run_id,fingerprint,claimed_at) \
381                VALUES (?,?,?,?) ON CONFLICT (key) DO NOTHING"
382                .into(),
383            select_idem: "SELECT run_id,fingerprint,claimed_at FROM faucet_serve_idem WHERE key=?"
384                .into(),
385            takeover_idem: "UPDATE faucet_serve_idem \
386                SET run_id=?,fingerprint=?,claimed_at=? WHERE key=? AND claimed_at=?"
387                .into(),
388            delete_idem_by_run: "DELETE FROM faucet_serve_idem WHERE run_id=?".into(),
389            select_pending: "SELECT run_id, body FROM faucet_serve_runs \
390                WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT ?"
391                .into(),
392            claim_one: "UPDATE faucet_serve_runs \
393                SET owner = ?, status = 'running', lease_expires_at = ?, body = ? \
394                WHERE run_id = ? AND status = 'pending'"
395                .into(),
396            reclaim_select: "SELECT body FROM faucet_serve_runs \
397                WHERE status = 'running' \
398                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
399                .into(),
400            reclaim_requeue: "UPDATE faucet_serve_runs \
401                SET status = 'pending', owner = NULL, lease_expires_at = NULL, \
402                    cancel_requested = NULL, body = ? \
403                WHERE run_id = ? AND status = 'running' \
404                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
405                .into(),
406            reclaim_fail: "UPDATE faucet_serve_runs \
407                SET status = 'failed', finished_at = ?, body = ?, owner = NULL \
408                WHERE run_id = ? AND status = 'running' \
409                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
410                .into(),
411            finalize_owned: "UPDATE faucet_serve_runs \
412                SET status = ?, finished_at = ?, lease_expires_at = ?, body = ? \
413                WHERE run_id = ? AND owner = ?"
414                .into(),
415            cancel_pending: "UPDATE faucet_serve_runs \
416                SET status = 'cancelled', finished_at = ?, body = ? \
417                WHERE run_id = ? AND status = 'pending'"
418                .into(),
419            request_cancel: "UPDATE faucet_serve_runs \
420                SET cancel_requested = ? WHERE run_id = ? AND status IN ('running','sharded')"
421                .into(),
422            pending_cancellations: "SELECT run_id FROM faucet_serve_runs \
423                WHERE status = 'running' AND owner = ? AND cancel_requested IS NOT NULL"
424                .into(),
425            heartbeat_instance: "INSERT INTO faucet_serve_instances \
426                (instance_id, started_at, last_heartbeat, listen, max_concurrent, in_flight) \
427                VALUES (?,?,?,?,?,?) \
428                ON CONFLICT (instance_id) DO UPDATE SET \
429                last_heartbeat = excluded.last_heartbeat, listen = excluded.listen, \
430                max_concurrent = excluded.max_concurrent, in_flight = excluded.in_flight"
431                .into(),
432            live_instances: "SELECT instance_id, started_at, last_heartbeat, listen, \
433                max_concurrent, in_flight FROM faucet_serve_instances \
434                WHERE last_heartbeat >= ?"
435                .into(),
436            prune_instances: "DELETE FROM faucet_serve_instances WHERE last_heartbeat < ?".into(),
437            insert_shard: "INSERT INTO faucet_serve_shards \
438                (run_id, shard_id, descriptor, size_estimate, status, attempt) \
439                VALUES (?,?,?,?,'pending','0') \
440                ON CONFLICT (run_id, shard_id) DO NOTHING"
441                .into(),
442            claim_shards_select: "SELECT s.run_id, s.shard_id, s.descriptor, r.body \
443                FROM faucet_serve_shards s JOIN faucet_serve_runs r ON r.run_id = s.run_id \
444                WHERE s.status = 'pending' \
445                ORDER BY CAST(COALESCE(s.size_estimate, '0') AS INTEGER) DESC, s.run_id, s.shard_id \
446                LIMIT ?"
447                .into(),
448            claim_shard_one: "UPDATE faucet_serve_shards \
449                SET owner = ?, status = 'running', lease_expires_at = ? \
450                WHERE run_id = ? AND shard_id = ? AND status = 'pending'"
451                .into(),
452            renew_shard_leases: "UPDATE faucet_serve_shards SET lease_expires_at = ? \
453                WHERE owner = ? AND status = 'running'"
454                .into(),
455            reclaim_shards_select: "SELECT run_id, shard_id, attempt FROM faucet_serve_shards \
456                WHERE status = 'running' \
457                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
458                .into(),
459            reclaim_shard_requeue: "UPDATE faucet_serve_shards \
460                SET status = 'pending', owner = NULL, lease_expires_at = NULL, attempt = ? \
461                WHERE run_id = ? AND shard_id = ? AND status = 'running' \
462                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
463                .into(),
464            reclaim_shard_fail: "UPDATE faucet_serve_shards \
465                SET status = 'failed', finished_at = ?, owner = NULL \
466                WHERE run_id = ? AND shard_id = ? AND status = 'running' \
467                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
468                .into(),
469            finalize_shard: "UPDATE faucet_serve_shards \
470                SET status = ?, finished_at = ? \
471                WHERE run_id = ? AND shard_id = ? AND owner = ? AND status = 'running'"
472                .into(),
473            shard_progress: "SELECT status, COUNT(*) AS n FROM faucet_serve_shards \
474                WHERE run_id = ? GROUP BY status"
475                .into(),
476            pending_shard_cancellations: "SELECT DISTINCT s.run_id \
477                FROM faucet_serve_shards s \
478                JOIN faucet_serve_runs r ON r.run_id = s.run_id \
479                WHERE s.owner = ? AND s.status = 'running' \
480                AND r.cancel_requested IS NOT NULL"
481                .into(),
482            select_sharded_parents: "SELECT run_id FROM faucet_serve_runs \
483                WHERE status = 'sharded'"
484                .into(),
485            finalize_sharded_parent: "UPDATE faucet_serve_runs \
486                SET status = ?, finished_at = ?, body = ? \
487                WHERE run_id = ? AND status = 'sharded'"
488                .into(),
489            delete_shards_by_run: "DELETE FROM faucet_serve_shards WHERE run_id = ?".into(),
490            purge_orphan_shards: "DELETE FROM faucet_serve_shards \
491                WHERE run_id NOT IN (SELECT run_id FROM faucet_serve_runs)"
492                .into(),
493        }
494    }
495}
496
497/// Bounded retry count for the atomic idempotency claim (handles a claim being
498/// purged concurrently between the insert attempt and the read-back).
499pub const CLAIM_ATTEMPTS: usize = 4;
500
501/// Fixed-width RFC3339 (nanoseconds + `Z`) — lexicographically sortable.
502pub fn fmt_ts(dt: DateTime<Utc>) -> String {
503    dt.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)
504}
505
506/// True when a claim timestamped `claimed_at` (RFC3339) is older than `window`.
507/// An unparseable or future timestamp is treated as **not** expired (safe: it
508/// won't be silently re-claimed).
509pub fn is_expired(claimed_at: &str, now: DateTime<Utc>, window: Duration) -> bool {
510    match DateTime::parse_from_rfc3339(claimed_at) {
511        Ok(t) => now
512            .signed_duration_since(t.with_timezone(&Utc))
513            .to_std()
514            .map(|age| age >= window)
515            .unwrap_or(false),
516        Err(_) => false,
517    }
518}
519
520/// RFC3339 timestamp `window` before `now` (the purge / expiry threshold).
521pub fn threshold(now: DateTime<Utc>, window: Duration) -> String {
522    let delta =
523        chrono::Duration::from_std(window).unwrap_or_else(|_| chrono::Duration::days(36_500));
524    fmt_ts(now - delta)
525}
526
527pub fn encode_body(rec: &RunRecord) -> Result<String, HistoryError> {
528    serde_json::to_string(rec).map_err(|e| HistoryError::Backend(format!("encode run record: {e}")))
529}
530
531pub fn decode_body(body: &str) -> Result<RunRecord, HistoryError> {
532    serde_json::from_str(body).map_err(|e| HistoryError::Backend(format!("decode run record: {e}")))
533}
534
535pub fn parse_status(s: &str) -> RunStatus {
536    match s {
537        "queued" => RunStatus::Queued,
538        "pending" => RunStatus::Pending,
539        "running" => RunStatus::Running,
540        "sharded" => RunStatus::Sharded,
541        "completed" => RunStatus::Completed,
542        "cancelled" => RunStatus::Cancelled,
543        _ => RunStatus::Failed,
544    }
545}
546
547/// Generate a concrete `RunHistory` implementation over a specific `sqlx` pool.
548/// `$name` is the backend struct, `$pool` its `sqlx` pool type. The struct holds
549/// the pool, the idempotency retention window, and the dialect's [`Stmts`].
550macro_rules! impl_sql_history {
551    ($name:ident, $pool:ty) => {
552        /// SQL-backed [`RunHistory`](crate::serve::history::RunHistory). See
553        /// [`crate::serve::history::sql`] for the shared schema + semantics.
554        pub struct $name {
555            pool: $pool,
556            idem_retention: std::time::Duration,
557            /// This serve instance's id, stamped as `owner` on every upsert.
558            instance_id: String,
559            /// How far ahead each upsert / heartbeat pushes a run's lease.
560            lease_ttl: std::time::Duration,
561            stmts: $crate::serve::history::sql::Stmts,
562        }
563
564        impl $name {
565            /// Assemble from an already-connected pool (used by `connect`).
566            pub fn from_parts(
567                pool: $pool,
568                idem_retention: std::time::Duration,
569                lease_ttl: std::time::Duration,
570                instance_id: String,
571                stmts: $crate::serve::history::sql::Stmts,
572            ) -> Self {
573                Self {
574                    pool,
575                    idem_retention,
576                    instance_id,
577                    lease_ttl,
578                    stmts,
579                }
580            }
581
582            /// Borrow the underlying pool (tests close it to exercise fallback).
583            pub fn pool(&self) -> &$pool {
584                &self.pool
585            }
586        }
587
588        #[async_trait::async_trait]
589        impl $crate::serve::history::RunHistory for $name {
590            async fn claim_idempotency(
591                &self,
592                key: &str,
593                fingerprint: &str,
594                run_id: &str,
595                window: std::time::Duration,
596            ) -> Result<$crate::serve::history::Claim, $crate::serve::history::HistoryError> {
597                use sqlx::Row as _;
598                use $crate::serve::history::Claim;
599                use $crate::serve::history::HistoryError;
600                use $crate::serve::history::sql;
601
602                let now = chrono::Utc::now();
603                let now_s = sql::fmt_ts(now);
604                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
605
606                for _ in 0..sql::CLAIM_ATTEMPTS {
607                    // 1) Atomic first-claim: the winner inserts exactly one row.
608                    let inserted = sqlx::query(&self.stmts.insert_idem)
609                        .bind(key)
610                        .bind(run_id)
611                        .bind(fingerprint)
612                        .bind(&now_s)
613                        .execute(&self.pool)
614                        .await
615                        .map_err(backend)?
616                        .rows_affected();
617                    if inserted == 1 {
618                        return Ok(Claim::Fresh);
619                    }
620                    // 2) Conflict: inspect the existing claim.
621                    let Some(row) = sqlx::query(&self.stmts.select_idem)
622                        .bind(key)
623                        .fetch_optional(&self.pool)
624                        .await
625                        .map_err(backend)?
626                    else {
627                        // Vanished between the insert and the read — retry.
628                        continue;
629                    };
630                    let existing_run: String = row.try_get("run_id").map_err(backend)?;
631                    let existing_fp: String = row.try_get("fingerprint").map_err(backend)?;
632                    let claimed_at: String = row.try_get("claimed_at").map_err(backend)?;
633
634                    if sql::is_expired(&claimed_at, now, window) {
635                        // 3) Optimistic, expiry-guarded takeover: only the request
636                        // that still sees `claimed_at` succeeds.
637                        let took = sqlx::query(&self.stmts.takeover_idem)
638                            .bind(run_id)
639                            .bind(fingerprint)
640                            .bind(&now_s)
641                            .bind(key)
642                            .bind(&claimed_at)
643                            .execute(&self.pool)
644                            .await
645                            .map_err(backend)?
646                            .rows_affected();
647                        if took == 1 {
648                            return Ok(Claim::Fresh);
649                        }
650                        continue; // lost the race; re-evaluate
651                    }
652                    return Ok(if existing_fp == fingerprint {
653                        Claim::Replay(existing_run)
654                    } else {
655                        Claim::Conflict
656                    });
657                }
658                // Pathological contention only. Conservative: a 409 is safer than
659                // risking a duplicate run.
660                tracing::warn!(
661                    key,
662                    "idempotency claim exhausted retries; reporting conflict"
663                );
664                Ok(Claim::Conflict)
665            }
666
667            async fn upsert(
668                &self,
669                rec: &$crate::serve::history::RunRecord,
670            ) -> Result<(), $crate::serve::history::HistoryError> {
671                use $crate::serve::history::HistoryError;
672                use $crate::serve::history::sql;
673                let body = sql::encode_body(rec)?;
674                let submitted = sql::fmt_ts(rec.submitted_at);
675                let finished = rec.finished_at.map(sql::fmt_ts);
676                // Stamp this instance as the owner and start/renew the lease.
677                // The owner/lease are SQL-column-only (never in the record body),
678                // so the heartbeat can extend a lease without a body read-modify-
679                // write race (#146 H7).
680                let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
681                sqlx::query(&self.stmts.upsert)
682                    .bind(&rec.run_id)
683                    .bind(rec.name.as_deref())
684                    .bind(rec.status.as_str())
685                    .bind(&submitted)
686                    .bind(finished.as_deref())
687                    .bind(rec.idempotency_key.as_deref())
688                    .bind(&self.instance_id)
689                    .bind(&lease)
690                    .bind(&body)
691                    .execute(&self.pool)
692                    .await
693                    .map_err(|e| HistoryError::Backend(e.to_string()))?;
694                Ok(())
695            }
696
697            async fn get(
698                &self,
699                id: &str,
700            ) -> Result<
701                Option<$crate::serve::history::RunRecord>,
702                $crate::serve::history::HistoryError,
703            > {
704                use sqlx::Row as _;
705                use $crate::serve::history::HistoryError;
706                use $crate::serve::history::sql;
707                let row = sqlx::query(&self.stmts.select_body)
708                    .bind(id)
709                    .fetch_optional(&self.pool)
710                    .await
711                    .map_err(|e| HistoryError::Backend(e.to_string()))?;
712                match row {
713                    None => Ok(None),
714                    Some(r) => {
715                        let body: String = r
716                            .try_get("body")
717                            .map_err(|e| HistoryError::Backend(e.to_string()))?;
718                        Ok(Some(sql::decode_body(&body)?))
719                    }
720                }
721            }
722
723            async fn list(
724                &self,
725                filter: &$crate::serve::history::ListFilter,
726            ) -> Result<$crate::serve::history::ListPage, $crate::serve::history::HistoryError>
727            {
728                use sqlx::Row as _;
729                use $crate::serve::history::HistoryError;
730                use $crate::serve::history::ListPage;
731                use $crate::serve::history::sql;
732                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
733
734                // Resolve the cursor's submitted_at for keyset pagination. An
735                // unknown cursor is ignored (page starts from the top), matching
736                // the memory backend.
737                let cursor_ts: Option<String> = match &filter.cursor {
738                    None => None,
739                    Some(c) => sqlx::query(&self.stmts.select_submitted)
740                        .bind(c)
741                        .fetch_optional(&self.pool)
742                        .await
743                        .map_err(backend)?
744                        .map(|r| r.try_get::<String, _>("submitted_at"))
745                        .transpose()
746                        .map_err(backend)?,
747                };
748                let cur_id = if cursor_ts.is_some() {
749                    filter.cursor.as_deref()
750                } else {
751                    None
752                };
753
754                let status_s = filter.status.map(|s| s.as_str());
755                let name_s = filter.name.as_deref();
756                let since_s = filter.since.map(sql::fmt_ts);
757                let until_s = filter.until.map(sql::fmt_ts);
758                let limit = filter.limit.max(1);
759                let fetch_n = limit as i64 + 1; // +1 to detect a next page
760
761                let rows = sqlx::query(&self.stmts.list)
762                    .bind(status_s)
763                    .bind(status_s)
764                    .bind(name_s)
765                    .bind(name_s)
766                    .bind(since_s.as_deref())
767                    .bind(since_s.as_deref())
768                    .bind(until_s.as_deref())
769                    .bind(until_s.as_deref())
770                    .bind(cursor_ts.as_deref())
771                    .bind(cursor_ts.as_deref())
772                    .bind(cursor_ts.as_deref())
773                    .bind(cur_id)
774                    .bind(fetch_n)
775                    .fetch_all(&self.pool)
776                    .await
777                    .map_err(backend)?;
778
779                let mut runs = Vec::with_capacity(rows.len());
780                for r in &rows {
781                    let body: String = r.try_get("body").map_err(backend)?;
782                    runs.push(sql::decode_body(&body)?);
783                }
784                let next_cursor = if runs.len() > limit {
785                    Some(runs[limit - 1].run_id.clone())
786                } else {
787                    None
788                };
789                runs.truncate(limit);
790                Ok(ListPage { runs, next_cursor })
791            }
792
793            async fn delete(
794                &self,
795                id: &str,
796            ) -> Result<$crate::serve::history::DeleteOutcome, $crate::serve::history::HistoryError>
797            {
798                use sqlx::Row as _;
799                use $crate::serve::history::DeleteOutcome;
800                use $crate::serve::history::HistoryError;
801                use $crate::serve::history::sql;
802                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
803                let status: Option<String> = sqlx::query(&self.stmts.select_status)
804                    .bind(id)
805                    .fetch_optional(&self.pool)
806                    .await
807                    .map_err(backend)?
808                    .map(|r| r.try_get::<String, _>("status"))
809                    .transpose()
810                    .map_err(backend)?;
811                match status {
812                    None => Ok(DeleteOutcome::NotFound),
813                    Some(s) if !sql::parse_status(&s).is_terminal() => {
814                        Ok(DeleteOutcome::StillRunning)
815                    }
816                    Some(_) => {
817                        sqlx::query(&self.stmts.delete)
818                            .bind(id)
819                            .execute(&self.pool)
820                            .await
821                            .map_err(backend)?;
822                        // Drop the run's idempotency claim too, so a replay of
823                        // the key starts fresh instead of 404-ing on the deleted
824                        // record until the claim self-expires (#146 M8). Scoped
825                        // by run_id, so a newer run that re-claimed the same key
826                        // keeps its claim.
827                        sqlx::query(&self.stmts.delete_idem_by_run)
828                            .bind(id)
829                            .execute(&self.pool)
830                            .await
831                            .map_err(backend)?;
832                        // Drop the run's shard rows too (Mode B, #230), so a
833                        // deleted run leaves no orphaned shard rows that would
834                        // otherwise leak unboundedly (F25).
835                        sqlx::query(&self.stmts.delete_shards_by_run)
836                            .bind(id)
837                            .execute(&self.pool)
838                            .await
839                            .map_err(backend)?;
840                        Ok(DeleteOutcome::Deleted)
841                    }
842                }
843            }
844
845            async fn release_idempotency(
846                &self,
847                run_id: &str,
848            ) -> Result<(), $crate::serve::history::HistoryError> {
849                use $crate::serve::history::HistoryError;
850                sqlx::query(&self.stmts.delete_idem_by_run)
851                    .bind(run_id)
852                    .execute(&self.pool)
853                    .await
854                    .map_err(|e| HistoryError::Backend(e.to_string()))?;
855                Ok(())
856            }
857
858            async fn purge_expired(
859                &self,
860                retain_for: std::time::Duration,
861            ) -> Result<usize, $crate::serve::history::HistoryError> {
862                use $crate::serve::history::HistoryError;
863                use $crate::serve::history::sql;
864                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
865                let now = chrono::Utc::now();
866                let removed = sqlx::query(&self.stmts.purge_runs)
867                    .bind(sql::threshold(now, retain_for))
868                    .execute(&self.pool)
869                    .await
870                    .map_err(backend)?
871                    .rows_affected() as usize;
872                // Drop expired idempotency claims too (best-effort).
873                let _ = sqlx::query(&self.stmts.purge_idem)
874                    .bind(sql::threshold(now, self.idem_retention))
875                    .execute(&self.pool)
876                    .await;
877                // Drop membership rows that have not heartbeated within the
878                // run-retention window (far longer than the lease, so this never
879                // prunes a live member — that's `live_instances(ttl)`'s job).
880                let _ = sqlx::query(&self.stmts.prune_instances)
881                    .bind(sql::threshold(now, retain_for))
882                    .execute(&self.pool)
883                    .await;
884                // Reclaim shard rows whose parent run was just purged (F25):
885                // `purge_runs` removed the expired terminal records above, so any
886                // shard row no longer matching a run is orphaned. Best-effort.
887                let _ = sqlx::query(&self.stmts.purge_orphan_shards)
888                    .execute(&self.pool)
889                    .await;
890                Ok(removed)
891            }
892
893            async fn recover_orphans(&self) -> Result<usize, $crate::serve::history::HistoryError> {
894                use sqlx::Row as _;
895                use $crate::serve::history::HistoryError;
896                use $crate::serve::history::RunStatus;
897                use $crate::serve::history::sql;
898                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
899                let now = chrono::Utc::now();
900                // Only non-terminal runs whose lease has expired (the owning
901                // instance is presumed dead). A live instance heartbeats its
902                // runs' leases into the future, so this never fails another
903                // healthy instance's in-flight runs (#146 H7).
904                let rows = sqlx::query(&self.stmts.select_orphans)
905                    .bind(sql::fmt_ts(now))
906                    .fetch_all(&self.pool)
907                    .await
908                    .map_err(backend)?;
909                let mut count = 0usize;
910                for r in &rows {
911                    let body: String = r.try_get("body").map_err(backend)?;
912                    let mut rec = sql::decode_body(&body)?;
913                    rec.status = RunStatus::Failed;
914                    rec.finished_at = Some(now);
915                    rec.error = Some(
916                        "owning serve instance's lease expired before the run finished".into(),
917                    );
918                    if rec.elapsed_secs.is_none()
919                        && let Some(started) = rec.started_at
920                    {
921                        rec.elapsed_secs = (now - started).to_std().ok().map(|d| d.as_secs_f64());
922                    }
923                    self.upsert(&rec).await?;
924                    count += 1;
925                }
926                Ok(count)
927            }
928
929            async fn renew_leases(&self) -> Result<usize, $crate::serve::history::HistoryError> {
930                use $crate::serve::history::HistoryError;
931                use $crate::serve::history::sql;
932                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
933                let new_lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
934                let renewed = sqlx::query(&self.stmts.renew_leases)
935                    .bind(&new_lease)
936                    .bind(&self.instance_id)
937                    .execute(&self.pool)
938                    .await
939                    .map_err(backend)?
940                    .rows_affected() as usize;
941                Ok(renewed)
942            }
943
944            async fn claim_pending(
945                &self,
946                limit: usize,
947            ) -> Result<Vec<$crate::serve::history::RunRecord>, $crate::serve::history::HistoryError>
948            {
949                use sqlx::Row as _;
950                use $crate::serve::history::HistoryError;
951                use $crate::serve::history::RunStatus;
952                use $crate::serve::history::sql;
953                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
954                if limit == 0 {
955                    return Ok(Vec::new());
956                }
957                let now = chrono::Utc::now();
958                let lease = sql::fmt_ts(now + self.lease_ttl);
959
960                // 1. Candidate pending runs (oldest first), with their bodies.
961                let rows = sqlx::query(&self.stmts.select_pending)
962                    .bind(limit as i64)
963                    .fetch_all(&self.pool)
964                    .await
965                    .map_err(backend)?;
966
967                // Per-row conditional claim (1 SELECT + N guarded UPDATEs). The
968                // batch is bounded by the caller's free permits (small), and this
969                // is portable across Postgres + SQLite — deliberately NOT a
970                // Postgres-only `FOR UPDATE SKIP LOCKED`.
971                let mut claimed = Vec::new();
972                for row in &rows {
973                    let run_id: String = row.try_get("run_id").map_err(backend)?;
974                    let body: String = row.try_get("body").map_err(backend)?;
975                    // Flip the record to Running and rewrite the body so the column
976                    // and the (source-of-truth) body stay consistent — a GET right
977                    // after the claim must not show a stale `pending`.
978                    let mut r = sql::decode_body(&body)?;
979                    r.status = RunStatus::Running;
980                    let new_body = sql::encode_body(&r)?;
981                    // 2. Conditional claim — only the first committer wins.
982                    let won = sqlx::query(&self.stmts.claim_one)
983                        .bind(&self.instance_id)
984                        .bind(&lease)
985                        .bind(&new_body)
986                        .bind(&run_id)
987                        .execute(&self.pool)
988                        .await
989                        .map_err(backend)?
990                        .rows_affected();
991                    if won == 1 {
992                        claimed.push(r);
993                    }
994                }
995                Ok(claimed)
996            }
997
998            async fn reclaim_orphans(
999                &self,
1000                max_attempts: u32,
1001            ) -> Result<$crate::serve::history::ReclaimReport, $crate::serve::history::HistoryError>
1002            {
1003                use sqlx::Row as _;
1004                use $crate::serve::history::HistoryError;
1005                use $crate::serve::history::ReclaimReport;
1006                use $crate::serve::history::RunStatus;
1007                use $crate::serve::history::sql;
1008                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1009                let now = chrono::Utc::now();
1010                let now_s = sql::fmt_ts(now);
1011
1012                let rows = sqlx::query(&self.stmts.reclaim_select)
1013                    .bind(&now_s)
1014                    .fetch_all(&self.pool)
1015                    .await
1016                    .map_err(backend)?;
1017
1018                let mut report = ReclaimReport::default();
1019                for row in &rows {
1020                    let body: String = row.try_get("body").map_err(backend)?;
1021                    let mut rec = sql::decode_body(&body)?;
1022                    let next_attempt = rec.attempt + 1;
1023                    // Cap is on the attempts already made: a run that has been
1024                    // reclaimed fewer than `max_attempts` times gets another try;
1025                    // once it reaches the cap it is poisoned.
1026                    if rec.attempt < max_attempts {
1027                        // Re-queue for another instance to re-run.
1028                        rec.attempt = next_attempt;
1029                        rec.status = RunStatus::Pending;
1030                        let new_body = sql::encode_body(&rec)?;
1031                        let n = sqlx::query(&self.stmts.reclaim_requeue)
1032                            .bind(&new_body)
1033                            .bind(&rec.run_id)
1034                            .bind(&now_s)
1035                            .execute(&self.pool)
1036                            .await
1037                            .map_err(backend)?
1038                            .rows_affected();
1039                        if n == 1 {
1040                            report.requeued += 1;
1041                        }
1042                    } else {
1043                        // Poison: too many attempts.
1044                        rec.attempt = next_attempt;
1045                        rec.status = RunStatus::Failed;
1046                        rec.finished_at = Some(now);
1047                        rec.error = Some(format!(
1048                            "run reclaimed {next_attempt} times after its owning instance's \
1049                             lease expired; giving up (poison run)"
1050                        ));
1051                        if rec.elapsed_secs.is_none()
1052                            && let Some(started) = rec.started_at
1053                        {
1054                            rec.elapsed_secs =
1055                                (now - started).to_std().ok().map(|d| d.as_secs_f64());
1056                        }
1057                        let new_body = sql::encode_body(&rec)?;
1058                        let n = sqlx::query(&self.stmts.reclaim_fail)
1059                            .bind(&now_s)
1060                            .bind(&new_body)
1061                            .bind(&rec.run_id)
1062                            .bind(&now_s)
1063                            .execute(&self.pool)
1064                            .await
1065                            .map_err(backend)?
1066                            .rows_affected();
1067                        if n == 1 {
1068                            report.failed += 1;
1069                        }
1070                    }
1071                }
1072                Ok(report)
1073            }
1074
1075            async fn finalize_owned(
1076                &self,
1077                rec: &$crate::serve::history::RunRecord,
1078            ) -> Result<bool, $crate::serve::history::HistoryError> {
1079                use $crate::serve::history::HistoryError;
1080                use $crate::serve::history::sql;
1081                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1082                // Defensive: a terminal record must carry finished_at, or
1083                // purge_runs (which requires finished_at IS NOT NULL) can never
1084                // reclaim it. Stamp it if a caller left it unset.
1085                let mut rec = rec.clone();
1086                if rec.status.is_terminal() && rec.finished_at.is_none() {
1087                    rec.finished_at = Some(chrono::Utc::now());
1088                }
1089                let body = sql::encode_body(&rec)?;
1090                let finished = rec.finished_at.map(sql::fmt_ts);
1091                let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1092                let n = sqlx::query(&self.stmts.finalize_owned)
1093                    .bind(rec.status.as_str())
1094                    .bind(finished.as_deref())
1095                    .bind(&lease)
1096                    .bind(&body)
1097                    .bind(&rec.run_id)
1098                    .bind(&self.instance_id)
1099                    .execute(&self.pool)
1100                    .await
1101                    .map_err(backend)?
1102                    .rows_affected();
1103                Ok(n == 1)
1104            }
1105
1106            async fn finalize_sharded_parent(
1107                &self,
1108                run_id: &str,
1109                status: $crate::serve::history::RunStatus,
1110                finished_at: chrono::DateTime<chrono::Utc>,
1111                error: Option<String>,
1112            ) -> Result<bool, $crate::serve::history::HistoryError> {
1113                use sqlx::Row as _;
1114                use $crate::serve::history::HistoryError;
1115                use $crate::serve::history::RunStatus;
1116                use $crate::serve::history::sql;
1117                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1118                // Read the parent body, apply the terminal status, and write back
1119                // conditional on it still being `sharded` — so a concurrent
1120                // double-finalize from two instances has exactly one winner and
1121                // neither re-stamps owner/lease on the terminal record (F45).
1122                let Some(row) = sqlx::query(&self.stmts.select_body)
1123                    .bind(run_id)
1124                    .fetch_optional(&self.pool)
1125                    .await
1126                    .map_err(backend)?
1127                else {
1128                    return Ok(false);
1129                };
1130                let body: String = row.try_get("body").map_err(backend)?;
1131                let mut rec = sql::decode_body(&body)?;
1132                if rec.status != RunStatus::Sharded {
1133                    return Ok(false);
1134                }
1135                rec.status = status;
1136                rec.finished_at = Some(finished_at);
1137                rec.error = error;
1138                let new_body = sql::encode_body(&rec)?;
1139                let n = sqlx::query(&self.stmts.finalize_sharded_parent)
1140                    .bind(status.as_str())
1141                    .bind(sql::fmt_ts(finished_at))
1142                    .bind(&new_body)
1143                    .bind(run_id)
1144                    .execute(&self.pool)
1145                    .await
1146                    .map_err(backend)?
1147                    .rows_affected();
1148                Ok(n == 1)
1149            }
1150
1151            async fn cancel_pending(
1152                &self,
1153                run_id: &str,
1154            ) -> Result<bool, $crate::serve::history::HistoryError> {
1155                use sqlx::Row as _;
1156                use $crate::serve::history::HistoryError;
1157                use $crate::serve::history::RunStatus;
1158                use $crate::serve::history::sql;
1159                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1160                // Read the pending run's body, flip it to Cancelled, and write back
1161                // conditional on it still being pending (loses the race to a claim).
1162                let Some(row) = sqlx::query(&self.stmts.select_body)
1163                    .bind(run_id)
1164                    .fetch_optional(&self.pool)
1165                    .await
1166                    .map_err(backend)?
1167                else {
1168                    return Ok(false);
1169                };
1170                let body: String = row.try_get("body").map_err(backend)?;
1171                let mut rec = sql::decode_body(&body)?;
1172                if rec.status != RunStatus::Pending {
1173                    return Ok(false);
1174                }
1175                let now = chrono::Utc::now();
1176                rec.status = RunStatus::Cancelled;
1177                rec.finished_at = Some(now);
1178                let new_body = sql::encode_body(&rec)?;
1179                let n = sqlx::query(&self.stmts.cancel_pending)
1180                    .bind(sql::fmt_ts(now))
1181                    .bind(&new_body)
1182                    .bind(run_id)
1183                    .execute(&self.pool)
1184                    .await
1185                    .map_err(backend)?
1186                    .rows_affected();
1187                Ok(n == 1)
1188            }
1189
1190            async fn request_cancel(
1191                &self,
1192                run_id: &str,
1193            ) -> Result<(), $crate::serve::history::HistoryError> {
1194                use $crate::serve::history::HistoryError;
1195                use $crate::serve::history::sql;
1196                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1197                sqlx::query(&self.stmts.request_cancel)
1198                    .bind(sql::fmt_ts(chrono::Utc::now()))
1199                    .bind(run_id)
1200                    .execute(&self.pool)
1201                    .await
1202                    .map_err(backend)?;
1203                Ok(())
1204            }
1205
1206            async fn pending_cancellations(
1207                &self,
1208            ) -> Result<Vec<String>, $crate::serve::history::HistoryError> {
1209                use sqlx::Row as _;
1210                use $crate::serve::history::HistoryError;
1211                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1212                let rows = sqlx::query(&self.stmts.pending_cancellations)
1213                    .bind(&self.instance_id)
1214                    .fetch_all(&self.pool)
1215                    .await
1216                    .map_err(backend)?;
1217                let mut ids = Vec::with_capacity(rows.len());
1218                for r in &rows {
1219                    ids.push(r.try_get::<String, _>("run_id").map_err(backend)?);
1220                }
1221                Ok(ids)
1222            }
1223
1224            async fn heartbeat_instance(
1225                &self,
1226                beat: &$crate::serve::history::InstanceHeartbeat,
1227            ) -> Result<(), $crate::serve::history::HistoryError> {
1228                use $crate::serve::history::HistoryError;
1229                use $crate::serve::history::sql;
1230                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1231                let now = sql::fmt_ts(chrono::Utc::now());
1232                sqlx::query(&self.stmts.heartbeat_instance)
1233                    .bind(&self.instance_id)
1234                    .bind(sql::fmt_ts(beat.started_at))
1235                    .bind(&now)
1236                    .bind(beat.listen.as_deref())
1237                    .bind(beat.max_concurrent.to_string())
1238                    .bind(beat.in_flight.to_string())
1239                    .execute(&self.pool)
1240                    .await
1241                    .map_err(backend)?;
1242                Ok(())
1243            }
1244
1245            async fn live_instances(
1246                &self,
1247                ttl: std::time::Duration,
1248            ) -> Result<Vec<$crate::serve::history::InstanceRecord>, $crate::serve::history::HistoryError>
1249            {
1250                use sqlx::Row as _;
1251                use $crate::serve::history::HistoryError;
1252                use $crate::serve::history::InstanceRecord;
1253                use $crate::serve::history::sql;
1254                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1255                let now = chrono::Utc::now();
1256                let rows = sqlx::query(&self.stmts.live_instances)
1257                    .bind(sql::threshold(now, ttl))
1258                    .fetch_all(&self.pool)
1259                    .await
1260                    .map_err(backend)?;
1261                let parse_dt = |s: &str| {
1262                    chrono::DateTime::parse_from_rfc3339(s)
1263                        .map(|d| d.to_utc())
1264                        .unwrap_or(now)
1265                };
1266                let mut out = Vec::with_capacity(rows.len());
1267                for r in &rows {
1268                    let started: String = r.try_get("started_at").map_err(backend)?;
1269                    let hb: String = r.try_get("last_heartbeat").map_err(backend)?;
1270                    let mc: Option<String> = r.try_get("max_concurrent").map_err(backend)?;
1271                    let inf: Option<String> = r.try_get("in_flight").map_err(backend)?;
1272                    out.push(InstanceRecord {
1273                        instance_id: r.try_get("instance_id").map_err(backend)?,
1274                        started_at: parse_dt(&started),
1275                        last_heartbeat: parse_dt(&hb),
1276                        listen: r.try_get("listen").map_err(backend)?,
1277                        max_concurrent: mc.and_then(|s| s.parse().ok()).unwrap_or(0),
1278                        in_flight: inf.and_then(|s| s.parse().ok()).unwrap_or(0),
1279                    });
1280                }
1281                Ok(out)
1282            }
1283
1284            // ── Source shards (Mode B, #230) ─────────────────────────────────
1285
1286            async fn insert_shards(
1287                &self,
1288                run_id: &str,
1289                shards: &[$crate::serve::history::ShardInsert],
1290            ) -> Result<usize, $crate::serve::history::HistoryError> {
1291                use $crate::serve::history::HistoryError;
1292                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1293                let mut inserted = 0usize;
1294                for s in shards {
1295                    let descriptor = serde_json::to_string(&s.descriptor).map_err(|e| {
1296                        HistoryError::Backend(format!("encode shard descriptor: {e}"))
1297                    })?;
1298                    let size = s.size_estimate.map(|n| n.to_string());
1299                    let n = sqlx::query(&self.stmts.insert_shard)
1300                        .bind(run_id)
1301                        .bind(&s.shard_id)
1302                        .bind(&descriptor)
1303                        .bind(size.as_deref())
1304                        .execute(&self.pool)
1305                        .await
1306                        .map_err(backend)?
1307                        .rows_affected();
1308                    inserted += n as usize;
1309                }
1310                Ok(inserted)
1311            }
1312
1313            async fn claim_shards(
1314                &self,
1315                limit: usize,
1316            ) -> Result<
1317                Vec<$crate::serve::history::ClaimedShard>,
1318                $crate::serve::history::HistoryError,
1319            > {
1320                use sqlx::Row as _;
1321                use $crate::serve::history::ClaimedShard;
1322                use $crate::serve::history::HistoryError;
1323                use $crate::serve::history::sql;
1324                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1325                if limit == 0 {
1326                    return Ok(Vec::new());
1327                }
1328                let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1329
1330                // 1. Candidate pending shards (largest estimated size first),
1331                //    joined to their parent run body.
1332                let rows = sqlx::query(&self.stmts.claim_shards_select)
1333                    .bind(limit as i64)
1334                    .fetch_all(&self.pool)
1335                    .await
1336                    .map_err(backend)?;
1337
1338                // 2. Per-row conditional claim (portable; not FOR UPDATE SKIP LOCKED).
1339                let mut claimed = Vec::new();
1340                for row in &rows {
1341                    let run_id: String = row.try_get("run_id").map_err(backend)?;
1342                    let shard_id: String = row.try_get("shard_id").map_err(backend)?;
1343                    let descriptor_s: String = row.try_get("descriptor").map_err(backend)?;
1344                    let body: String = row.try_get("body").map_err(backend)?;
1345                    let won = sqlx::query(&self.stmts.claim_shard_one)
1346                        .bind(&self.instance_id)
1347                        .bind(&lease)
1348                        .bind(&run_id)
1349                        .bind(&shard_id)
1350                        .execute(&self.pool)
1351                        .await
1352                        .map_err(backend)?
1353                        .rows_affected();
1354                    if won == 1 {
1355                        let descriptor: serde_json::Value = serde_json::from_str(&descriptor_s)
1356                            .map_err(|e| {
1357                                HistoryError::Backend(format!("decode shard descriptor: {e}"))
1358                            })?;
1359                        let run = sql::decode_body(&body)?;
1360                        claimed.push(ClaimedShard {
1361                            run_id,
1362                            shard_id,
1363                            descriptor,
1364                            run,
1365                        });
1366                    }
1367                }
1368                Ok(claimed)
1369            }
1370
1371            async fn renew_shard_leases(
1372                &self,
1373            ) -> Result<usize, $crate::serve::history::HistoryError> {
1374                use $crate::serve::history::HistoryError;
1375                use $crate::serve::history::sql;
1376                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1377                let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1378                let n = sqlx::query(&self.stmts.renew_shard_leases)
1379                    .bind(&lease)
1380                    .bind(&self.instance_id)
1381                    .execute(&self.pool)
1382                    .await
1383                    .map_err(backend)?
1384                    .rows_affected() as usize;
1385                Ok(n)
1386            }
1387
1388            async fn reclaim_shards(
1389                &self,
1390                max_attempts: u32,
1391            ) -> Result<$crate::serve::history::ReclaimReport, $crate::serve::history::HistoryError>
1392            {
1393                use sqlx::Row as _;
1394                use $crate::serve::history::HistoryError;
1395                use $crate::serve::history::ReclaimReport;
1396                use $crate::serve::history::sql;
1397                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1398                let now_s = sql::fmt_ts(chrono::Utc::now());
1399
1400                let rows = sqlx::query(&self.stmts.reclaim_shards_select)
1401                    .bind(&now_s)
1402                    .fetch_all(&self.pool)
1403                    .await
1404                    .map_err(backend)?;
1405
1406                let mut report = ReclaimReport::default();
1407                for row in &rows {
1408                    let run_id: String = row.try_get("run_id").map_err(backend)?;
1409                    let shard_id: String = row.try_get("shard_id").map_err(backend)?;
1410                    let attempt_s: String = row.try_get("attempt").map_err(backend)?;
1411                    let attempt: u32 = attempt_s.parse().unwrap_or(0);
1412                    if attempt < max_attempts {
1413                        let next = (attempt + 1).to_string();
1414                        let n = sqlx::query(&self.stmts.reclaim_shard_requeue)
1415                            .bind(&next)
1416                            .bind(&run_id)
1417                            .bind(&shard_id)
1418                            .bind(&now_s)
1419                            .execute(&self.pool)
1420                            .await
1421                            .map_err(backend)?
1422                            .rows_affected();
1423                        if n == 1 {
1424                            report.requeued += 1;
1425                        }
1426                    } else {
1427                        let n = sqlx::query(&self.stmts.reclaim_shard_fail)
1428                            .bind(&now_s)
1429                            .bind(&run_id)
1430                            .bind(&shard_id)
1431                            .bind(&now_s)
1432                            .execute(&self.pool)
1433                            .await
1434                            .map_err(backend)?
1435                            .rows_affected();
1436                        if n == 1 {
1437                            report.failed += 1;
1438                        }
1439                    }
1440                }
1441                Ok(report)
1442            }
1443
1444            async fn finalize_shard(
1445                &self,
1446                run_id: &str,
1447                shard_id: &str,
1448                success: bool,
1449            ) -> Result<bool, $crate::serve::history::HistoryError> {
1450                use $crate::serve::history::HistoryError;
1451                use $crate::serve::history::sql;
1452                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1453                let status = if success { "completed" } else { "failed" };
1454                let now_s = sql::fmt_ts(chrono::Utc::now());
1455                let n = sqlx::query(&self.stmts.finalize_shard)
1456                    .bind(status)
1457                    .bind(&now_s)
1458                    .bind(run_id)
1459                    .bind(shard_id)
1460                    .bind(&self.instance_id)
1461                    .execute(&self.pool)
1462                    .await
1463                    .map_err(backend)?
1464                    .rows_affected();
1465                Ok(n == 1)
1466            }
1467
1468            async fn shard_progress(
1469                &self,
1470                run_id: &str,
1471            ) -> Result<$crate::serve::history::ShardProgress, $crate::serve::history::HistoryError>
1472            {
1473                use sqlx::Row as _;
1474                use $crate::serve::history::HistoryError;
1475                use $crate::serve::history::ShardProgress;
1476                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1477                let rows = sqlx::query(&self.stmts.shard_progress)
1478                    .bind(run_id)
1479                    .fetch_all(&self.pool)
1480                    .await
1481                    .map_err(backend)?;
1482                let mut p = ShardProgress::default();
1483                for row in &rows {
1484                    let status: String = row.try_get("status").map_err(backend)?;
1485                    let n: i64 = row.try_get("n").map_err(backend)?;
1486                    let n = n.max(0) as usize;
1487                    p.total += n;
1488                    match status.as_str() {
1489                        "completed" => p.completed += n,
1490                        "failed" => p.failed += n,
1491                        "running" => p.running += n,
1492                        _ => p.pending += n,
1493                    }
1494                }
1495                Ok(p)
1496            }
1497
1498            async fn pending_shard_cancellations(
1499                &self,
1500            ) -> Result<Vec<String>, $crate::serve::history::HistoryError> {
1501                use sqlx::Row as _;
1502                use $crate::serve::history::HistoryError;
1503                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1504                let rows = sqlx::query(&self.stmts.pending_shard_cancellations)
1505                    .bind(&self.instance_id)
1506                    .fetch_all(&self.pool)
1507                    .await
1508                    .map_err(backend)?;
1509                let mut ids = Vec::with_capacity(rows.len());
1510                for r in &rows {
1511                    ids.push(r.try_get::<String, _>("run_id").map_err(backend)?);
1512                }
1513                Ok(ids)
1514            }
1515
1516            async fn finalize_completed_sharded_parents(
1517                &self,
1518            ) -> Result<usize, $crate::serve::history::HistoryError> {
1519                use sqlx::Row as _;
1520                use $crate::serve::history::HistoryError;
1521                use $crate::serve::history::RunStatus;
1522                use $crate::serve::history::sql;
1523                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1524
1525                // Candidate `sharded` parents — finalize each whose shards are all
1526                // terminal. The status-fenced UPDATE makes a concurrent finalize
1527                // (here or in `maybe_finalize_parent`) a benign no-op.
1528                let rows = sqlx::query(&self.stmts.select_sharded_parents)
1529                    .fetch_all(&self.pool)
1530                    .await
1531                    .map_err(backend)?;
1532
1533                let mut finalized = 0usize;
1534                for row in &rows {
1535                    let run_id: String = row.try_get("run_id").map_err(backend)?;
1536                    let progress = self.shard_progress(&run_id).await?;
1537                    if !progress.all_terminal() {
1538                        continue;
1539                    }
1540                    let success = progress.failed == 0;
1541                    // Read-modify-write the body so the surfaced record stays
1542                    // consistent (status, finished_at, error) with the column.
1543                    let Some(body_row) = sqlx::query(&self.stmts.select_body)
1544                        .bind(&run_id)
1545                        .fetch_optional(&self.pool)
1546                        .await
1547                        .map_err(backend)?
1548                    else {
1549                        continue;
1550                    };
1551                    let body: String = body_row.try_get("body").map_err(backend)?;
1552                    let mut rec = sql::decode_body(&body)?;
1553                    // Skip if it raced to terminal already (column says sharded but
1554                    // the body was just updated). The fenced UPDATE is the real guard.
1555                    if rec.status != RunStatus::Sharded {
1556                        continue;
1557                    }
1558                    let now = chrono::Utc::now();
1559                    rec.status = if success {
1560                        RunStatus::Completed
1561                    } else {
1562                        RunStatus::Failed
1563                    };
1564                    rec.finished_at = Some(now);
1565                    if !success {
1566                        rec.error = Some(format!(
1567                            "{}/{} shard(s) failed",
1568                            progress.failed, progress.total
1569                        ));
1570                    }
1571                    let new_body = sql::encode_body(&rec)?;
1572                    let n = sqlx::query(&self.stmts.finalize_sharded_parent)
1573                        .bind(rec.status.as_str())
1574                        .bind(sql::fmt_ts(now))
1575                        .bind(&new_body)
1576                        .bind(&run_id)
1577                        .execute(&self.pool)
1578                        .await
1579                        .map_err(backend)?
1580                        .rows_affected();
1581                    if n == 1 {
1582                        finalized += 1;
1583                        $crate::serve::metrics::record_run_finished(
1584                            rec.status,
1585                            if success { "ok" } else { "error" },
1586                        );
1587                        tracing::info!(
1588                            run_id,
1589                            shards = progress.total,
1590                            failed = progress.failed,
1591                            "sharded run finalized by sweep (F11)"
1592                        );
1593                    }
1594                }
1595                Ok(finalized)
1596            }
1597
1598            fn degraded(&self) -> bool {
1599                // A live SQL backend is never self-degraded; the FallbackHistory
1600                // wrapper owns degradation when the backend becomes unreachable.
1601                false
1602            }
1603        }
1604    };
1605}
1606
1607pub(crate) use impl_sql_history;
1608
1609#[cfg(test)]
1610mod tests {
1611    use super::*;
1612
1613    #[test]
1614    fn postgres_shard_statements_are_built() {
1615        // SQLite tests only build the Sqlite statement set; exercise the
1616        // Postgres shard-statement construction too (Mode B, #230).
1617        let s = Stmts::new(Dialect::Postgres);
1618        assert!(s.insert_shard.contains("faucet_serve_shards"));
1619        assert!(s.insert_shard.contains("ON CONFLICT"));
1620        assert!(s.claim_shards_select.contains("JOIN faucet_serve_runs"));
1621        assert!(s.claim_shard_one.contains("'running'"));
1622        assert!(s.renew_shard_leases.contains("lease_expires_at"));
1623        assert!(s.reclaim_shards_select.contains("'running'"));
1624        assert!(s.reclaim_shard_requeue.contains("'pending'"));
1625        assert!(s.reclaim_shard_fail.contains("'failed'"));
1626        assert!(s.finalize_shard.contains("owner"));
1627        assert!(s.shard_progress.contains("GROUP BY"));
1628    }
1629
1630    #[test]
1631    fn fmt_ts_is_fixed_width_and_sortable() {
1632        let a = fmt_ts(
1633            DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1634                .unwrap()
1635                .to_utc(),
1636        );
1637        let b = fmt_ts(
1638            DateTime::parse_from_rfc3339("2026-01-01T00:00:01Z")
1639                .unwrap()
1640                .to_utc(),
1641        );
1642        assert!(a.ends_with('Z'));
1643        assert_eq!(a.len(), b.len(), "fixed width");
1644        assert!(a < b, "lexicographic order matches chronological order");
1645    }
1646
1647    #[test]
1648    fn is_expired_respects_window() {
1649        let now = Utc::now();
1650        let old = fmt_ts(now - chrono::Duration::seconds(120));
1651        assert!(is_expired(&old, now, Duration::from_secs(60)));
1652        assert!(!is_expired(&old, now, Duration::from_secs(600)));
1653        // Unparseable → not expired (conservative).
1654        assert!(!is_expired("not-a-timestamp", now, Duration::ZERO));
1655    }
1656
1657    #[test]
1658    fn parse_status_round_trips_known_and_defaults_failed() {
1659        for s in [
1660            RunStatus::Queued,
1661            RunStatus::Pending,
1662            RunStatus::Running,
1663            RunStatus::Completed,
1664            RunStatus::Failed,
1665            RunStatus::Cancelled,
1666        ] {
1667            assert_eq!(parse_status(s.as_str()), s);
1668        }
1669        assert_eq!(parse_status("garbage"), RunStatus::Failed);
1670    }
1671
1672    #[test]
1673    fn body_round_trips() {
1674        let rec = RunRecord::queued(
1675            "r1".into(),
1676            Some("n".into()),
1677            Default::default(),
1678            Some("idem".into()),
1679            Utc::now(),
1680        );
1681        let encoded = encode_body(&rec).unwrap();
1682        let decoded = decode_body(&encoded).unwrap();
1683        assert_eq!(decoded.run_id, "r1");
1684        assert_eq!(decoded.idempotency_key.as_deref(), Some("idem"));
1685    }
1686
1687    #[test]
1688    fn postgres_and_sqlite_statements_differ_only_in_placeholders() {
1689        let pg = Stmts::new(Dialect::Postgres);
1690        let lite = Stmts::new(Dialect::Sqlite);
1691        assert!(pg.upsert.contains("$1") && lite.upsert.contains('?'));
1692        assert!(pg.list.contains("$13") && lite.list.contains('?'));
1693        // Both target the same tables / conflict targets.
1694        assert!(pg.insert_idem.contains("ON CONFLICT (key) DO NOTHING"));
1695        assert!(lite.insert_idem.contains("ON CONFLICT (key) DO NOTHING"));
1696        assert!(pg.claim_one.contains("$3") && lite.claim_one.contains('?'));
1697        assert!(pg.heartbeat_instance.contains("faucet_serve_instances"));
1698        assert!(lite.heartbeat_instance.contains("faucet_serve_instances"));
1699    }
1700}