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    // Audit log for RBAC (#205). One row per mutating (or denied) control-plane
83    // action. `id` is a time-ordered UUIDv7; `ts` is fixed-width RFC3339 so the
84    // newest-first ordering and retention purge sort lexicographically.
85    "CREATE TABLE IF NOT EXISTS faucet_serve_audit (\
86        id TEXT PRIMARY KEY,\
87        ts TEXT NOT NULL,\
88        principal TEXT NOT NULL,\
89        role TEXT NOT NULL,\
90        action TEXT NOT NULL,\
91        run_id TEXT,\
92        config_fingerprint TEXT,\
93        source_ip TEXT,\
94        result TEXT NOT NULL)",
95    "CREATE INDEX IF NOT EXISTS faucet_serve_audit_ts_idx \
96        ON faucet_serve_audit (ts)",
97    // Data Movement Catalog (#279). Accumulating cross-run state, deliberately
98    // NOT covered by `purge_expired` (the history is the value). Same
99    // TEXT-columns + JSON `body` convention as the run tables: the dedicated
100    // columns exist for filtering only; `body` is the source of truth on read.
101    "CREATE TABLE IF NOT EXISTS faucet_catalog_datasets (\
102        id TEXT PRIMARY KEY,\
103        uri TEXT NOT NULL,\
104        kind TEXT NOT NULL,\
105        last_seen TEXT NOT NULL,\
106        body TEXT NOT NULL)",
107    // One row per (dataset, schema version); appended only on content change.
108    // `version` is an integer stored as TEXT (cast on ORDER BY), matching the
109    // shard table's `size_estimate` convention.
110    "CREATE TABLE IF NOT EXISTS faucet_catalog_schema_versions (\
111        dataset_id TEXT NOT NULL,\
112        version TEXT NOT NULL,\
113        recorded_at TEXT NOT NULL,\
114        body TEXT NOT NULL,\
115        PRIMARY KEY (dataset_id, version))",
116    // One row per (source dataset, sink dataset) lineage edge.
117    "CREATE TABLE IF NOT EXISTS faucet_catalog_edges (\
118        src_id TEXT NOT NULL,\
119        dst_id TEXT NOT NULL,\
120        last_seen TEXT NOT NULL,\
121        body TEXT NOT NULL,\
122        PRIMARY KEY (src_id, dst_id))",
123    // Per-run volume points, capped per dataset at `catalog::STATS_RETAIN`.
124    "CREATE TABLE IF NOT EXISTS faucet_catalog_stats (\
125        dataset_id TEXT NOT NULL,\
126        recorded_at TEXT NOT NULL,\
127        run_id TEXT NOT NULL,\
128        records TEXT NOT NULL,\
129        PRIMARY KEY (dataset_id, recorded_at))",
130];
131
132/// SQL placeholder dialect.
133#[derive(Clone, Copy, Debug)]
134pub enum Dialect {
135    Postgres,
136    Sqlite,
137}
138
139/// Prepared-statement text for a backend, built once per dialect at connect time.
140pub struct Stmts {
141    /// (`cancel_requested` is intentionally NOT written by `upsert` — it is set
142    /// only via `request_cancel` and cleared by `reclaim_requeue`; it defaults to
143    /// NULL on insert.)
144    pub upsert: String,
145    pub select_body: String,
146    pub select_status: String,
147    pub select_submitted: String,
148    pub delete: String,
149    pub list: String,
150    pub purge_runs: String,
151    pub purge_idem: String,
152    /// Select non-terminal runs whose owning instance's lease has expired (or
153    /// is unset) — the orphans this instance may safely fail. Param: `now`.
154    pub select_orphans: String,
155    /// Extend the lease of this instance's own non-terminal runs (heartbeat).
156    /// Params: `new_lease_expiry`, `instance_id`.
157    pub renew_leases: String,
158    pub insert_idem: String,
159    pub select_idem: String,
160    pub takeover_idem: String,
161    /// Delete the idempotency claim(s) that point at a given run — used when a
162    /// run is deleted so a replay of the key starts fresh rather than 404-ing
163    /// on the missing record (#146 M8). Scoped by `run_id`, so a newer run that
164    /// re-claimed the same key keeps its claim.
165    pub delete_idem_by_run: String,
166    /// Cluster dispatcher: fetch oldest pending runs up to a given limit.
167    pub select_pending: String,
168    /// Cluster dispatcher: atomically claim a pending run (set owner + running).
169    pub claim_one: String,
170    /// Cluster reclaimer: select expired running runs for requeue/fail evaluation.
171    /// NOTE: `'queued'` is the single-instance status; cluster runs flow
172    /// `pending → running`, so the failover reclaimer covers `'running'` only.
173    pub reclaim_select: String,
174    /// Cluster reclaimer: requeue an expired running run back to pending.
175    pub reclaim_requeue: String,
176    /// Cluster reclaimer: fail an expired running run that cannot be requeued.
177    pub reclaim_fail: String,
178    /// Finalize a run owned by this instance (terminal status update).
179    pub finalize_owned: String,
180    /// Cancel a pending run directly (transition pending → cancelled).
181    pub cancel_pending: String,
182    /// Request cancellation of an in-flight run owned by another instance.
183    pub request_cancel: String,
184    /// List run IDs owned by this instance that have a pending cancellation request.
185    pub pending_cancellations: String,
186    /// Upsert this instance's membership heartbeat into `faucet_serve_instances`.
187    pub heartbeat_instance: String,
188    /// List instances whose last heartbeat is at or after a given threshold.
189    pub live_instances: String,
190    /// Prune instances whose last heartbeat is before a given threshold.
191    pub prune_instances: String,
192    // ── Source shards (Mode B, #230) ─────────────────────────────────────────
193    /// Idempotent shard insert (`ON CONFLICT (run_id, shard_id) DO NOTHING`).
194    pub insert_shard: String,
195    /// Select claimable pending shards joined to their run body, largest first.
196    pub claim_shards_select: String,
197    /// Atomically claim one pending shard for this instance.
198    pub claim_shard_one: String,
199    /// Heartbeat this instance's running shards.
200    pub renew_shard_leases: String,
201    /// Select expired-lease running shards for requeue/fail evaluation.
202    pub reclaim_shards_select: String,
203    /// Requeue an expired running shard back to pending (attempt++).
204    pub reclaim_shard_requeue: String,
205    /// Fail an expired running shard that exhausted its attempts (poison).
206    pub reclaim_shard_fail: String,
207    /// Owner-fenced terminal write for one shard.
208    pub finalize_shard: String,
209    /// Status counts for a run's shards.
210    pub shard_progress: String,
211    /// Distinct run_ids for which THIS instance owns a `running` shard whose
212    /// parent run has a pending cancellation request (cross-instance shard
213    /// cancel, F10). Param: `instance_id`.
214    pub pending_shard_cancellations: String,
215    /// Select run_ids of `sharded` parents (candidates to finalize once all
216    /// their shards are terminal, F11).
217    pub select_sharded_parents: String,
218    /// Status-fenced terminal write for a `sharded` parent (F11). A benign
219    /// double-finalize across instances is a no-op: the guard requires the
220    /// parent to still be `sharded`. Does NOT re-arm owner/lease.
221    pub finalize_sharded_parent: String,
222    /// Delete a run's shard rows (paired with [`delete`](Self::delete) so a
223    /// deleted run leaves no orphaned shard rows behind, F25). Param: `run_id`.
224    pub delete_shards_by_run: String,
225    /// Purge shard rows whose parent run no longer exists (run-record purged by
226    /// retention, F25). No params — a set-difference against `faucet_serve_runs`.
227    pub purge_orphan_shards: String,
228    // ── Audit log (RBAC, #205) ───────────────────────────────────────────────
229    /// Append one audit record.
230    pub insert_audit: String,
231    /// Newest-first audit records matching the (nullable) filters. Param order:
232    /// principal, action, since, until, limit.
233    pub list_audit: String,
234    /// Purge audit records older than a threshold (retention).
235    pub purge_audit: String,
236    // ── Data Movement Catalog (#279) ─────────────────────────────────────────
237    /// One dataset body by id (the merge read + the detail head).
238    pub catalog_select_dataset: String,
239    /// Upsert one dataset row (filter columns + body). Params: id, uri, kind,
240    /// last_seen, body.
241    pub catalog_upsert_dataset: String,
242    /// Every dataset body — filtering/ordering happens in shared pure code
243    /// ([`catalog::filter_datasets`](super::catalog::filter_datasets)), so the
244    /// memory and SQL backends can never disagree on semantics.
245    pub catalog_select_datasets: String,
246    /// Append one schema-timeline entry; `ON CONFLICT DO NOTHING` so a cluster
247    /// replay of the same (dataset, version) is idempotent.
248    pub catalog_insert_schema_version: String,
249    /// A dataset's schema timeline, oldest first.
250    pub catalog_select_schema_versions: String,
251    /// Upsert one lineage edge. Params: src_id, dst_id, last_seen, body.
252    pub catalog_upsert_edge: String,
253    /// Every edge body, newest activity first.
254    pub catalog_select_edges: String,
255    /// Append one volume point. Params: dataset_id, recorded_at, run_id, records.
256    pub catalog_insert_stat: String,
257    /// A dataset's most recent volume points. Params: dataset_id, limit.
258    pub catalog_select_stats: String,
259    /// Drop volume points beyond the newest `STATS_RETAIN` for one dataset.
260    /// Params: dataset_id, dataset_id, keep-limit.
261    pub catalog_prune_stats: String,
262}
263
264impl Stmts {
265    pub fn new(dialect: Dialect) -> Self {
266        match dialect {
267            Dialect::Postgres => Self::postgres(),
268            Dialect::Sqlite => Self::sqlite(),
269        }
270    }
271
272    fn postgres() -> Self {
273        Self {
274            upsert: "INSERT INTO faucet_serve_runs \
275                (run_id,name,status,submitted_at,finished_at,idempotency_key,owner,lease_expires_at,body) \
276                VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) \
277                ON CONFLICT (run_id) DO UPDATE SET \
278                name=excluded.name,status=excluded.status,submitted_at=excluded.submitted_at,\
279                finished_at=excluded.finished_at,idempotency_key=excluded.idempotency_key,\
280                owner=excluded.owner,lease_expires_at=excluded.lease_expires_at,\
281                body=excluded.body"
282                .into(),
283            select_body: "SELECT body FROM faucet_serve_runs WHERE run_id=$1".into(),
284            select_status: "SELECT status FROM faucet_serve_runs WHERE run_id=$1".into(),
285            select_submitted: "SELECT submitted_at FROM faucet_serve_runs WHERE run_id=$1".into(),
286            delete: "DELETE FROM faucet_serve_runs WHERE run_id=$1".into(),
287            // Casts make the parameter types explicit so `$n IS NULL` cannot trip
288            // Postgres' "could not determine data type of parameter" check.
289            list: "SELECT body FROM faucet_serve_runs \
290                WHERE ($1::text IS NULL OR status = $2::text) \
291                AND ($3::text IS NULL OR name = $4::text) \
292                AND ($5::text IS NULL OR submitted_at >= $6::text) \
293                AND ($7::text IS NULL OR submitted_at <= $8::text) \
294                AND ($9::text IS NULL OR (submitted_at < $10::text \
295                    OR (submitted_at = $11::text AND run_id < $12::text))) \
296                ORDER BY submitted_at DESC, run_id DESC LIMIT $13"
297                .into(),
298            purge_runs: "DELETE FROM faucet_serve_runs \
299                WHERE status IN ('completed','failed','cancelled') \
300                AND finished_at IS NOT NULL AND finished_at < $1"
301                .into(),
302            purge_idem: "DELETE FROM faucet_serve_idem WHERE claimed_at < $1".into(),
303            select_orphans: "SELECT body FROM faucet_serve_runs \
304                WHERE status IN ('queued','running') \
305                AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
306                .into(),
307            renew_leases: "UPDATE faucet_serve_runs SET lease_expires_at = $1 \
308                WHERE owner = $2 AND status IN ('queued','running')"
309                .into(),
310            insert_idem: "INSERT INTO faucet_serve_idem (key,run_id,fingerprint,claimed_at) \
311                VALUES ($1,$2,$3,$4) ON CONFLICT (key) DO NOTHING"
312                .into(),
313            select_idem: "SELECT run_id,fingerprint,claimed_at FROM faucet_serve_idem WHERE key=$1"
314                .into(),
315            takeover_idem: "UPDATE faucet_serve_idem \
316                SET run_id=$1,fingerprint=$2,claimed_at=$3 WHERE key=$4 AND claimed_at=$5"
317                .into(),
318            delete_idem_by_run: "DELETE FROM faucet_serve_idem WHERE run_id=$1".into(),
319            select_pending: "SELECT run_id, body FROM faucet_serve_runs \
320                WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT $1"
321                .into(),
322            claim_one: "UPDATE faucet_serve_runs \
323                SET owner = $1, status = 'running', lease_expires_at = $2, body = $3 \
324                WHERE run_id = $4 AND status = 'pending'"
325                .into(),
326            reclaim_select: "SELECT body FROM faucet_serve_runs \
327                WHERE status = 'running' \
328                AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
329                .into(),
330            // Preserve `cancel_requested` across a requeue (audit #321 M7): a
331            // cross-instance cancel acknowledged while the owner was partitioned
332            // must survive re-queueing so the next owner still honours it, rather
333            // than the run silently running to completion despite the cancel.
334            reclaim_requeue: "UPDATE faucet_serve_runs \
335                SET status = 'pending', owner = NULL, lease_expires_at = NULL, \
336                    body = $1 \
337                WHERE run_id = $2 AND status = 'running' \
338                AND (lease_expires_at IS NULL OR lease_expires_at < $3)"
339                .into(),
340            reclaim_fail: "UPDATE faucet_serve_runs \
341                SET status = 'failed', finished_at = $1, body = $2, owner = NULL \
342                WHERE run_id = $3 AND status = 'running' \
343                AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
344                .into(),
345            // Status-fenced (audit #321 L5): only finalize a still-non-terminal
346            // record, so a stale zombie execution that shares the same owner (a
347            // lease-lapse re-claim by the same instance) can never overwrite the
348            // terminal record written by the live execution. First finalizer wins.
349            finalize_owned: "UPDATE faucet_serve_runs \
350                SET status = $1, finished_at = $2, lease_expires_at = $3, body = $4 \
351                WHERE run_id = $5 AND owner = $6 \
352                AND status NOT IN ('completed','failed','cancelled')"
353                .into(),
354            cancel_pending: "UPDATE faucet_serve_runs \
355                SET status = 'cancelled', finished_at = $1, body = $2 \
356                WHERE run_id = $3 AND status = 'pending'"
357                .into(),
358            request_cancel: "UPDATE faucet_serve_runs \
359                SET cancel_requested = $1 WHERE run_id = $2 AND status IN ('running','sharded')"
360                .into(),
361            pending_cancellations: "SELECT run_id FROM faucet_serve_runs \
362                WHERE status = 'running' AND owner = $1 AND cancel_requested IS NOT NULL"
363                .into(),
364            heartbeat_instance: "INSERT INTO faucet_serve_instances \
365                (instance_id, started_at, last_heartbeat, listen, max_concurrent, in_flight) \
366                VALUES ($1,$2,$3,$4,$5,$6) \
367                ON CONFLICT (instance_id) DO UPDATE SET \
368                last_heartbeat = excluded.last_heartbeat, listen = excluded.listen, \
369                max_concurrent = excluded.max_concurrent, in_flight = excluded.in_flight"
370                .into(),
371            live_instances: "SELECT instance_id, started_at, last_heartbeat, listen, \
372                max_concurrent, in_flight FROM faucet_serve_instances \
373                WHERE last_heartbeat >= $1"
374                .into(),
375            prune_instances: "DELETE FROM faucet_serve_instances WHERE last_heartbeat < $1".into(),
376            insert_shard: "INSERT INTO faucet_serve_shards \
377                (run_id, shard_id, descriptor, size_estimate, status, attempt) \
378                VALUES ($1,$2,$3,$4,'pending','0') \
379                ON CONFLICT (run_id, shard_id) DO NOTHING"
380                .into(),
381            claim_shards_select: "SELECT s.run_id, s.shard_id, s.descriptor, r.body \
382                FROM faucet_serve_shards s JOIN faucet_serve_runs r ON r.run_id = s.run_id \
383                WHERE s.status = 'pending' \
384                ORDER BY CAST(COALESCE(s.size_estimate, '0') AS BIGINT) DESC, s.run_id, s.shard_id \
385                LIMIT $1"
386                .into(),
387            claim_shard_one: "UPDATE faucet_serve_shards \
388                SET owner = $1, status = 'running', lease_expires_at = $2 \
389                WHERE run_id = $3 AND shard_id = $4 AND status = 'pending'"
390                .into(),
391            renew_shard_leases: "UPDATE faucet_serve_shards SET lease_expires_at = $1 \
392                WHERE owner = $2 AND status = 'running'"
393                .into(),
394            reclaim_shards_select: "SELECT run_id, shard_id, attempt FROM faucet_serve_shards \
395                WHERE status = 'running' \
396                AND (lease_expires_at IS NULL OR lease_expires_at < $1)"
397                .into(),
398            reclaim_shard_requeue: "UPDATE faucet_serve_shards \
399                SET status = 'pending', owner = NULL, lease_expires_at = NULL, attempt = $1 \
400                WHERE run_id = $2 AND shard_id = $3 AND status = 'running' \
401                AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
402                .into(),
403            reclaim_shard_fail: "UPDATE faucet_serve_shards \
404                SET status = 'failed', finished_at = $1, owner = NULL \
405                WHERE run_id = $2 AND shard_id = $3 AND status = 'running' \
406                AND (lease_expires_at IS NULL OR lease_expires_at < $4)"
407                .into(),
408            finalize_shard: "UPDATE faucet_serve_shards \
409                SET status = $1, finished_at = $2 \
410                WHERE run_id = $3 AND shard_id = $4 AND owner = $5 AND status = 'running'"
411                .into(),
412            shard_progress: "SELECT status, COUNT(*) AS n FROM faucet_serve_shards \
413                WHERE run_id = $1 GROUP BY status"
414                .into(),
415            pending_shard_cancellations: "SELECT DISTINCT s.run_id \
416                FROM faucet_serve_shards s \
417                JOIN faucet_serve_runs r ON r.run_id = s.run_id \
418                WHERE s.owner = $1 AND s.status = 'running' \
419                AND r.cancel_requested IS NOT NULL"
420                .into(),
421            select_sharded_parents: "SELECT run_id FROM faucet_serve_runs \
422                WHERE status = 'sharded'"
423                .into(),
424            finalize_sharded_parent: "UPDATE faucet_serve_runs \
425                SET status = $1, finished_at = $2, body = $3 \
426                WHERE run_id = $4 AND status = 'sharded'"
427                .into(),
428            delete_shards_by_run: "DELETE FROM faucet_serve_shards WHERE run_id = $1".into(),
429            purge_orphan_shards: "DELETE FROM faucet_serve_shards \
430                WHERE run_id NOT IN (SELECT run_id FROM faucet_serve_runs)"
431                .into(),
432            insert_audit: "INSERT INTO faucet_serve_audit \
433                (id, ts, principal, role, action, run_id, config_fingerprint, source_ip, result) \
434                VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)"
435                .into(),
436            list_audit: "SELECT id, ts, principal, role, action, run_id, config_fingerprint, \
437                source_ip, result FROM faucet_serve_audit \
438                WHERE ($1::text IS NULL OR principal = $2::text) \
439                AND ($3::text IS NULL OR action = $4::text) \
440                AND ($5::text IS NULL OR ts >= $6::text) \
441                AND ($7::text IS NULL OR ts <= $8::text) \
442                ORDER BY ts DESC, id DESC LIMIT $9"
443                .into(),
444            purge_audit: "DELETE FROM faucet_serve_audit WHERE ts < $1".into(),
445            catalog_select_dataset: "SELECT body FROM faucet_catalog_datasets WHERE id=$1".into(),
446            catalog_upsert_dataset: "INSERT INTO faucet_catalog_datasets \
447                (id, uri, kind, last_seen, body) VALUES ($1,$2,$3,$4,$5) \
448                ON CONFLICT (id) DO UPDATE SET uri=excluded.uri, kind=excluded.kind, \
449                last_seen=excluded.last_seen, body=excluded.body"
450                .into(),
451            catalog_select_datasets: "SELECT body FROM faucet_catalog_datasets".into(),
452            catalog_insert_schema_version: "INSERT INTO faucet_catalog_schema_versions \
453                (dataset_id, version, recorded_at, body) VALUES ($1,$2,$3,$4) \
454                ON CONFLICT (dataset_id, version) DO NOTHING"
455                .into(),
456            catalog_select_schema_versions: "SELECT body FROM faucet_catalog_schema_versions \
457                WHERE dataset_id=$1 ORDER BY CAST(version AS BIGINT) ASC"
458                .into(),
459            catalog_upsert_edge: "INSERT INTO faucet_catalog_edges \
460                (src_id, dst_id, last_seen, body) VALUES ($1,$2,$3,$4) \
461                ON CONFLICT (src_id, dst_id) DO UPDATE SET \
462                last_seen=excluded.last_seen, body=excluded.body"
463                .into(),
464            catalog_select_edges: "SELECT body FROM faucet_catalog_edges \
465                ORDER BY last_seen DESC, src_id, dst_id"
466                .into(),
467            catalog_insert_stat: "INSERT INTO faucet_catalog_stats \
468                (dataset_id, recorded_at, run_id, records) VALUES ($1,$2,$3,$4) \
469                ON CONFLICT (dataset_id, recorded_at) DO NOTHING"
470                .into(),
471            catalog_select_stats: "SELECT recorded_at, run_id, records \
472                FROM faucet_catalog_stats WHERE dataset_id=$1 \
473                ORDER BY recorded_at DESC LIMIT $2"
474                .into(),
475            catalog_prune_stats: "DELETE FROM faucet_catalog_stats \
476                WHERE dataset_id=$1 AND recorded_at NOT IN (\
477                    SELECT recorded_at FROM faucet_catalog_stats WHERE dataset_id=$2 \
478                    ORDER BY recorded_at DESC LIMIT $3)"
479                .into(),
480        }
481    }
482
483    fn sqlite() -> Self {
484        Self {
485            upsert: "INSERT INTO faucet_serve_runs \
486                (run_id,name,status,submitted_at,finished_at,idempotency_key,owner,lease_expires_at,body) \
487                VALUES (?,?,?,?,?,?,?,?,?) \
488                ON CONFLICT (run_id) DO UPDATE SET \
489                name=excluded.name,status=excluded.status,submitted_at=excluded.submitted_at,\
490                finished_at=excluded.finished_at,idempotency_key=excluded.idempotency_key,\
491                owner=excluded.owner,lease_expires_at=excluded.lease_expires_at,\
492                body=excluded.body"
493                .into(),
494            select_body: "SELECT body FROM faucet_serve_runs WHERE run_id=?".into(),
495            select_status: "SELECT status FROM faucet_serve_runs WHERE run_id=?".into(),
496            select_submitted: "SELECT submitted_at FROM faucet_serve_runs WHERE run_id=?".into(),
497            delete: "DELETE FROM faucet_serve_runs WHERE run_id=?".into(),
498            list: "SELECT body FROM faucet_serve_runs \
499                WHERE (? IS NULL OR status = ?) \
500                AND (? IS NULL OR name = ?) \
501                AND (? IS NULL OR submitted_at >= ?) \
502                AND (? IS NULL OR submitted_at <= ?) \
503                AND (? IS NULL OR (submitted_at < ? \
504                    OR (submitted_at = ? AND run_id < ?))) \
505                ORDER BY submitted_at DESC, run_id DESC LIMIT ?"
506                .into(),
507            purge_runs: "DELETE FROM faucet_serve_runs \
508                WHERE status IN ('completed','failed','cancelled') \
509                AND finished_at IS NOT NULL AND finished_at < ?"
510                .into(),
511            purge_idem: "DELETE FROM faucet_serve_idem WHERE claimed_at < ?".into(),
512            select_orphans: "SELECT body FROM faucet_serve_runs \
513                WHERE status IN ('queued','running') \
514                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
515                .into(),
516            renew_leases: "UPDATE faucet_serve_runs SET lease_expires_at = ? \
517                WHERE owner = ? AND status IN ('queued','running')"
518                .into(),
519            insert_idem: "INSERT INTO faucet_serve_idem (key,run_id,fingerprint,claimed_at) \
520                VALUES (?,?,?,?) ON CONFLICT (key) DO NOTHING"
521                .into(),
522            select_idem: "SELECT run_id,fingerprint,claimed_at FROM faucet_serve_idem WHERE key=?"
523                .into(),
524            takeover_idem: "UPDATE faucet_serve_idem \
525                SET run_id=?,fingerprint=?,claimed_at=? WHERE key=? AND claimed_at=?"
526                .into(),
527            delete_idem_by_run: "DELETE FROM faucet_serve_idem WHERE run_id=?".into(),
528            select_pending: "SELECT run_id, body FROM faucet_serve_runs \
529                WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT ?"
530                .into(),
531            claim_one: "UPDATE faucet_serve_runs \
532                SET owner = ?, status = 'running', lease_expires_at = ?, body = ? \
533                WHERE run_id = ? AND status = 'pending'"
534                .into(),
535            reclaim_select: "SELECT body FROM faucet_serve_runs \
536                WHERE status = 'running' \
537                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
538                .into(),
539            // Preserve `cancel_requested` across a requeue (audit #321 M7).
540            reclaim_requeue: "UPDATE faucet_serve_runs \
541                SET status = 'pending', owner = NULL, lease_expires_at = NULL, \
542                    body = ? \
543                WHERE run_id = ? AND status = 'running' \
544                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
545                .into(),
546            reclaim_fail: "UPDATE faucet_serve_runs \
547                SET status = 'failed', finished_at = ?, body = ?, owner = NULL \
548                WHERE run_id = ? AND status = 'running' \
549                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
550                .into(),
551            // Status-fenced (audit #321 L5): first finalizer wins.
552            finalize_owned: "UPDATE faucet_serve_runs \
553                SET status = ?, finished_at = ?, lease_expires_at = ?, body = ? \
554                WHERE run_id = ? AND owner = ? \
555                AND status NOT IN ('completed','failed','cancelled')"
556                .into(),
557            cancel_pending: "UPDATE faucet_serve_runs \
558                SET status = 'cancelled', finished_at = ?, body = ? \
559                WHERE run_id = ? AND status = 'pending'"
560                .into(),
561            request_cancel: "UPDATE faucet_serve_runs \
562                SET cancel_requested = ? WHERE run_id = ? AND status IN ('running','sharded')"
563                .into(),
564            pending_cancellations: "SELECT run_id FROM faucet_serve_runs \
565                WHERE status = 'running' AND owner = ? AND cancel_requested IS NOT NULL"
566                .into(),
567            heartbeat_instance: "INSERT INTO faucet_serve_instances \
568                (instance_id, started_at, last_heartbeat, listen, max_concurrent, in_flight) \
569                VALUES (?,?,?,?,?,?) \
570                ON CONFLICT (instance_id) DO UPDATE SET \
571                last_heartbeat = excluded.last_heartbeat, listen = excluded.listen, \
572                max_concurrent = excluded.max_concurrent, in_flight = excluded.in_flight"
573                .into(),
574            live_instances: "SELECT instance_id, started_at, last_heartbeat, listen, \
575                max_concurrent, in_flight FROM faucet_serve_instances \
576                WHERE last_heartbeat >= ?"
577                .into(),
578            prune_instances: "DELETE FROM faucet_serve_instances WHERE last_heartbeat < ?".into(),
579            insert_shard: "INSERT INTO faucet_serve_shards \
580                (run_id, shard_id, descriptor, size_estimate, status, attempt) \
581                VALUES (?,?,?,?,'pending','0') \
582                ON CONFLICT (run_id, shard_id) DO NOTHING"
583                .into(),
584            claim_shards_select: "SELECT s.run_id, s.shard_id, s.descriptor, r.body \
585                FROM faucet_serve_shards s JOIN faucet_serve_runs r ON r.run_id = s.run_id \
586                WHERE s.status = 'pending' \
587                ORDER BY CAST(COALESCE(s.size_estimate, '0') AS INTEGER) DESC, s.run_id, s.shard_id \
588                LIMIT ?"
589                .into(),
590            claim_shard_one: "UPDATE faucet_serve_shards \
591                SET owner = ?, status = 'running', lease_expires_at = ? \
592                WHERE run_id = ? AND shard_id = ? AND status = 'pending'"
593                .into(),
594            renew_shard_leases: "UPDATE faucet_serve_shards SET lease_expires_at = ? \
595                WHERE owner = ? AND status = 'running'"
596                .into(),
597            reclaim_shards_select: "SELECT run_id, shard_id, attempt FROM faucet_serve_shards \
598                WHERE status = 'running' \
599                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
600                .into(),
601            reclaim_shard_requeue: "UPDATE faucet_serve_shards \
602                SET status = 'pending', owner = NULL, lease_expires_at = NULL, attempt = ? \
603                WHERE run_id = ? AND shard_id = ? AND status = 'running' \
604                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
605                .into(),
606            reclaim_shard_fail: "UPDATE faucet_serve_shards \
607                SET status = 'failed', finished_at = ?, owner = NULL \
608                WHERE run_id = ? AND shard_id = ? AND status = 'running' \
609                AND (lease_expires_at IS NULL OR lease_expires_at < ?)"
610                .into(),
611            finalize_shard: "UPDATE faucet_serve_shards \
612                SET status = ?, finished_at = ? \
613                WHERE run_id = ? AND shard_id = ? AND owner = ? AND status = 'running'"
614                .into(),
615            shard_progress: "SELECT status, COUNT(*) AS n FROM faucet_serve_shards \
616                WHERE run_id = ? GROUP BY status"
617                .into(),
618            pending_shard_cancellations: "SELECT DISTINCT s.run_id \
619                FROM faucet_serve_shards s \
620                JOIN faucet_serve_runs r ON r.run_id = s.run_id \
621                WHERE s.owner = ? AND s.status = 'running' \
622                AND r.cancel_requested IS NOT NULL"
623                .into(),
624            select_sharded_parents: "SELECT run_id FROM faucet_serve_runs \
625                WHERE status = 'sharded'"
626                .into(),
627            finalize_sharded_parent: "UPDATE faucet_serve_runs \
628                SET status = ?, finished_at = ?, body = ? \
629                WHERE run_id = ? AND status = 'sharded'"
630                .into(),
631            delete_shards_by_run: "DELETE FROM faucet_serve_shards WHERE run_id = ?".into(),
632            purge_orphan_shards: "DELETE FROM faucet_serve_shards \
633                WHERE run_id NOT IN (SELECT run_id FROM faucet_serve_runs)"
634                .into(),
635            insert_audit: "INSERT INTO faucet_serve_audit \
636                (id, ts, principal, role, action, run_id, config_fingerprint, source_ip, result) \
637                VALUES (?,?,?,?,?,?,?,?,?)"
638                .into(),
639            list_audit: "SELECT id, ts, principal, role, action, run_id, config_fingerprint, \
640                source_ip, result FROM faucet_serve_audit \
641                WHERE (? IS NULL OR principal = ?) \
642                AND (? IS NULL OR action = ?) \
643                AND (? IS NULL OR ts >= ?) \
644                AND (? IS NULL OR ts <= ?) \
645                ORDER BY ts DESC, id DESC LIMIT ?"
646                .into(),
647            purge_audit: "DELETE FROM faucet_serve_audit WHERE ts < ?".into(),
648            catalog_select_dataset: "SELECT body FROM faucet_catalog_datasets WHERE id=?".into(),
649            catalog_upsert_dataset: "INSERT INTO faucet_catalog_datasets \
650                (id, uri, kind, last_seen, body) VALUES (?,?,?,?,?) \
651                ON CONFLICT (id) DO UPDATE SET uri=excluded.uri, kind=excluded.kind, \
652                last_seen=excluded.last_seen, body=excluded.body"
653                .into(),
654            catalog_select_datasets: "SELECT body FROM faucet_catalog_datasets".into(),
655            catalog_insert_schema_version: "INSERT INTO faucet_catalog_schema_versions \
656                (dataset_id, version, recorded_at, body) VALUES (?,?,?,?) \
657                ON CONFLICT (dataset_id, version) DO NOTHING"
658                .into(),
659            catalog_select_schema_versions: "SELECT body FROM faucet_catalog_schema_versions \
660                WHERE dataset_id=? ORDER BY CAST(version AS INTEGER) ASC"
661                .into(),
662            catalog_upsert_edge: "INSERT INTO faucet_catalog_edges \
663                (src_id, dst_id, last_seen, body) VALUES (?,?,?,?) \
664                ON CONFLICT (src_id, dst_id) DO UPDATE SET \
665                last_seen=excluded.last_seen, body=excluded.body"
666                .into(),
667            catalog_select_edges: "SELECT body FROM faucet_catalog_edges \
668                ORDER BY last_seen DESC, src_id, dst_id"
669                .into(),
670            catalog_insert_stat: "INSERT INTO faucet_catalog_stats \
671                (dataset_id, recorded_at, run_id, records) VALUES (?,?,?,?) \
672                ON CONFLICT (dataset_id, recorded_at) DO NOTHING"
673                .into(),
674            catalog_select_stats: "SELECT recorded_at, run_id, records \
675                FROM faucet_catalog_stats WHERE dataset_id=? \
676                ORDER BY recorded_at DESC LIMIT ?"
677                .into(),
678            catalog_prune_stats: "DELETE FROM faucet_catalog_stats \
679                WHERE dataset_id=? AND recorded_at NOT IN (\
680                    SELECT recorded_at FROM faucet_catalog_stats WHERE dataset_id=? \
681                    ORDER BY recorded_at DESC LIMIT ?)"
682                .into(),
683        }
684    }
685}
686
687/// Bounded retry count for the atomic idempotency claim (handles a claim being
688/// purged concurrently between the insert attempt and the read-back).
689pub const CLAIM_ATTEMPTS: usize = 4;
690
691/// Fixed-width RFC3339 (nanoseconds + `Z`) — lexicographically sortable.
692pub fn fmt_ts(dt: DateTime<Utc>) -> String {
693    dt.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)
694}
695
696/// True when a claim timestamped `claimed_at` (RFC3339) is older than `window`.
697/// An unparseable or future timestamp is treated as **not** expired (safe: it
698/// won't be silently re-claimed).
699pub fn is_expired(claimed_at: &str, now: DateTime<Utc>, window: Duration) -> bool {
700    match DateTime::parse_from_rfc3339(claimed_at) {
701        Ok(t) => now
702            .signed_duration_since(t.with_timezone(&Utc))
703            .to_std()
704            .map(|age| age >= window)
705            .unwrap_or(false),
706        Err(_) => false,
707    }
708}
709
710/// RFC3339 timestamp `window` before `now` (the purge / expiry threshold).
711pub fn threshold(now: DateTime<Utc>, window: Duration) -> String {
712    let delta =
713        chrono::Duration::from_std(window).unwrap_or_else(|_| chrono::Duration::days(36_500));
714    fmt_ts(now - delta)
715}
716
717pub fn encode_body(rec: &RunRecord) -> Result<String, HistoryError> {
718    serde_json::to_string(rec).map_err(|e| HistoryError::Backend(format!("encode run record: {e}")))
719}
720
721pub fn decode_body(body: &str) -> Result<RunRecord, HistoryError> {
722    serde_json::from_str(body).map_err(|e| HistoryError::Backend(format!("decode run record: {e}")))
723}
724
725/// Generic body (de)serialization for the catalog tables (#279).
726pub fn encode_json<T: serde::Serialize>(value: &T, what: &str) -> Result<String, HistoryError> {
727    serde_json::to_string(value).map_err(|e| HistoryError::Backend(format!("encode {what}: {e}")))
728}
729
730pub fn decode_json<T: serde::de::DeserializeOwned>(
731    body: &str,
732    what: &str,
733) -> Result<T, HistoryError> {
734    serde_json::from_str(body).map_err(|e| HistoryError::Backend(format!("decode {what}: {e}")))
735}
736
737pub fn parse_status(s: &str) -> RunStatus {
738    match s {
739        "queued" => RunStatus::Queued,
740        "pending" => RunStatus::Pending,
741        "running" => RunStatus::Running,
742        "sharded" => RunStatus::Sharded,
743        "completed" => RunStatus::Completed,
744        "cancelled" => RunStatus::Cancelled,
745        _ => RunStatus::Failed,
746    }
747}
748
749/// Generate a concrete `RunHistory` implementation over a specific `sqlx` pool.
750/// `$name` is the backend struct, `$pool` its `sqlx` pool type. The struct holds
751/// the pool, the idempotency retention window, and the dialect's [`Stmts`].
752macro_rules! impl_sql_history {
753    ($name:ident, $pool:ty) => {
754        /// SQL-backed [`RunHistory`](crate::serve::history::RunHistory). See
755        /// [`crate::serve::history::sql`] for the shared schema + semantics.
756        pub struct $name {
757            pool: $pool,
758            idem_retention: std::time::Duration,
759            /// This serve instance's id, stamped as `owner` on every upsert.
760            instance_id: String,
761            /// How far ahead each upsert / heartbeat pushes a run's lease.
762            lease_ttl: std::time::Duration,
763            stmts: $crate::serve::history::sql::Stmts,
764        }
765
766        impl $name {
767            /// Assemble from an already-connected pool (used by `connect`).
768            pub fn from_parts(
769                pool: $pool,
770                idem_retention: std::time::Duration,
771                lease_ttl: std::time::Duration,
772                instance_id: String,
773                stmts: $crate::serve::history::sql::Stmts,
774            ) -> Self {
775                Self {
776                    pool,
777                    idem_retention,
778                    instance_id,
779                    lease_ttl,
780                    stmts,
781                }
782            }
783
784            /// Borrow the underlying pool (tests close it to exercise fallback).
785            pub fn pool(&self) -> &$pool {
786                &self.pool
787            }
788        }
789
790        #[async_trait::async_trait]
791        impl $crate::serve::history::RunHistory for $name {
792            async fn claim_idempotency(
793                &self,
794                key: &str,
795                fingerprint: &str,
796                run_id: &str,
797                window: std::time::Duration,
798            ) -> Result<$crate::serve::history::Claim, $crate::serve::history::HistoryError> {
799                use sqlx::Row as _;
800                use $crate::serve::history::Claim;
801                use $crate::serve::history::HistoryError;
802                use $crate::serve::history::sql;
803
804                let now = chrono::Utc::now();
805                let now_s = sql::fmt_ts(now);
806                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
807
808                for _ in 0..sql::CLAIM_ATTEMPTS {
809                    // 1) Atomic first-claim: the winner inserts exactly one row.
810                    let inserted = sqlx::query(&self.stmts.insert_idem)
811                        .bind(key)
812                        .bind(run_id)
813                        .bind(fingerprint)
814                        .bind(&now_s)
815                        .execute(&self.pool)
816                        .await
817                        .map_err(backend)?
818                        .rows_affected();
819                    if inserted == 1 {
820                        return Ok(Claim::Fresh);
821                    }
822                    // 2) Conflict: inspect the existing claim.
823                    let Some(row) = sqlx::query(&self.stmts.select_idem)
824                        .bind(key)
825                        .fetch_optional(&self.pool)
826                        .await
827                        .map_err(backend)?
828                    else {
829                        // Vanished between the insert and the read — retry.
830                        continue;
831                    };
832                    let existing_run: String = row.try_get("run_id").map_err(backend)?;
833                    let existing_fp: String = row.try_get("fingerprint").map_err(backend)?;
834                    let claimed_at: String = row.try_get("claimed_at").map_err(backend)?;
835
836                    if sql::is_expired(&claimed_at, now, window) {
837                        // 3) Optimistic, expiry-guarded takeover: only the request
838                        // that still sees `claimed_at` succeeds.
839                        let took = sqlx::query(&self.stmts.takeover_idem)
840                            .bind(run_id)
841                            .bind(fingerprint)
842                            .bind(&now_s)
843                            .bind(key)
844                            .bind(&claimed_at)
845                            .execute(&self.pool)
846                            .await
847                            .map_err(backend)?
848                            .rows_affected();
849                        if took == 1 {
850                            return Ok(Claim::Fresh);
851                        }
852                        continue; // lost the race; re-evaluate
853                    }
854                    return Ok(if existing_fp == fingerprint {
855                        Claim::Replay(existing_run)
856                    } else {
857                        Claim::Conflict
858                    });
859                }
860                // Pathological contention only. Conservative: a 409 is safer than
861                // risking a duplicate run.
862                tracing::warn!(
863                    key,
864                    "idempotency claim exhausted retries; reporting conflict"
865                );
866                Ok(Claim::Conflict)
867            }
868
869            async fn upsert(
870                &self,
871                rec: &$crate::serve::history::RunRecord,
872            ) -> Result<(), $crate::serve::history::HistoryError> {
873                use $crate::serve::history::HistoryError;
874                use $crate::serve::history::sql;
875                let body = sql::encode_body(rec)?;
876                let submitted = sql::fmt_ts(rec.submitted_at);
877                let finished = rec.finished_at.map(sql::fmt_ts);
878                // Stamp this instance as the owner and start/renew the lease.
879                // The owner/lease are SQL-column-only (never in the record body),
880                // so the heartbeat can extend a lease without a body read-modify-
881                // write race (#146 H7).
882                let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
883                sqlx::query(&self.stmts.upsert)
884                    .bind(&rec.run_id)
885                    .bind(rec.name.as_deref())
886                    .bind(rec.status.as_str())
887                    .bind(&submitted)
888                    .bind(finished.as_deref())
889                    .bind(rec.idempotency_key.as_deref())
890                    .bind(&self.instance_id)
891                    .bind(&lease)
892                    .bind(&body)
893                    .execute(&self.pool)
894                    .await
895                    .map_err(|e| HistoryError::Backend(e.to_string()))?;
896                Ok(())
897            }
898
899            async fn get(
900                &self,
901                id: &str,
902            ) -> Result<
903                Option<$crate::serve::history::RunRecord>,
904                $crate::serve::history::HistoryError,
905            > {
906                use sqlx::Row as _;
907                use $crate::serve::history::HistoryError;
908                use $crate::serve::history::sql;
909                let row = sqlx::query(&self.stmts.select_body)
910                    .bind(id)
911                    .fetch_optional(&self.pool)
912                    .await
913                    .map_err(|e| HistoryError::Backend(e.to_string()))?;
914                match row {
915                    None => Ok(None),
916                    Some(r) => {
917                        let body: String = r
918                            .try_get("body")
919                            .map_err(|e| HistoryError::Backend(e.to_string()))?;
920                        Ok(Some(sql::decode_body(&body)?))
921                    }
922                }
923            }
924
925            async fn list(
926                &self,
927                filter: &$crate::serve::history::ListFilter,
928            ) -> Result<$crate::serve::history::ListPage, $crate::serve::history::HistoryError>
929            {
930                use sqlx::Row as _;
931                use $crate::serve::history::HistoryError;
932                use $crate::serve::history::ListPage;
933                use $crate::serve::history::sql;
934                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
935
936                // Resolve the cursor's submitted_at for keyset pagination. An
937                // unknown cursor is ignored (page starts from the top), matching
938                // the memory backend.
939                let cursor_ts: Option<String> = match &filter.cursor {
940                    None => None,
941                    Some(c) => sqlx::query(&self.stmts.select_submitted)
942                        .bind(c)
943                        .fetch_optional(&self.pool)
944                        .await
945                        .map_err(backend)?
946                        .map(|r| r.try_get::<String, _>("submitted_at"))
947                        .transpose()
948                        .map_err(backend)?,
949                };
950                let cur_id = if cursor_ts.is_some() {
951                    filter.cursor.as_deref()
952                } else {
953                    None
954                };
955
956                let status_s = filter.status.map(|s| s.as_str());
957                let name_s = filter.name.as_deref();
958                let since_s = filter.since.map(sql::fmt_ts);
959                let until_s = filter.until.map(sql::fmt_ts);
960                let limit = filter.limit.max(1);
961                let fetch_n = limit as i64 + 1; // +1 to detect a next page
962
963                let rows = sqlx::query(&self.stmts.list)
964                    .bind(status_s)
965                    .bind(status_s)
966                    .bind(name_s)
967                    .bind(name_s)
968                    .bind(since_s.as_deref())
969                    .bind(since_s.as_deref())
970                    .bind(until_s.as_deref())
971                    .bind(until_s.as_deref())
972                    .bind(cursor_ts.as_deref())
973                    .bind(cursor_ts.as_deref())
974                    .bind(cursor_ts.as_deref())
975                    .bind(cur_id)
976                    .bind(fetch_n)
977                    .fetch_all(&self.pool)
978                    .await
979                    .map_err(backend)?;
980
981                let mut runs = Vec::with_capacity(rows.len());
982                for r in &rows {
983                    let body: String = r.try_get("body").map_err(backend)?;
984                    runs.push(sql::decode_body(&body)?);
985                }
986                let next_cursor = if runs.len() > limit {
987                    Some(runs[limit - 1].run_id.clone())
988                } else {
989                    None
990                };
991                runs.truncate(limit);
992                Ok(ListPage { runs, next_cursor })
993            }
994
995            async fn delete(
996                &self,
997                id: &str,
998            ) -> Result<$crate::serve::history::DeleteOutcome, $crate::serve::history::HistoryError>
999            {
1000                use sqlx::Row as _;
1001                use $crate::serve::history::DeleteOutcome;
1002                use $crate::serve::history::HistoryError;
1003                use $crate::serve::history::sql;
1004                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1005                let status: Option<String> = sqlx::query(&self.stmts.select_status)
1006                    .bind(id)
1007                    .fetch_optional(&self.pool)
1008                    .await
1009                    .map_err(backend)?
1010                    .map(|r| r.try_get::<String, _>("status"))
1011                    .transpose()
1012                    .map_err(backend)?;
1013                match status {
1014                    None => Ok(DeleteOutcome::NotFound),
1015                    Some(s) if !sql::parse_status(&s).is_terminal() => {
1016                        Ok(DeleteOutcome::StillRunning)
1017                    }
1018                    Some(_) => {
1019                        sqlx::query(&self.stmts.delete)
1020                            .bind(id)
1021                            .execute(&self.pool)
1022                            .await
1023                            .map_err(backend)?;
1024                        // Drop the run's idempotency claim too, so a replay of
1025                        // the key starts fresh instead of 404-ing on the deleted
1026                        // record until the claim self-expires (#146 M8). Scoped
1027                        // by run_id, so a newer run that re-claimed the same key
1028                        // keeps its claim.
1029                        sqlx::query(&self.stmts.delete_idem_by_run)
1030                            .bind(id)
1031                            .execute(&self.pool)
1032                            .await
1033                            .map_err(backend)?;
1034                        // Drop the run's shard rows too (Mode B, #230), so a
1035                        // deleted run leaves no orphaned shard rows that would
1036                        // otherwise leak unboundedly (F25).
1037                        sqlx::query(&self.stmts.delete_shards_by_run)
1038                            .bind(id)
1039                            .execute(&self.pool)
1040                            .await
1041                            .map_err(backend)?;
1042                        Ok(DeleteOutcome::Deleted)
1043                    }
1044                }
1045            }
1046
1047            async fn release_idempotency(
1048                &self,
1049                run_id: &str,
1050            ) -> Result<(), $crate::serve::history::HistoryError> {
1051                use $crate::serve::history::HistoryError;
1052                sqlx::query(&self.stmts.delete_idem_by_run)
1053                    .bind(run_id)
1054                    .execute(&self.pool)
1055                    .await
1056                    .map_err(|e| HistoryError::Backend(e.to_string()))?;
1057                Ok(())
1058            }
1059
1060            async fn purge_expired(
1061                &self,
1062                retain_for: std::time::Duration,
1063            ) -> Result<usize, $crate::serve::history::HistoryError> {
1064                use $crate::serve::history::HistoryError;
1065                use $crate::serve::history::sql;
1066                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1067                let now = chrono::Utc::now();
1068                let removed = sqlx::query(&self.stmts.purge_runs)
1069                    .bind(sql::threshold(now, retain_for))
1070                    .execute(&self.pool)
1071                    .await
1072                    .map_err(backend)?
1073                    .rows_affected() as usize;
1074                // Drop expired idempotency claims too (best-effort).
1075                let _ = sqlx::query(&self.stmts.purge_idem)
1076                    .bind(sql::threshold(now, self.idem_retention))
1077                    .execute(&self.pool)
1078                    .await;
1079                // Drop membership rows that have not heartbeated within the
1080                // run-retention window (far longer than the lease, so this never
1081                // prunes a live member — that's `live_instances(ttl)`'s job).
1082                let _ = sqlx::query(&self.stmts.prune_instances)
1083                    .bind(sql::threshold(now, retain_for))
1084                    .execute(&self.pool)
1085                    .await;
1086                // Reclaim shard rows whose parent run was just purged (F25):
1087                // `purge_runs` removed the expired terminal records above, so any
1088                // shard row no longer matching a run is orphaned. Best-effort.
1089                let _ = sqlx::query(&self.stmts.purge_orphan_shards)
1090                    .execute(&self.pool)
1091                    .await;
1092                // Drop audit records older than the run-retention window (#205).
1093                let _ = sqlx::query(&self.stmts.purge_audit)
1094                    .bind(sql::threshold(now, retain_for))
1095                    .execute(&self.pool)
1096                    .await;
1097                Ok(removed)
1098            }
1099
1100            async fn recover_orphans(&self) -> Result<usize, $crate::serve::history::HistoryError> {
1101                use sqlx::Row as _;
1102                use $crate::serve::history::HistoryError;
1103                use $crate::serve::history::RunStatus;
1104                use $crate::serve::history::sql;
1105                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1106                let now = chrono::Utc::now();
1107                // Only non-terminal runs whose lease has expired (the owning
1108                // instance is presumed dead). A live instance heartbeats its
1109                // runs' leases into the future, so this never fails another
1110                // healthy instance's in-flight runs (#146 H7).
1111                let rows = sqlx::query(&self.stmts.select_orphans)
1112                    .bind(sql::fmt_ts(now))
1113                    .fetch_all(&self.pool)
1114                    .await
1115                    .map_err(backend)?;
1116                let mut count = 0usize;
1117                for r in &rows {
1118                    let body: String = r.try_get("body").map_err(backend)?;
1119                    let mut rec = sql::decode_body(&body)?;
1120                    rec.status = RunStatus::Failed;
1121                    rec.finished_at = Some(now);
1122                    rec.error = Some(
1123                        "owning serve instance's lease expired before the run finished".into(),
1124                    );
1125                    if rec.elapsed_secs.is_none()
1126                        && let Some(started) = rec.started_at
1127                    {
1128                        rec.elapsed_secs = (now - started).to_std().ok().map(|d| d.as_secs_f64());
1129                    }
1130                    self.upsert(&rec).await?;
1131                    count += 1;
1132                }
1133                Ok(count)
1134            }
1135
1136            async fn renew_leases(&self) -> Result<usize, $crate::serve::history::HistoryError> {
1137                use $crate::serve::history::HistoryError;
1138                use $crate::serve::history::sql;
1139                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1140                let new_lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1141                let renewed = sqlx::query(&self.stmts.renew_leases)
1142                    .bind(&new_lease)
1143                    .bind(&self.instance_id)
1144                    .execute(&self.pool)
1145                    .await
1146                    .map_err(backend)?
1147                    .rows_affected() as usize;
1148                Ok(renewed)
1149            }
1150
1151            async fn claim_pending(
1152                &self,
1153                limit: usize,
1154            ) -> Result<Vec<$crate::serve::history::RunRecord>, $crate::serve::history::HistoryError>
1155            {
1156                use sqlx::Row as _;
1157                use $crate::serve::history::HistoryError;
1158                use $crate::serve::history::RunStatus;
1159                use $crate::serve::history::sql;
1160                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1161                if limit == 0 {
1162                    return Ok(Vec::new());
1163                }
1164                let now = chrono::Utc::now();
1165                let lease = sql::fmt_ts(now + self.lease_ttl);
1166
1167                // 1. Candidate pending runs (oldest first), with their bodies.
1168                let rows = sqlx::query(&self.stmts.select_pending)
1169                    .bind(limit as i64)
1170                    .fetch_all(&self.pool)
1171                    .await
1172                    .map_err(backend)?;
1173
1174                // Per-row conditional claim (1 SELECT + N guarded UPDATEs). The
1175                // batch is bounded by the caller's free permits (small), and this
1176                // is portable across Postgres + SQLite — deliberately NOT a
1177                // Postgres-only `FOR UPDATE SKIP LOCKED`.
1178                let mut claimed = Vec::new();
1179                for row in &rows {
1180                    let run_id: String = row.try_get("run_id").map_err(backend)?;
1181                    let body: String = row.try_get("body").map_err(backend)?;
1182                    // Flip the record to Running and rewrite the body so the column
1183                    // and the (source-of-truth) body stay consistent — a GET right
1184                    // after the claim must not show a stale `pending`.
1185                    let mut r = sql::decode_body(&body)?;
1186                    r.status = RunStatus::Running;
1187                    let new_body = sql::encode_body(&r)?;
1188                    // 2. Conditional claim — only the first committer wins.
1189                    let won = sqlx::query(&self.stmts.claim_one)
1190                        .bind(&self.instance_id)
1191                        .bind(&lease)
1192                        .bind(&new_body)
1193                        .bind(&run_id)
1194                        .execute(&self.pool)
1195                        .await
1196                        .map_err(backend)?
1197                        .rows_affected();
1198                    if won == 1 {
1199                        claimed.push(r);
1200                    }
1201                }
1202                Ok(claimed)
1203            }
1204
1205            async fn reclaim_orphans(
1206                &self,
1207                max_attempts: u32,
1208            ) -> Result<$crate::serve::history::ReclaimReport, $crate::serve::history::HistoryError>
1209            {
1210                use sqlx::Row as _;
1211                use $crate::serve::history::HistoryError;
1212                use $crate::serve::history::ReclaimReport;
1213                use $crate::serve::history::RunStatus;
1214                use $crate::serve::history::sql;
1215                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1216                let now = chrono::Utc::now();
1217                let now_s = sql::fmt_ts(now);
1218
1219                let rows = sqlx::query(&self.stmts.reclaim_select)
1220                    .bind(&now_s)
1221                    .fetch_all(&self.pool)
1222                    .await
1223                    .map_err(backend)?;
1224
1225                let mut report = ReclaimReport::default();
1226                for row in &rows {
1227                    let body: String = row.try_get("body").map_err(backend)?;
1228                    let mut rec = sql::decode_body(&body)?;
1229                    let next_attempt = rec.attempt + 1;
1230                    // Cap is on the attempts already made: a run that has been
1231                    // reclaimed fewer than `max_attempts` times gets another try;
1232                    // once it reaches the cap it is poisoned.
1233                    if rec.attempt < max_attempts {
1234                        // Re-queue for another instance to re-run.
1235                        rec.attempt = next_attempt;
1236                        rec.status = RunStatus::Pending;
1237                        let new_body = sql::encode_body(&rec)?;
1238                        let n = sqlx::query(&self.stmts.reclaim_requeue)
1239                            .bind(&new_body)
1240                            .bind(&rec.run_id)
1241                            .bind(&now_s)
1242                            .execute(&self.pool)
1243                            .await
1244                            .map_err(backend)?
1245                            .rows_affected();
1246                        if n == 1 {
1247                            report.requeued += 1;
1248                        }
1249                    } else {
1250                        // Poison: too many attempts.
1251                        rec.attempt = next_attempt;
1252                        rec.status = RunStatus::Failed;
1253                        rec.finished_at = Some(now);
1254                        rec.error = Some(format!(
1255                            "run reclaimed {next_attempt} times after its owning instance's \
1256                             lease expired; giving up (poison run)"
1257                        ));
1258                        if rec.elapsed_secs.is_none()
1259                            && let Some(started) = rec.started_at
1260                        {
1261                            rec.elapsed_secs =
1262                                (now - started).to_std().ok().map(|d| d.as_secs_f64());
1263                        }
1264                        let new_body = sql::encode_body(&rec)?;
1265                        let n = sqlx::query(&self.stmts.reclaim_fail)
1266                            .bind(&now_s)
1267                            .bind(&new_body)
1268                            .bind(&rec.run_id)
1269                            .bind(&now_s)
1270                            .execute(&self.pool)
1271                            .await
1272                            .map_err(backend)?
1273                            .rows_affected();
1274                        if n == 1 {
1275                            report.failed += 1;
1276                        }
1277                    }
1278                }
1279                Ok(report)
1280            }
1281
1282            async fn finalize_owned(
1283                &self,
1284                rec: &$crate::serve::history::RunRecord,
1285            ) -> Result<bool, $crate::serve::history::HistoryError> {
1286                use $crate::serve::history::HistoryError;
1287                use $crate::serve::history::sql;
1288                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1289                // Defensive: a terminal record must carry finished_at, or
1290                // purge_runs (which requires finished_at IS NOT NULL) can never
1291                // reclaim it. Stamp it if a caller left it unset.
1292                let mut rec = rec.clone();
1293                if rec.status.is_terminal() && rec.finished_at.is_none() {
1294                    rec.finished_at = Some(chrono::Utc::now());
1295                }
1296                let body = sql::encode_body(&rec)?;
1297                let finished = rec.finished_at.map(sql::fmt_ts);
1298                let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1299                let n = sqlx::query(&self.stmts.finalize_owned)
1300                    .bind(rec.status.as_str())
1301                    .bind(finished.as_deref())
1302                    .bind(&lease)
1303                    .bind(&body)
1304                    .bind(&rec.run_id)
1305                    .bind(&self.instance_id)
1306                    .execute(&self.pool)
1307                    .await
1308                    .map_err(backend)?
1309                    .rows_affected();
1310                Ok(n == 1)
1311            }
1312
1313            async fn finalize_sharded_parent(
1314                &self,
1315                run_id: &str,
1316                status: $crate::serve::history::RunStatus,
1317                finished_at: chrono::DateTime<chrono::Utc>,
1318                error: Option<String>,
1319            ) -> Result<bool, $crate::serve::history::HistoryError> {
1320                use sqlx::Row as _;
1321                use $crate::serve::history::HistoryError;
1322                use $crate::serve::history::RunStatus;
1323                use $crate::serve::history::sql;
1324                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1325                // Read the parent body, apply the terminal status, and write back
1326                // conditional on it still being `sharded` — so a concurrent
1327                // double-finalize from two instances has exactly one winner and
1328                // neither re-stamps owner/lease on the terminal record (F45).
1329                let Some(row) = sqlx::query(&self.stmts.select_body)
1330                    .bind(run_id)
1331                    .fetch_optional(&self.pool)
1332                    .await
1333                    .map_err(backend)?
1334                else {
1335                    return Ok(false);
1336                };
1337                let body: String = row.try_get("body").map_err(backend)?;
1338                let mut rec = sql::decode_body(&body)?;
1339                if rec.status != RunStatus::Sharded {
1340                    return Ok(false);
1341                }
1342                rec.status = status;
1343                rec.finished_at = Some(finished_at);
1344                rec.error = error;
1345                let new_body = sql::encode_body(&rec)?;
1346                let n = sqlx::query(&self.stmts.finalize_sharded_parent)
1347                    .bind(status.as_str())
1348                    .bind(sql::fmt_ts(finished_at))
1349                    .bind(&new_body)
1350                    .bind(run_id)
1351                    .execute(&self.pool)
1352                    .await
1353                    .map_err(backend)?
1354                    .rows_affected();
1355                Ok(n == 1)
1356            }
1357
1358            async fn cancel_pending(
1359                &self,
1360                run_id: &str,
1361            ) -> Result<bool, $crate::serve::history::HistoryError> {
1362                use sqlx::Row as _;
1363                use $crate::serve::history::HistoryError;
1364                use $crate::serve::history::RunStatus;
1365                use $crate::serve::history::sql;
1366                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1367                // Read the pending run's body, flip it to Cancelled, and write back
1368                // conditional on it still being pending (loses the race to a claim).
1369                let Some(row) = sqlx::query(&self.stmts.select_body)
1370                    .bind(run_id)
1371                    .fetch_optional(&self.pool)
1372                    .await
1373                    .map_err(backend)?
1374                else {
1375                    return Ok(false);
1376                };
1377                let body: String = row.try_get("body").map_err(backend)?;
1378                let mut rec = sql::decode_body(&body)?;
1379                if rec.status != RunStatus::Pending {
1380                    return Ok(false);
1381                }
1382                let now = chrono::Utc::now();
1383                rec.status = RunStatus::Cancelled;
1384                rec.finished_at = Some(now);
1385                let new_body = sql::encode_body(&rec)?;
1386                let n = sqlx::query(&self.stmts.cancel_pending)
1387                    .bind(sql::fmt_ts(now))
1388                    .bind(&new_body)
1389                    .bind(run_id)
1390                    .execute(&self.pool)
1391                    .await
1392                    .map_err(backend)?
1393                    .rows_affected();
1394                Ok(n == 1)
1395            }
1396
1397            async fn request_cancel(
1398                &self,
1399                run_id: &str,
1400            ) -> Result<(), $crate::serve::history::HistoryError> {
1401                use $crate::serve::history::HistoryError;
1402                use $crate::serve::history::sql;
1403                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1404                sqlx::query(&self.stmts.request_cancel)
1405                    .bind(sql::fmt_ts(chrono::Utc::now()))
1406                    .bind(run_id)
1407                    .execute(&self.pool)
1408                    .await
1409                    .map_err(backend)?;
1410                Ok(())
1411            }
1412
1413            async fn pending_cancellations(
1414                &self,
1415            ) -> Result<Vec<String>, $crate::serve::history::HistoryError> {
1416                use sqlx::Row as _;
1417                use $crate::serve::history::HistoryError;
1418                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1419                let rows = sqlx::query(&self.stmts.pending_cancellations)
1420                    .bind(&self.instance_id)
1421                    .fetch_all(&self.pool)
1422                    .await
1423                    .map_err(backend)?;
1424                let mut ids = Vec::with_capacity(rows.len());
1425                for r in &rows {
1426                    ids.push(r.try_get::<String, _>("run_id").map_err(backend)?);
1427                }
1428                Ok(ids)
1429            }
1430
1431            async fn heartbeat_instance(
1432                &self,
1433                beat: &$crate::serve::history::InstanceHeartbeat,
1434            ) -> Result<(), $crate::serve::history::HistoryError> {
1435                use $crate::serve::history::HistoryError;
1436                use $crate::serve::history::sql;
1437                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1438                let now = sql::fmt_ts(chrono::Utc::now());
1439                sqlx::query(&self.stmts.heartbeat_instance)
1440                    .bind(&self.instance_id)
1441                    .bind(sql::fmt_ts(beat.started_at))
1442                    .bind(&now)
1443                    .bind(beat.listen.as_deref())
1444                    .bind(beat.max_concurrent.to_string())
1445                    .bind(beat.in_flight.to_string())
1446                    .execute(&self.pool)
1447                    .await
1448                    .map_err(backend)?;
1449                Ok(())
1450            }
1451
1452            async fn live_instances(
1453                &self,
1454                ttl: std::time::Duration,
1455            ) -> Result<Vec<$crate::serve::history::InstanceRecord>, $crate::serve::history::HistoryError>
1456            {
1457                use sqlx::Row as _;
1458                use $crate::serve::history::HistoryError;
1459                use $crate::serve::history::InstanceRecord;
1460                use $crate::serve::history::sql;
1461                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1462                let now = chrono::Utc::now();
1463                let rows = sqlx::query(&self.stmts.live_instances)
1464                    .bind(sql::threshold(now, ttl))
1465                    .fetch_all(&self.pool)
1466                    .await
1467                    .map_err(backend)?;
1468                let parse_dt = |s: &str| {
1469                    chrono::DateTime::parse_from_rfc3339(s)
1470                        .map(|d| d.to_utc())
1471                        .unwrap_or(now)
1472                };
1473                let mut out = Vec::with_capacity(rows.len());
1474                for r in &rows {
1475                    let started: String = r.try_get("started_at").map_err(backend)?;
1476                    let hb: String = r.try_get("last_heartbeat").map_err(backend)?;
1477                    let mc: Option<String> = r.try_get("max_concurrent").map_err(backend)?;
1478                    let inf: Option<String> = r.try_get("in_flight").map_err(backend)?;
1479                    out.push(InstanceRecord {
1480                        instance_id: r.try_get("instance_id").map_err(backend)?,
1481                        started_at: parse_dt(&started),
1482                        last_heartbeat: parse_dt(&hb),
1483                        listen: r.try_get("listen").map_err(backend)?,
1484                        max_concurrent: mc.and_then(|s| s.parse().ok()).unwrap_or(0),
1485                        in_flight: inf.and_then(|s| s.parse().ok()).unwrap_or(0),
1486                    });
1487                }
1488                Ok(out)
1489            }
1490
1491            // ── Source shards (Mode B, #230) ─────────────────────────────────
1492
1493            async fn insert_shards(
1494                &self,
1495                run_id: &str,
1496                shards: &[$crate::serve::history::ShardInsert],
1497            ) -> Result<usize, $crate::serve::history::HistoryError> {
1498                use $crate::serve::history::HistoryError;
1499                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1500                let mut inserted = 0usize;
1501                for s in shards {
1502                    let descriptor = serde_json::to_string(&s.descriptor).map_err(|e| {
1503                        HistoryError::Backend(format!("encode shard descriptor: {e}"))
1504                    })?;
1505                    let size = s.size_estimate.map(|n| n.to_string());
1506                    let n = sqlx::query(&self.stmts.insert_shard)
1507                        .bind(run_id)
1508                        .bind(&s.shard_id)
1509                        .bind(&descriptor)
1510                        .bind(size.as_deref())
1511                        .execute(&self.pool)
1512                        .await
1513                        .map_err(backend)?
1514                        .rows_affected();
1515                    inserted += n as usize;
1516                }
1517                Ok(inserted)
1518            }
1519
1520            async fn claim_shards(
1521                &self,
1522                limit: usize,
1523            ) -> Result<
1524                Vec<$crate::serve::history::ClaimedShard>,
1525                $crate::serve::history::HistoryError,
1526            > {
1527                use sqlx::Row as _;
1528                use $crate::serve::history::ClaimedShard;
1529                use $crate::serve::history::HistoryError;
1530                use $crate::serve::history::sql;
1531                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1532                if limit == 0 {
1533                    return Ok(Vec::new());
1534                }
1535                let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1536
1537                // 1. Candidate pending shards (largest estimated size first),
1538                //    joined to their parent run body.
1539                let rows = sqlx::query(&self.stmts.claim_shards_select)
1540                    .bind(limit as i64)
1541                    .fetch_all(&self.pool)
1542                    .await
1543                    .map_err(backend)?;
1544
1545                // 2. Per-row conditional claim (portable; not FOR UPDATE SKIP LOCKED).
1546                let mut claimed = Vec::new();
1547                for row in &rows {
1548                    let run_id: String = row.try_get("run_id").map_err(backend)?;
1549                    let shard_id: String = row.try_get("shard_id").map_err(backend)?;
1550                    let descriptor_s: String = row.try_get("descriptor").map_err(backend)?;
1551                    let body: String = row.try_get("body").map_err(backend)?;
1552                    let won = sqlx::query(&self.stmts.claim_shard_one)
1553                        .bind(&self.instance_id)
1554                        .bind(&lease)
1555                        .bind(&run_id)
1556                        .bind(&shard_id)
1557                        .execute(&self.pool)
1558                        .await
1559                        .map_err(backend)?
1560                        .rows_affected();
1561                    if won == 1 {
1562                        let descriptor: serde_json::Value = serde_json::from_str(&descriptor_s)
1563                            .map_err(|e| {
1564                                HistoryError::Backend(format!("decode shard descriptor: {e}"))
1565                            })?;
1566                        let run = sql::decode_body(&body)?;
1567                        claimed.push(ClaimedShard {
1568                            run_id,
1569                            shard_id,
1570                            descriptor,
1571                            run,
1572                        });
1573                    }
1574                }
1575                Ok(claimed)
1576            }
1577
1578            async fn renew_shard_leases(
1579                &self,
1580            ) -> Result<usize, $crate::serve::history::HistoryError> {
1581                use $crate::serve::history::HistoryError;
1582                use $crate::serve::history::sql;
1583                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1584                let lease = sql::fmt_ts(chrono::Utc::now() + self.lease_ttl);
1585                let n = sqlx::query(&self.stmts.renew_shard_leases)
1586                    .bind(&lease)
1587                    .bind(&self.instance_id)
1588                    .execute(&self.pool)
1589                    .await
1590                    .map_err(backend)?
1591                    .rows_affected() as usize;
1592                Ok(n)
1593            }
1594
1595            async fn reclaim_shards(
1596                &self,
1597                max_attempts: u32,
1598            ) -> Result<$crate::serve::history::ReclaimReport, $crate::serve::history::HistoryError>
1599            {
1600                use sqlx::Row as _;
1601                use $crate::serve::history::HistoryError;
1602                use $crate::serve::history::ReclaimReport;
1603                use $crate::serve::history::sql;
1604                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1605                let now_s = sql::fmt_ts(chrono::Utc::now());
1606
1607                let rows = sqlx::query(&self.stmts.reclaim_shards_select)
1608                    .bind(&now_s)
1609                    .fetch_all(&self.pool)
1610                    .await
1611                    .map_err(backend)?;
1612
1613                let mut report = ReclaimReport::default();
1614                for row in &rows {
1615                    let run_id: String = row.try_get("run_id").map_err(backend)?;
1616                    let shard_id: String = row.try_get("shard_id").map_err(backend)?;
1617                    let attempt_s: String = row.try_get("attempt").map_err(backend)?;
1618                    let attempt: u32 = attempt_s.parse().unwrap_or(0);
1619                    if attempt < max_attempts {
1620                        let next = (attempt + 1).to_string();
1621                        let n = sqlx::query(&self.stmts.reclaim_shard_requeue)
1622                            .bind(&next)
1623                            .bind(&run_id)
1624                            .bind(&shard_id)
1625                            .bind(&now_s)
1626                            .execute(&self.pool)
1627                            .await
1628                            .map_err(backend)?
1629                            .rows_affected();
1630                        if n == 1 {
1631                            report.requeued += 1;
1632                        }
1633                    } else {
1634                        let n = sqlx::query(&self.stmts.reclaim_shard_fail)
1635                            .bind(&now_s)
1636                            .bind(&run_id)
1637                            .bind(&shard_id)
1638                            .bind(&now_s)
1639                            .execute(&self.pool)
1640                            .await
1641                            .map_err(backend)?
1642                            .rows_affected();
1643                        if n == 1 {
1644                            report.failed += 1;
1645                        }
1646                    }
1647                }
1648                Ok(report)
1649            }
1650
1651            async fn finalize_shard(
1652                &self,
1653                run_id: &str,
1654                shard_id: &str,
1655                success: bool,
1656            ) -> Result<bool, $crate::serve::history::HistoryError> {
1657                use $crate::serve::history::HistoryError;
1658                use $crate::serve::history::sql;
1659                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1660                let status = if success { "completed" } else { "failed" };
1661                let now_s = sql::fmt_ts(chrono::Utc::now());
1662                let n = sqlx::query(&self.stmts.finalize_shard)
1663                    .bind(status)
1664                    .bind(&now_s)
1665                    .bind(run_id)
1666                    .bind(shard_id)
1667                    .bind(&self.instance_id)
1668                    .execute(&self.pool)
1669                    .await
1670                    .map_err(backend)?
1671                    .rows_affected();
1672                Ok(n == 1)
1673            }
1674
1675            async fn shard_progress(
1676                &self,
1677                run_id: &str,
1678            ) -> Result<$crate::serve::history::ShardProgress, $crate::serve::history::HistoryError>
1679            {
1680                use sqlx::Row as _;
1681                use $crate::serve::history::HistoryError;
1682                use $crate::serve::history::ShardProgress;
1683                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1684                let rows = sqlx::query(&self.stmts.shard_progress)
1685                    .bind(run_id)
1686                    .fetch_all(&self.pool)
1687                    .await
1688                    .map_err(backend)?;
1689                let mut p = ShardProgress::default();
1690                for row in &rows {
1691                    let status: String = row.try_get("status").map_err(backend)?;
1692                    let n: i64 = row.try_get("n").map_err(backend)?;
1693                    let n = n.max(0) as usize;
1694                    p.total += n;
1695                    match status.as_str() {
1696                        "completed" => p.completed += n,
1697                        "failed" => p.failed += n,
1698                        "running" => p.running += n,
1699                        _ => p.pending += n,
1700                    }
1701                }
1702                Ok(p)
1703            }
1704
1705            async fn pending_shard_cancellations(
1706                &self,
1707            ) -> Result<Vec<String>, $crate::serve::history::HistoryError> {
1708                use sqlx::Row as _;
1709                use $crate::serve::history::HistoryError;
1710                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1711                let rows = sqlx::query(&self.stmts.pending_shard_cancellations)
1712                    .bind(&self.instance_id)
1713                    .fetch_all(&self.pool)
1714                    .await
1715                    .map_err(backend)?;
1716                let mut ids = Vec::with_capacity(rows.len());
1717                for r in &rows {
1718                    ids.push(r.try_get::<String, _>("run_id").map_err(backend)?);
1719                }
1720                Ok(ids)
1721            }
1722
1723            async fn finalize_completed_sharded_parents(
1724                &self,
1725            ) -> Result<usize, $crate::serve::history::HistoryError> {
1726                use sqlx::Row as _;
1727                use $crate::serve::history::HistoryError;
1728                use $crate::serve::history::RunStatus;
1729                use $crate::serve::history::sql;
1730                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1731
1732                // Candidate `sharded` parents — finalize each whose shards are all
1733                // terminal. The status-fenced UPDATE makes a concurrent finalize
1734                // (here or in `maybe_finalize_parent`) a benign no-op.
1735                let rows = sqlx::query(&self.stmts.select_sharded_parents)
1736                    .fetch_all(&self.pool)
1737                    .await
1738                    .map_err(backend)?;
1739
1740                let mut finalized = 0usize;
1741                for row in &rows {
1742                    let run_id: String = row.try_get("run_id").map_err(backend)?;
1743                    let progress = self.shard_progress(&run_id).await?;
1744                    if !progress.all_terminal() {
1745                        continue;
1746                    }
1747                    let success = progress.failed == 0;
1748                    // Read-modify-write the body so the surfaced record stays
1749                    // consistent (status, finished_at, error) with the column.
1750                    let Some(body_row) = sqlx::query(&self.stmts.select_body)
1751                        .bind(&run_id)
1752                        .fetch_optional(&self.pool)
1753                        .await
1754                        .map_err(backend)?
1755                    else {
1756                        continue;
1757                    };
1758                    let body: String = body_row.try_get("body").map_err(backend)?;
1759                    let mut rec = sql::decode_body(&body)?;
1760                    // Skip if it raced to terminal already (column says sharded but
1761                    // the body was just updated). The fenced UPDATE is the real guard.
1762                    if rec.status != RunStatus::Sharded {
1763                        continue;
1764                    }
1765                    let now = chrono::Utc::now();
1766                    rec.status = if success {
1767                        RunStatus::Completed
1768                    } else {
1769                        RunStatus::Failed
1770                    };
1771                    rec.finished_at = Some(now);
1772                    if !success {
1773                        rec.error = Some(format!(
1774                            "{}/{} shard(s) failed",
1775                            progress.failed, progress.total
1776                        ));
1777                    }
1778                    let new_body = sql::encode_body(&rec)?;
1779                    let n = sqlx::query(&self.stmts.finalize_sharded_parent)
1780                        .bind(rec.status.as_str())
1781                        .bind(sql::fmt_ts(now))
1782                        .bind(&new_body)
1783                        .bind(&run_id)
1784                        .execute(&self.pool)
1785                        .await
1786                        .map_err(backend)?
1787                        .rows_affected();
1788                    if n == 1 {
1789                        finalized += 1;
1790                        $crate::serve::metrics::record_run_finished(
1791                            rec.status,
1792                            if success { "ok" } else { "error" },
1793                        );
1794                        tracing::info!(
1795                            run_id,
1796                            shards = progress.total,
1797                            failed = progress.failed,
1798                            "sharded run finalized by sweep (F11)"
1799                        );
1800                    }
1801                }
1802                Ok(finalized)
1803            }
1804
1805            // ── Audit log (RBAC, #205) ───────────────────────────────────────
1806
1807            async fn record_audit(
1808                &self,
1809                entry: &$crate::serve::history::AuditEntry,
1810            ) -> Result<(), $crate::serve::history::HistoryError> {
1811                use $crate::serve::history::HistoryError;
1812                use $crate::serve::history::sql;
1813                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1814                sqlx::query(&self.stmts.insert_audit)
1815                    .bind(&entry.id)
1816                    .bind(sql::fmt_ts(entry.timestamp))
1817                    .bind(&entry.principal)
1818                    .bind(&entry.role)
1819                    .bind(&entry.action)
1820                    .bind(entry.run_id.as_deref())
1821                    .bind(entry.config_fingerprint.as_deref())
1822                    .bind(entry.source_ip.as_deref())
1823                    .bind(&entry.result)
1824                    .execute(&self.pool)
1825                    .await
1826                    .map_err(backend)?;
1827                Ok(())
1828            }
1829
1830            async fn list_audit(
1831                &self,
1832                filter: &$crate::serve::history::AuditFilter,
1833            ) -> Result<
1834                Vec<$crate::serve::history::AuditEntry>,
1835                $crate::serve::history::HistoryError,
1836            > {
1837                use sqlx::Row as _;
1838                use $crate::serve::history::AuditEntry;
1839                use $crate::serve::history::HistoryError;
1840                use $crate::serve::history::sql;
1841                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1842                let principal = filter.principal.as_deref();
1843                let action = filter.action.as_deref();
1844                let since = filter.since.map(sql::fmt_ts);
1845                let until = filter.until.map(sql::fmt_ts);
1846                let limit = filter.limit.max(1) as i64;
1847                let rows = sqlx::query(&self.stmts.list_audit)
1848                    .bind(principal)
1849                    .bind(principal)
1850                    .bind(action)
1851                    .bind(action)
1852                    .bind(since.as_deref())
1853                    .bind(since.as_deref())
1854                    .bind(until.as_deref())
1855                    .bind(until.as_deref())
1856                    .bind(limit)
1857                    .fetch_all(&self.pool)
1858                    .await
1859                    .map_err(backend)?;
1860                let mut out = Vec::with_capacity(rows.len());
1861                for r in &rows {
1862                    let ts: String = r.try_get("ts").map_err(backend)?;
1863                    let timestamp = chrono::DateTime::parse_from_rfc3339(&ts)
1864                        .map(|d| d.to_utc())
1865                        .unwrap_or_else(|_| chrono::Utc::now());
1866                    out.push(AuditEntry {
1867                        id: r.try_get("id").map_err(backend)?,
1868                        timestamp,
1869                        principal: r.try_get("principal").map_err(backend)?,
1870                        role: r.try_get("role").map_err(backend)?,
1871                        action: r.try_get("action").map_err(backend)?,
1872                        run_id: r.try_get("run_id").map_err(backend)?,
1873                        config_fingerprint: r.try_get("config_fingerprint").map_err(backend)?,
1874                        source_ip: r.try_get("source_ip").map_err(backend)?,
1875                        result: r.try_get("result").map_err(backend)?,
1876                    });
1877                }
1878                Ok(out)
1879            }
1880
1881            // ── Data Movement Catalog (#279) ─────────────────────────────────
1882
1883            async fn catalog_record(
1884                &self,
1885                update: &$crate::serve::history::catalog::CatalogUpdate,
1886            ) -> Result<(), $crate::serve::history::HistoryError> {
1887                use sqlx::Row as _;
1888                use $crate::serve::history::HistoryError;
1889                use $crate::serve::history::catalog;
1890                use $crate::serve::history::sql;
1891                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1892                let now_s = sql::fmt_ts(update.recorded_at);
1893
1894                for obs in [&update.source, &update.sink] {
1895                    let id = catalog::dataset_id(&obs.uri);
1896                    // Read-merge-write; last-write-wins under cluster concurrency
1897                    // (counters may undercount on a race — acceptable for
1898                    // operational stats, never for correctness).
1899                    let existing = sqlx::query(&self.stmts.catalog_select_dataset)
1900                        .bind(&id)
1901                        .fetch_optional(&self.pool)
1902                        .await
1903                        .map_err(backend)?
1904                        .map(|r| r.try_get::<String, _>("body"))
1905                        .transpose()
1906                        .map_err(backend)?
1907                        .map(|b| {
1908                            sql::decode_json::<catalog::CatalogDataset>(&b, "catalog dataset")
1909                        })
1910                        .transpose()?;
1911                    let (ds, new_version) = catalog::apply_observation(
1912                        existing.as_ref(),
1913                        obs,
1914                        &update.run_id,
1915                        &update.pipeline,
1916                        &update.row,
1917                        update.recorded_at,
1918                    );
1919                    sqlx::query(&self.stmts.catalog_upsert_dataset)
1920                        .bind(&ds.id)
1921                        .bind(&ds.uri)
1922                        .bind(&ds.kind)
1923                        .bind(&now_s)
1924                        .bind(sql::encode_json(&ds, "catalog dataset")?)
1925                        .execute(&self.pool)
1926                        .await
1927                        .map_err(backend)?;
1928                    if let Some(v) = new_version {
1929                        sqlx::query(&self.stmts.catalog_insert_schema_version)
1930                            .bind(&v.dataset_id)
1931                            .bind(v.version.to_string())
1932                            .bind(sql::fmt_ts(v.recorded_at))
1933                            .bind(sql::encode_json(&v, "catalog schema version")?)
1934                            .execute(&self.pool)
1935                            .await
1936                            .map_err(backend)?;
1937                    }
1938                    sqlx::query(&self.stmts.catalog_insert_stat)
1939                        .bind(&id)
1940                        .bind(&now_s)
1941                        .bind(&update.run_id)
1942                        .bind(obs.records.to_string())
1943                        .execute(&self.pool)
1944                        .await
1945                        .map_err(backend)?;
1946                    sqlx::query(&self.stmts.catalog_prune_stats)
1947                        .bind(&id)
1948                        .bind(&id)
1949                        .bind(catalog::STATS_RETAIN as i64)
1950                        .execute(&self.pool)
1951                        .await
1952                        .map_err(backend)?;
1953                }
1954
1955                let src_id = catalog::dataset_id(&update.source.uri);
1956                let dst_id = catalog::dataset_id(&update.sink.uri);
1957                let existing_edges = self.catalog_all_edges().await?;
1958                let existing = existing_edges
1959                    .iter()
1960                    .find(|e| e.src_id == src_id && e.dst_id == dst_id);
1961                let edge = catalog::apply_edge(existing, update);
1962                sqlx::query(&self.stmts.catalog_upsert_edge)
1963                    .bind(&edge.src_id)
1964                    .bind(&edge.dst_id)
1965                    .bind(&now_s)
1966                    .bind(sql::encode_json(&edge, "catalog edge")?)
1967                    .execute(&self.pool)
1968                    .await
1969                    .map_err(backend)?;
1970                Ok(())
1971            }
1972
1973            async fn catalog_list_datasets(
1974                &self,
1975                filter: &$crate::serve::history::catalog::CatalogListFilter,
1976            ) -> Result<
1977                $crate::serve::history::catalog::CatalogDatasetPage,
1978                $crate::serve::history::HistoryError,
1979            > {
1980                use sqlx::Row as _;
1981                use $crate::serve::history::HistoryError;
1982                use $crate::serve::history::catalog;
1983                use $crate::serve::history::sql;
1984                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
1985                let rows = sqlx::query(&self.stmts.catalog_select_datasets)
1986                    .fetch_all(&self.pool)
1987                    .await
1988                    .map_err(backend)?;
1989                let mut all = Vec::with_capacity(rows.len());
1990                for r in &rows {
1991                    let body: String = r.try_get("body").map_err(backend)?;
1992                    all.push(sql::decode_json(&body, "catalog dataset")?);
1993                }
1994                Ok(catalog::filter_datasets(all, filter))
1995            }
1996
1997            async fn catalog_get_dataset(
1998                &self,
1999                id: &str,
2000            ) -> Result<
2001                Option<$crate::serve::history::catalog::CatalogDatasetDetail>,
2002                $crate::serve::history::HistoryError,
2003            > {
2004                use sqlx::Row as _;
2005                use $crate::serve::history::HistoryError;
2006                use $crate::serve::history::catalog;
2007                use $crate::serve::history::sql;
2008                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
2009                let Some(row) = sqlx::query(&self.stmts.catalog_select_dataset)
2010                    .bind(id)
2011                    .fetch_optional(&self.pool)
2012                    .await
2013                    .map_err(backend)?
2014                else {
2015                    return Ok(None);
2016                };
2017                let body: String = row.try_get("body").map_err(backend)?;
2018                let dataset: catalog::CatalogDataset =
2019                    sql::decode_json(&body, "catalog dataset")?;
2020
2021                let rows = sqlx::query(&self.stmts.catalog_select_schema_versions)
2022                    .bind(id)
2023                    .fetch_all(&self.pool)
2024                    .await
2025                    .map_err(backend)?;
2026                let mut schema_timeline = Vec::with_capacity(rows.len());
2027                for r in &rows {
2028                    let body: String = r.try_get("body").map_err(backend)?;
2029                    schema_timeline.push(sql::decode_json(&body, "catalog schema version")?);
2030                }
2031
2032                let rows = sqlx::query(&self.stmts.catalog_select_stats)
2033                    .bind(id)
2034                    .bind(catalog::STATS_DETAIL_LIMIT as i64)
2035                    .fetch_all(&self.pool)
2036                    .await
2037                    .map_err(backend)?;
2038                let mut stats = Vec::with_capacity(rows.len());
2039                for r in &rows {
2040                    let recorded: String = r.try_get("recorded_at").map_err(backend)?;
2041                    let run_id: String = r.try_get("run_id").map_err(backend)?;
2042                    let records: String = r.try_get("records").map_err(backend)?;
2043                    stats.push(catalog::CatalogStatsPoint {
2044                        recorded_at: chrono::DateTime::parse_from_rfc3339(&recorded)
2045                            .map(|d| d.to_utc())
2046                            .unwrap_or_else(|_| chrono::Utc::now()),
2047                        run_id,
2048                        records: records.parse().unwrap_or(0),
2049                    });
2050                }
2051
2052                let edges = self.catalog_all_edges().await?;
2053                let (downstream, rest): (Vec<_>, Vec<_>) =
2054                    edges.into_iter().partition(|e| e.src_id == id);
2055                let upstream = rest.into_iter().filter(|e| e.dst_id == id).collect();
2056                Ok(Some(catalog::CatalogDatasetDetail {
2057                    dataset,
2058                    schema_timeline,
2059                    stats,
2060                    upstream,
2061                    downstream,
2062                }))
2063            }
2064
2065            async fn catalog_lineage(
2066                &self,
2067                root: Option<&str>,
2068                depth: u32,
2069            ) -> Result<
2070                Vec<$crate::serve::history::catalog::CatalogLineageEdge>,
2071                $crate::serve::history::HistoryError,
2072            > {
2073                use $crate::serve::history::catalog;
2074                let edges = self.catalog_all_edges().await?;
2075                Ok(catalog::lineage_slice(edges, root, depth))
2076            }
2077
2078            fn degraded(&self) -> bool {
2079                // A live SQL backend is never self-degraded; the FallbackHistory
2080                // wrapper owns degradation when the backend becomes unreachable.
2081                false
2082            }
2083        }
2084
2085        impl $name {
2086            /// Every catalog lineage edge, newest activity first (#279).
2087            async fn catalog_all_edges(
2088                &self,
2089            ) -> Result<
2090                Vec<$crate::serve::history::catalog::CatalogLineageEdge>,
2091                $crate::serve::history::HistoryError,
2092            > {
2093                use sqlx::Row as _;
2094                use $crate::serve::history::HistoryError;
2095                use $crate::serve::history::sql;
2096                let backend = |e: sqlx::Error| HistoryError::Backend(e.to_string());
2097                let rows = sqlx::query(&self.stmts.catalog_select_edges)
2098                    .fetch_all(&self.pool)
2099                    .await
2100                    .map_err(backend)?;
2101                let mut edges = Vec::with_capacity(rows.len());
2102                for r in &rows {
2103                    let body: String = r.try_get("body").map_err(backend)?;
2104                    edges.push(sql::decode_json(&body, "catalog edge")?);
2105                }
2106                Ok(edges)
2107            }
2108        }
2109    };
2110}
2111
2112pub(crate) use impl_sql_history;
2113
2114#[cfg(test)]
2115mod tests {
2116    use super::*;
2117
2118    #[test]
2119    fn postgres_shard_statements_are_built() {
2120        // SQLite tests only build the Sqlite statement set; exercise the
2121        // Postgres shard-statement construction too (Mode B, #230).
2122        let s = Stmts::new(Dialect::Postgres);
2123        assert!(s.insert_shard.contains("faucet_serve_shards"));
2124        assert!(s.insert_shard.contains("ON CONFLICT"));
2125        assert!(s.claim_shards_select.contains("JOIN faucet_serve_runs"));
2126        assert!(s.claim_shard_one.contains("'running'"));
2127        assert!(s.renew_shard_leases.contains("lease_expires_at"));
2128        assert!(s.reclaim_shards_select.contains("'running'"));
2129        assert!(s.reclaim_shard_requeue.contains("'pending'"));
2130        assert!(s.reclaim_shard_fail.contains("'failed'"));
2131        assert!(s.finalize_shard.contains("owner"));
2132        assert!(s.shard_progress.contains("GROUP BY"));
2133    }
2134
2135    #[test]
2136    fn fmt_ts_is_fixed_width_and_sortable() {
2137        let a = fmt_ts(
2138            DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
2139                .unwrap()
2140                .to_utc(),
2141        );
2142        let b = fmt_ts(
2143            DateTime::parse_from_rfc3339("2026-01-01T00:00:01Z")
2144                .unwrap()
2145                .to_utc(),
2146        );
2147        assert!(a.ends_with('Z'));
2148        assert_eq!(a.len(), b.len(), "fixed width");
2149        assert!(a < b, "lexicographic order matches chronological order");
2150    }
2151
2152    #[test]
2153    fn is_expired_respects_window() {
2154        let now = Utc::now();
2155        let old = fmt_ts(now - chrono::Duration::seconds(120));
2156        assert!(is_expired(&old, now, Duration::from_secs(60)));
2157        assert!(!is_expired(&old, now, Duration::from_secs(600)));
2158        // Unparseable → not expired (conservative).
2159        assert!(!is_expired("not-a-timestamp", now, Duration::ZERO));
2160    }
2161
2162    #[test]
2163    fn parse_status_round_trips_known_and_defaults_failed() {
2164        for s in [
2165            RunStatus::Queued,
2166            RunStatus::Pending,
2167            RunStatus::Running,
2168            RunStatus::Completed,
2169            RunStatus::Failed,
2170            RunStatus::Cancelled,
2171        ] {
2172            assert_eq!(parse_status(s.as_str()), s);
2173        }
2174        assert_eq!(parse_status("garbage"), RunStatus::Failed);
2175    }
2176
2177    #[test]
2178    fn body_round_trips() {
2179        let rec = RunRecord::queued(
2180            "r1".into(),
2181            Some("n".into()),
2182            Default::default(),
2183            Some("idem".into()),
2184            Utc::now(),
2185        );
2186        let encoded = encode_body(&rec).unwrap();
2187        let decoded = decode_body(&encoded).unwrap();
2188        assert_eq!(decoded.run_id, "r1");
2189        assert_eq!(decoded.idempotency_key.as_deref(), Some("idem"));
2190    }
2191
2192    #[test]
2193    fn postgres_and_sqlite_statements_differ_only_in_placeholders() {
2194        let pg = Stmts::new(Dialect::Postgres);
2195        let lite = Stmts::new(Dialect::Sqlite);
2196        assert!(pg.upsert.contains("$1") && lite.upsert.contains('?'));
2197        assert!(pg.list.contains("$13") && lite.list.contains('?'));
2198        // Both target the same tables / conflict targets.
2199        assert!(pg.insert_idem.contains("ON CONFLICT (key) DO NOTHING"));
2200        assert!(lite.insert_idem.contains("ON CONFLICT (key) DO NOTHING"));
2201        assert!(pg.claim_one.contains("$3") && lite.claim_one.contains('?'));
2202        assert!(pg.heartbeat_instance.contains("faucet_serve_instances"));
2203        assert!(lite.heartbeat_instance.contains("faucet_serve_instances"));
2204    }
2205}