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