Skip to main content

assay_workflow/store/
postgres.rs

1use anyhow::Result;
2use sqlx::PgPool;
3
4use crate::store::WorkflowStore;
5use crate::types::*;
6
7/// v0.1.2 schema layout: workflow tables live in the `workflow` schema;
8/// the engine-events outbox lives in the `engine` schema (created
9/// alongside the engine-core tables by `assay_domain::engine`). The
10/// store creates the workflow schema first, then runs DDL against it
11/// schema-qualified.
12const SCHEMA: &str = r#"
13CREATE SCHEMA IF NOT EXISTS workflow;
14CREATE SCHEMA IF NOT EXISTS engine;
15
16CREATE TABLE IF NOT EXISTS workflow.namespaces (
17    name            TEXT PRIMARY KEY,
18    created_at      DOUBLE PRECISION NOT NULL
19);
20INSERT INTO workflow.namespaces (name, created_at)
21    VALUES ('main', EXTRACT(EPOCH FROM NOW()))
22    ON CONFLICT DO NOTHING;
23
24CREATE TABLE IF NOT EXISTS workflow.workflows (
25    id              TEXT PRIMARY KEY,
26    namespace       TEXT NOT NULL DEFAULT 'main',
27    run_id          TEXT NOT NULL,
28    workflow_type   TEXT NOT NULL,
29    task_queue      TEXT NOT NULL DEFAULT 'main',
30    status          TEXT NOT NULL DEFAULT 'PENDING',
31    input           TEXT,
32    result          TEXT,
33    error           TEXT,
34    parent_id       TEXT,
35    claimed_by      TEXT,
36    search_attributes TEXT,
37    archived_at     DOUBLE PRECISION,
38    archive_uri     TEXT,
39    -- Workflow-task dispatch (Phase 9): see sqlite.rs for the full comment.
40    needs_dispatch  BOOLEAN NOT NULL DEFAULT FALSE,
41    dispatch_claimed_by    TEXT,
42    dispatch_last_heartbeat DOUBLE PRECISION,
43    created_at      DOUBLE PRECISION NOT NULL,
44    updated_at      DOUBLE PRECISION NOT NULL,
45    completed_at    DOUBLE PRECISION
46);
47CREATE INDEX IF NOT EXISTS idx_wf_status_queue ON workflow.workflows(status, task_queue);
48CREATE INDEX IF NOT EXISTS idx_wf_namespace ON workflow.workflows(namespace);
49CREATE INDEX IF NOT EXISTS idx_wf_dispatch ON workflow.workflows(task_queue, needs_dispatch, dispatch_claimed_by);
50
51CREATE TABLE IF NOT EXISTS workflow.events (
52    id              BIGSERIAL PRIMARY KEY,
53    workflow_id     TEXT NOT NULL REFERENCES workflow.workflows(id),
54    seq             INTEGER NOT NULL,
55    event_type      TEXT NOT NULL,
56    payload         TEXT,
57    timestamp       DOUBLE PRECISION NOT NULL
58);
59CREATE INDEX IF NOT EXISTS idx_wf_events_lookup ON workflow.events(workflow_id, seq);
60
61CREATE TABLE IF NOT EXISTS workflow.activities (
62    id              BIGSERIAL PRIMARY KEY,
63    workflow_id     TEXT NOT NULL REFERENCES workflow.workflows(id),
64    seq             INTEGER NOT NULL,
65    name            TEXT NOT NULL,
66    task_queue      TEXT NOT NULL DEFAULT 'main',
67    input           TEXT,
68    status          TEXT NOT NULL DEFAULT 'PENDING',
69    result          TEXT,
70    error           TEXT,
71    attempt         INTEGER NOT NULL DEFAULT 1,
72    max_attempts    INTEGER NOT NULL DEFAULT 3,
73    initial_interval_secs   DOUBLE PRECISION NOT NULL DEFAULT 1,
74    backoff_coefficient     DOUBLE PRECISION NOT NULL DEFAULT 2,
75    start_to_close_secs     DOUBLE PRECISION NOT NULL DEFAULT 300,
76    heartbeat_timeout_secs  DOUBLE PRECISION,
77    claimed_by      TEXT,
78    scheduled_at    DOUBLE PRECISION NOT NULL,
79    started_at      DOUBLE PRECISION,
80    completed_at    DOUBLE PRECISION,
81    last_heartbeat  DOUBLE PRECISION,
82    UNIQUE (workflow_id, seq)
83);
84CREATE INDEX IF NOT EXISTS idx_wf_act_pending ON workflow.activities(task_queue, status, scheduled_at);
85
86CREATE TABLE IF NOT EXISTS workflow.timers (
87    id              BIGSERIAL PRIMARY KEY,
88    workflow_id     TEXT NOT NULL REFERENCES workflow.workflows(id),
89    seq             INTEGER NOT NULL,
90    fire_at         DOUBLE PRECISION NOT NULL,
91    fired           BOOLEAN NOT NULL DEFAULT FALSE,
92    UNIQUE (workflow_id, seq)
93);
94CREATE INDEX IF NOT EXISTS idx_wf_timers_due ON workflow.timers(fire_at) WHERE fired = FALSE;
95
96CREATE TABLE IF NOT EXISTS workflow.signals (
97    id              BIGSERIAL PRIMARY KEY,
98    workflow_id     TEXT NOT NULL REFERENCES workflow.workflows(id),
99    name            TEXT NOT NULL,
100    payload         TEXT,
101    consumed        BOOLEAN NOT NULL DEFAULT FALSE,
102    received_at     DOUBLE PRECISION NOT NULL
103);
104CREATE INDEX IF NOT EXISTS idx_wf_signals_lookup ON workflow.signals(workflow_id, name, consumed);
105
106CREATE TABLE IF NOT EXISTS workflow.schedules (
107    namespace       TEXT NOT NULL DEFAULT 'main',
108    name            TEXT NOT NULL,
109    workflow_type   TEXT NOT NULL,
110    cron_expr       TEXT NOT NULL,
111    timezone        TEXT NOT NULL DEFAULT 'UTC',
112    input           TEXT,
113    task_queue      TEXT NOT NULL DEFAULT 'main',
114    overlap_policy  TEXT NOT NULL DEFAULT 'skip',
115    paused          BOOLEAN NOT NULL DEFAULT FALSE,
116    last_run_at     DOUBLE PRECISION,
117    next_run_at     DOUBLE PRECISION,
118    last_workflow_id TEXT,
119    created_at      DOUBLE PRECISION NOT NULL,
120    PRIMARY KEY (namespace, name)
121);
122
123CREATE TABLE IF NOT EXISTS workflow.workers (
124    id              TEXT PRIMARY KEY,
125    namespace       TEXT NOT NULL DEFAULT 'main',
126    identity        TEXT NOT NULL,
127    task_queue      TEXT NOT NULL,
128    workflows       TEXT,
129    activities      TEXT,
130    max_concurrent_workflows  INTEGER NOT NULL DEFAULT 10,
131    max_concurrent_activities INTEGER NOT NULL DEFAULT 10,
132    active_tasks    INTEGER NOT NULL DEFAULT 0,
133    last_heartbeat  DOUBLE PRECISION NOT NULL,
134    registered_at   DOUBLE PRECISION NOT NULL
135);
136
137CREATE TABLE IF NOT EXISTS workflow.snapshots (
138    workflow_id     TEXT NOT NULL REFERENCES workflow.workflows(id),
139    event_seq       INTEGER NOT NULL,
140    state_json      TEXT NOT NULL,
141    created_at      DOUBLE PRECISION NOT NULL,
142    PRIMARY KEY (workflow_id, event_seq)
143);
144
145-- Plan-15 slice 3: workflow.api_keys retired in favour of the auth
146-- module (sessions / JWT / Zanzibar tuples). Table is dropped on
147-- migration; nothing here re-creates it.
148DROP TABLE IF EXISTS workflow.api_keys CASCADE;
149
150CREATE TABLE IF NOT EXISTS engine.events (
151    id              BIGSERIAL PRIMARY KEY,
152    ts              DOUBLE PRECISION NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()),
153    namespace       TEXT NOT NULL,
154    subsystem       TEXT NOT NULL,
155    kind            TEXT NOT NULL,
156    payload         JSONB NOT NULL DEFAULT '{}'::jsonb
157);
158CREATE INDEX IF NOT EXISTS idx_engine_events_ns_id ON engine.events(namespace, id);
159CREATE INDEX IF NOT EXISTS idx_engine_events_ts_prune ON engine.events(ts);
160
161"#;
162
163/// One-shot relocation: moves v0.13.1's prefixed `public.workflow_*`
164/// tables into the `workflow` schema. Idempotent — each step gates on
165/// `to_regclass(public.<old>) IS NOT NULL` so fresh installs and
166/// already-migrated DBs are no-ops.
167///
168/// SCHEMA above already created empty `workflow.*` tables; we DROP
169/// them with CASCADE here before ALTER TABLE … SET SCHEMA so the move
170/// has somewhere to land. RESTRICT would fail on the FKs between
171/// workflow.events / .activities / .timers / .signals / .snapshots
172/// and workflow.workflows.
173const V0_13_2_RELOCATION_SQL: &str = r#"
174DO $$
175DECLARE
176    has_old BOOLEAN;
177BEGIN
178    -- Each table: if the legacy public.<old> exists, drop the empty
179    -- schema-qualified twin (created above by SCHEMA) and move the
180    -- legacy table into its new home.
181
182    -- workflows
183    SELECT to_regclass('public.workflows') IS NOT NULL INTO has_old;
184    IF has_old THEN
185        DROP TABLE IF EXISTS workflow.workflows CASCADE;
186        ALTER TABLE public.workflows SET SCHEMA workflow;
187    END IF;
188
189    -- workflow_events → workflow.events
190    SELECT to_regclass('public.workflow_events') IS NOT NULL INTO has_old;
191    IF has_old THEN
192        DROP TABLE IF EXISTS workflow.events CASCADE;
193        ALTER TABLE public.workflow_events SET SCHEMA workflow;
194        ALTER TABLE workflow.workflow_events RENAME TO events;
195    END IF;
196
197    -- workflow_activities → workflow.activities
198    SELECT to_regclass('public.workflow_activities') IS NOT NULL INTO has_old;
199    IF has_old THEN
200        DROP TABLE IF EXISTS workflow.activities CASCADE;
201        ALTER TABLE public.workflow_activities SET SCHEMA workflow;
202        ALTER TABLE workflow.workflow_activities RENAME TO activities;
203    END IF;
204
205    -- workflow_timers → workflow.timers
206    SELECT to_regclass('public.workflow_timers') IS NOT NULL INTO has_old;
207    IF has_old THEN
208        DROP TABLE IF EXISTS workflow.timers CASCADE;
209        ALTER TABLE public.workflow_timers SET SCHEMA workflow;
210        ALTER TABLE workflow.workflow_timers RENAME TO timers;
211    END IF;
212
213    -- workflow_signals → workflow.signals
214    SELECT to_regclass('public.workflow_signals') IS NOT NULL INTO has_old;
215    IF has_old THEN
216        DROP TABLE IF EXISTS workflow.signals CASCADE;
217        ALTER TABLE public.workflow_signals SET SCHEMA workflow;
218        ALTER TABLE workflow.workflow_signals RENAME TO signals;
219    END IF;
220
221    -- workflow_snapshots → workflow.snapshots
222    SELECT to_regclass('public.workflow_snapshots') IS NOT NULL INTO has_old;
223    IF has_old THEN
224        DROP TABLE IF EXISTS workflow.snapshots CASCADE;
225        ALTER TABLE public.workflow_snapshots SET SCHEMA workflow;
226        ALTER TABLE workflow.workflow_snapshots RENAME TO snapshots;
227    END IF;
228
229    -- workflow_schedules → workflow.schedules
230    SELECT to_regclass('public.workflow_schedules') IS NOT NULL INTO has_old;
231    IF has_old THEN
232        DROP TABLE IF EXISTS workflow.schedules CASCADE;
233        ALTER TABLE public.workflow_schedules SET SCHEMA workflow;
234        ALTER TABLE workflow.workflow_schedules RENAME TO schedules;
235    END IF;
236
237    -- workflow_workers → workflow.workers
238    SELECT to_regclass('public.workflow_workers') IS NOT NULL INTO has_old;
239    IF has_old THEN
240        DROP TABLE IF EXISTS workflow.workers CASCADE;
241        ALTER TABLE public.workflow_workers SET SCHEMA workflow;
242        ALTER TABLE workflow.workflow_workers RENAME TO workers;
243    END IF;
244
245    -- namespaces → workflow.namespaces
246    SELECT to_regclass('public.namespaces') IS NOT NULL INTO has_old;
247    IF has_old THEN
248        DROP TABLE IF EXISTS workflow.namespaces CASCADE;
249        ALTER TABLE public.namespaces SET SCHEMA workflow;
250    END IF;
251
252    -- public.api_keys: retired in plan-15 slice 3 (workflow REST API
253    -- auth moved to the auth module — see CHANGELOG). Drop any
254    -- orphaned legacy table so an upgraded v0.13.1 install doesn't
255    -- carry it forward.
256    SELECT to_regclass('public.api_keys') IS NOT NULL INTO has_old;
257    IF has_old THEN
258        DROP TABLE public.api_keys CASCADE;
259    END IF;
260
261    -- engine_events → engine.events (notification outbox; preserves the
262    -- v0.13.1 publish-on-commit guarantee since the new INSERT into
263    -- engine.events sits in the same transaction as the pg_notify call).
264    SELECT to_regclass('public.engine_events') IS NOT NULL INTO has_old;
265    IF has_old THEN
266        DROP TABLE IF EXISTS engine.events CASCADE;
267        ALTER TABLE public.engine_events SET SCHEMA engine;
268        ALTER TABLE engine.engine_events RENAME TO events;
269    END IF;
270END
271$$;
272"#;
273
274/// Split a Postgres DDL script into individual statements ready for `sqlx::query`.
275///
276/// Drops pure-comment lines (those starting with `--` after optional whitespace)
277/// *before* splitting on `;`. Without this step, a semicolon inside a line comment
278/// (e.g. `-- Idempotent across startups; fresh installs pick the column up`) would
279/// split the surrounding comment into fragments — one of which is naked prose that
280/// Postgres tries to parse as SQL and rejects with `syntax error at or near "<word>"`.
281///
282/// The filter only drops *pure-comment* lines (leading whitespace then `--`), leaving
283/// `--`-after-code untouched. That keeps string literals safe (could legally contain
284/// `--`) and is conservative enough to remain correct if the SCHEMA grows more prose.
285fn sanitise_schema(schema: &str) -> Vec<String> {
286    let without_comments: String = schema
287        .lines()
288        .filter(|line| !line.trim_start().starts_with("--"))
289        .collect::<Vec<_>>()
290        .join("\n");
291
292    without_comments
293        .split(';')
294        .map(|s| s.trim().to_string())
295        .filter(|s| !s.is_empty())
296        .collect()
297}
298
299/// `Clone` is derived because the underlying `PgPool` is itself `Clone`
300/// (it's `Arc<PoolInner>` internally) — cloning the store hands back a
301/// new wrapper around the same connection pool. Required so engine
302/// composition (`EngineState<S>`) can derive `Clone` and pass through
303/// axum `with_state`.
304#[derive(Clone)]
305pub struct PostgresStore {
306    pool: PgPool,
307}
308
309impl PostgresStore {
310    pub async fn new(url: &str) -> Result<Self> {
311        let pool = PgPool::connect(url).await?;
312        Self::from_pool(pool).await
313    }
314
315    /// Build a store from an existing pool. Runs migrations on the target
316    /// database. Useful when the engine owns the pool (shared with other
317    /// modules) and hands a clone to the workflow module, or for tests that
318    /// point many stores at different databases in the same Postgres server.
319    pub async fn from_pool(pool: PgPool) -> Result<Self> {
320        let store = Self { pool };
321        store.migrate().await?;
322        Ok(store)
323    }
324
325    /// Expose the underlying pool (used by the engine to build a
326    /// `PgEngineEventBus` that shares the same connection pool).
327    pub fn pool(&self) -> &PgPool {
328        &self.pool
329    }
330
331    async fn migrate(&self) -> Result<()> {
332        // Apply the base schema (tables + indexes) statement-by-statement.
333        // This creates the workflow + engine schemas and the v0.13.2
334        // schema-qualified tables. On a fresh install this is the only
335        // step that runs; on an upgrade from v0.13.1 the empty new
336        // tables are dropped + replaced by the legacy public.* tables
337        // in the relocation block below.
338        for statement in sanitise_schema(SCHEMA) {
339            sqlx::query(&statement).execute(&self.pool).await?;
340        }
341        // v0.13.1 → v0.13.2 relocation. Idempotent: on fresh installs
342        // the public.* tables don't exist and every branch is a no-op.
343        sqlx::raw_sql(V0_13_2_RELOCATION_SQL)
344            .execute(&self.pool)
345            .await?;
346        // Drop the v0.13.0 LISTEN/NOTIFY triggers if they still exist on
347        // the target database. The Rust-managed CDC outbox in
348        // assay_domain::events is the replacement; leaving stale
349        // triggers in place would double-publish NOTIFYs with channels
350        // no one listens to. Triggers reference the post-relocation
351        // table names (workflow.workflows, workflow.activities) so they
352        // execute regardless of which side of the migration we're on.
353        sqlx::raw_sql(
354            r#"
355            DROP TRIGGER IF EXISTS workflow_runnable_notify ON workflow.workflows;
356            DROP TRIGGER IF EXISTS workflow_task_notify ON workflow.activities;
357            DROP FUNCTION IF EXISTS assay_notify_runnable();
358            DROP FUNCTION IF EXISTS assay_notify_task();
359            "#,
360        )
361        .execute(&self.pool)
362        .await?;
363        Ok(())
364    }
365
366    /// Try to acquire pg_advisory_lock for leader election.
367    /// Returns true if this instance is the leader (scheduler should run).
368    pub async fn try_acquire_leader_lock(&self) -> Result<bool> {
369        let row: (bool,) =
370            sqlx::query_as("SELECT pg_try_advisory_lock(1)")
371                .fetch_one(&self.pool)
372                .await?;
373        Ok(row.0)
374    }
375}
376
377impl WorkflowStore for PostgresStore {
378    // ── Namespaces ─────────────────────────────────────────
379
380    async fn create_namespace(&self, name: &str) -> Result<()> {
381        sqlx::query("INSERT INTO workflow.namespaces (name, created_at) VALUES ($1, EXTRACT(EPOCH FROM NOW()))")
382            .bind(name)
383            .execute(&self.pool)
384            .await?;
385        Ok(())
386    }
387
388    async fn list_namespaces(&self) -> Result<Vec<crate::store::NamespaceRecord>> {
389        let rows = sqlx::query_as::<_, (String, f64)>(
390            "SELECT name, created_at FROM workflow.namespaces ORDER BY name",
391        )
392        .fetch_all(&self.pool)
393        .await?;
394        Ok(rows
395            .into_iter()
396            .map(|(name, created_at)| crate::store::NamespaceRecord { name, created_at })
397            .collect())
398    }
399
400    async fn delete_namespace(&self, name: &str) -> Result<bool> {
401        let res = sqlx::query("DELETE FROM workflow.namespaces WHERE name = $1 AND name != 'main'")
402            .bind(name)
403            .execute(&self.pool)
404            .await?;
405        Ok(res.rows_affected() > 0)
406    }
407
408    async fn get_namespace_stats(&self, namespace: &str) -> Result<crate::store::NamespaceStats> {
409        let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1")
410            .bind(namespace)
411            .fetch_one(&self.pool)
412            .await?;
413        let running: (i64,) = sqlx::query_as(
414            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1 AND status = 'RUNNING'",
415        )
416        .bind(namespace)
417        .fetch_one(&self.pool)
418        .await?;
419        let pending: (i64,) = sqlx::query_as(
420            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1 AND status = 'PENDING'",
421        )
422        .bind(namespace)
423        .fetch_one(&self.pool)
424        .await?;
425        let completed: (i64,) = sqlx::query_as(
426            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1 AND status = 'COMPLETED'",
427        )
428        .bind(namespace)
429        .fetch_one(&self.pool)
430        .await?;
431        let failed: (i64,) = sqlx::query_as(
432            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1 AND status = 'FAILED'",
433        )
434        .bind(namespace)
435        .fetch_one(&self.pool)
436        .await?;
437        let schedules: (i64,) =
438            sqlx::query_as("SELECT COUNT(*) FROM workflow.schedules WHERE namespace = $1")
439                .bind(namespace)
440                .fetch_one(&self.pool)
441                .await?;
442        let workers: (i64,) =
443            sqlx::query_as("SELECT COUNT(*) FROM workflow.workers WHERE namespace = $1")
444                .bind(namespace)
445                .fetch_one(&self.pool)
446                .await?;
447
448        Ok(crate::store::NamespaceStats {
449            namespace: namespace.to_string(),
450            total_workflows: total.0,
451            running: running.0,
452            pending: pending.0,
453            completed: completed.0,
454            failed: failed.0,
455            schedules: schedules.0,
456            workers: workers.0,
457        })
458    }
459
460    // ── Workflows ──────────────────────────────────────────
461
462    async fn create_workflow(&self, wf: &WorkflowRecord) -> Result<()> {
463        sqlx::query(
464            "INSERT INTO workflow.workflows (id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at)
465             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
466        )
467        .bind(&wf.id)
468        .bind(&wf.namespace)
469        .bind(&wf.run_id)
470        .bind(&wf.workflow_type)
471        .bind(&wf.task_queue)
472        .bind(&wf.status)
473        .bind(&wf.input)
474        .bind(&wf.result)
475        .bind(&wf.error)
476        .bind(&wf.parent_id)
477        .bind(&wf.claimed_by)
478        .bind(&wf.search_attributes)
479        .bind(wf.archived_at)
480        .bind(&wf.archive_uri)
481        .bind(wf.created_at)
482        .bind(wf.updated_at)
483        .bind(wf.completed_at)
484        .execute(&self.pool)
485        .await?;
486        Ok(())
487    }
488
489    async fn get_workflow(&self, id: &str) -> Result<Option<WorkflowRecord>> {
490        let row = sqlx::query_as::<_, PgWorkflowRow>(
491            "SELECT id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at FROM workflow.workflows WHERE id = $1",
492        )
493        .bind(id)
494        .fetch_optional(&self.pool)
495        .await?;
496        Ok(row.map(Into::into))
497    }
498
499    async fn list_workflows(
500        &self,
501        namespace: &str,
502        status: Option<WorkflowStatus>,
503        workflow_type: Option<&str>,
504        search_attrs_filter: Option<&str>,
505        limit: i64,
506        offset: i64,
507    ) -> Result<Vec<WorkflowRecord>> {
508        let status_str = status.map(|s| s.to_string());
509
510        let filter_pairs: Vec<(String, serde_json::Value)> = search_attrs_filter
511            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
512            .and_then(|v| v.as_object().cloned())
513            .map(|m| m.into_iter().collect())
514            .unwrap_or_default();
515
516        let mut sql = String::from(
517            "SELECT id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at
518             FROM workflow.workflows
519             WHERE namespace = $1
520               AND ($2::TEXT IS NULL OR status = $2)
521               AND ($3::TEXT IS NULL OR workflow_type = $3)",
522        );
523        // Bind placeholders for the filter follow $3; next index is 4.
524        let mut idx = 4usize;
525        for _ in &filter_pairs {
526            sql.push_str(&format!(
527                " AND (search_attributes::jsonb)->>${} = ${}",
528                idx,
529                idx + 1
530            ));
531            idx += 2;
532        }
533        sql.push_str(&format!(" ORDER BY created_at DESC LIMIT ${} OFFSET ${}", idx, idx + 1));
534
535        let mut q = sqlx::query_as::<_, PgWorkflowRow>(&sql)
536            .bind(namespace)
537            .bind(&status_str)
538            .bind(workflow_type);
539        for (key, value) in &filter_pairs {
540            q = q.bind(key.clone());
541            // JSONB ->> always returns TEXT; compare by stringified value.
542            let as_text = match value {
543                serde_json::Value::String(s) => s.clone(),
544                other => other.to_string(),
545            };
546            q = q.bind(as_text);
547        }
548        let rows = q.bind(limit).bind(offset).fetch_all(&self.pool).await?;
549        Ok(rows.into_iter().map(Into::into).collect())
550    }
551
552    async fn update_workflow_status(
553        &self,
554        id: &str,
555        status: WorkflowStatus,
556        result: Option<&str>,
557        error: Option<&str>,
558    ) -> Result<()> {
559        let now = timestamp_now();
560        let completed_at = if status.is_terminal() { Some(now) } else { None };
561        sqlx::query(
562            "UPDATE workflow.workflows SET status = $1, result = COALESCE($2, result), error = COALESCE($3, error), updated_at = $4, completed_at = COALESCE($5, completed_at) WHERE id = $6",
563        )
564        .bind(status.to_string())
565        .bind(result)
566        .bind(error)
567        .bind(now)
568        .bind(completed_at)
569        .bind(id)
570        .execute(&self.pool)
571        .await?;
572        Ok(())
573    }
574
575    async fn claim_workflow(&self, id: &str, worker_id: &str) -> Result<bool> {
576        let res = sqlx::query(
577            "UPDATE workflow.workflows SET claimed_by = $1, status = 'RUNNING', updated_at = $2 WHERE id = $3 AND claimed_by IS NULL",
578        )
579        .bind(worker_id)
580        .bind(timestamp_now())
581        .bind(id)
582        .execute(&self.pool)
583        .await?;
584        Ok(res.rows_affected() > 0)
585    }
586
587    async fn mark_workflow_dispatchable(&self, workflow_id: &str) -> Result<()> {
588        sqlx::query("UPDATE workflow.workflows SET needs_dispatch = TRUE WHERE id = $1")
589            .bind(workflow_id)
590            .execute(&self.pool)
591            .await?;
592        Ok(())
593    }
594
595    async fn claim_workflow_task(
596        &self,
597        task_queue: &str,
598        worker_id: &str,
599    ) -> Result<Option<WorkflowRecord>> {
600        let now = timestamp_now();
601        // Atomic claim with FOR UPDATE SKIP LOCKED so multiple engine
602        // replicas don't fight over the same workflow task.
603        let row = sqlx::query_as::<_, PgWorkflowRow>(
604            "UPDATE workflow.workflows
605             SET dispatch_claimed_by = $1, dispatch_last_heartbeat = $2, needs_dispatch = FALSE
606             WHERE id = (
607                SELECT id FROM workflow.workflows
608                WHERE task_queue = $3
609                  AND needs_dispatch = TRUE
610                  AND dispatch_claimed_by IS NULL
611                  AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
612                ORDER BY updated_at ASC
613                FOR UPDATE SKIP LOCKED
614                LIMIT 1
615             )
616             RETURNING id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at",
617        )
618        .bind(worker_id)
619        .bind(now)
620        .bind(task_queue)
621        .fetch_optional(&self.pool)
622        .await?;
623        Ok(row.map(Into::into))
624    }
625
626    async fn release_workflow_task(&self, workflow_id: &str, worker_id: &str) -> Result<()> {
627        sqlx::query(
628            "UPDATE workflow.workflows
629             SET dispatch_claimed_by = NULL, dispatch_last_heartbeat = NULL
630             WHERE id = $1 AND dispatch_claimed_by = $2",
631        )
632        .bind(workflow_id)
633        .bind(worker_id)
634        .execute(&self.pool)
635        .await?;
636        Ok(())
637    }
638
639    async fn release_stale_dispatch_leases(
640        &self,
641        now: f64,
642        timeout_secs: f64,
643    ) -> Result<u64> {
644        let res = sqlx::query(
645            "UPDATE workflow.workflows
646             SET dispatch_claimed_by = NULL,
647                 dispatch_last_heartbeat = NULL,
648                 needs_dispatch = TRUE
649             WHERE dispatch_claimed_by IS NOT NULL
650               AND ($1 - dispatch_last_heartbeat) > $2
651               AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')",
652        )
653        .bind(now)
654        .bind(timeout_secs)
655        .execute(&self.pool)
656        .await?;
657        Ok(res.rows_affected())
658    }
659
660    // ── Events ─────────────────────────────────────────────
661
662    async fn append_event(&self, ev: &WorkflowEvent) -> Result<i64> {
663        let row: (i64,) = sqlx::query_as(
664            "INSERT INTO workflow.events (workflow_id, seq, event_type, payload, timestamp) VALUES ($1, $2, $3, $4, $5) RETURNING id",
665        )
666        .bind(&ev.workflow_id)
667        .bind(ev.seq)
668        .bind(&ev.event_type)
669        .bind(&ev.payload)
670        .bind(ev.timestamp)
671        .fetch_one(&self.pool)
672        .await?;
673        Ok(row.0)
674    }
675
676    async fn list_events(&self, workflow_id: &str) -> Result<Vec<WorkflowEvent>> {
677        let rows = sqlx::query_as::<_, PgEventRow>(
678            "SELECT id, workflow_id, seq, event_type, payload, timestamp FROM workflow.events WHERE workflow_id = $1 ORDER BY seq ASC",
679        )
680        .bind(workflow_id)
681        .fetch_all(&self.pool)
682        .await?;
683        Ok(rows.into_iter().map(Into::into).collect())
684    }
685
686    async fn get_event_count(&self, workflow_id: &str) -> Result<i64> {
687        let row: (i64,) =
688            sqlx::query_as("SELECT COUNT(*) FROM workflow.events WHERE workflow_id = $1")
689                .bind(workflow_id)
690                .fetch_one(&self.pool)
691                .await?;
692        Ok(row.0)
693    }
694
695    // ── Activities ──────────────────────────────────────────
696
697    async fn create_activity(&self, act: &WorkflowActivity) -> Result<i64> {
698        let row: (i64,) = sqlx::query_as(
699            "INSERT INTO workflow.activities (workflow_id, seq, name, task_queue, input, status, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, scheduled_at)
700             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id",
701        )
702        .bind(&act.workflow_id)
703        .bind(act.seq)
704        .bind(&act.name)
705        .bind(&act.task_queue)
706        .bind(&act.input)
707        .bind(&act.status)
708        .bind(act.attempt)
709        .bind(act.max_attempts)
710        .bind(act.initial_interval_secs)
711        .bind(act.backoff_coefficient)
712        .bind(act.start_to_close_secs)
713        .bind(act.heartbeat_timeout_secs)
714        .bind(act.scheduled_at)
715        .fetch_one(&self.pool)
716        .await?;
717        Ok(row.0)
718    }
719
720    async fn get_activity(&self, id: i64) -> Result<Option<WorkflowActivity>> {
721        let row = sqlx::query_as::<_, PgActivityRow>(
722            "SELECT id, workflow_id, seq, name, task_queue, input, status, result, error, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, claimed_by, scheduled_at, started_at, completed_at, last_heartbeat
723             FROM workflow.activities WHERE id = $1",
724        )
725        .bind(id)
726        .fetch_optional(&self.pool)
727        .await?;
728        Ok(row.map(Into::into))
729    }
730
731    async fn get_activity_by_workflow_seq(
732        &self,
733        workflow_id: &str,
734        seq: i32,
735    ) -> Result<Option<WorkflowActivity>> {
736        let row = sqlx::query_as::<_, PgActivityRow>(
737            "SELECT id, workflow_id, seq, name, task_queue, input, status, result, error, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, claimed_by, scheduled_at, started_at, completed_at, last_heartbeat
738             FROM workflow.activities WHERE workflow_id = $1 AND seq = $2",
739        )
740        .bind(workflow_id)
741        .bind(seq)
742        .fetch_optional(&self.pool)
743        .await?;
744        Ok(row.map(Into::into))
745    }
746
747    async fn claim_activity(
748        &self,
749        task_queue: &str,
750        worker_id: &str,
751    ) -> Result<Option<WorkflowActivity>> {
752        let now = timestamp_now();
753        // Atomic claim using FOR UPDATE SKIP LOCKED — prevents contention
754        // between multiple assay serve instances claiming the same activity
755        let row = sqlx::query_as::<_, PgActivityRow>(
756            "UPDATE workflow.activities SET status = 'RUNNING', claimed_by = $1, started_at = $2
757             WHERE id = (
758                SELECT id FROM workflow.activities
759                WHERE task_queue = $3 AND status = 'PENDING'
760                ORDER BY scheduled_at ASC
761                FOR UPDATE SKIP LOCKED
762                LIMIT 1
763             )
764             RETURNING id, workflow_id, seq, name, task_queue, input, status, result, error, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, claimed_by, scheduled_at, started_at, completed_at, last_heartbeat",
765        )
766        .bind(worker_id)
767        .bind(now)
768        .bind(task_queue)
769        .fetch_optional(&self.pool)
770        .await?;
771        Ok(row.map(Into::into))
772    }
773
774    async fn requeue_activity_for_retry(
775        &self,
776        id: i64,
777        next_attempt: i32,
778        next_scheduled_at: f64,
779    ) -> Result<()> {
780        sqlx::query(
781            "UPDATE workflow.activities
782             SET status = 'PENDING', attempt = $1, scheduled_at = $2,
783                 claimed_by = NULL, started_at = NULL, last_heartbeat = NULL,
784                 error = NULL
785             WHERE id = $3",
786        )
787        .bind(next_attempt)
788        .bind(next_scheduled_at)
789        .bind(id)
790        .execute(&self.pool)
791        .await?;
792        Ok(())
793    }
794
795    async fn complete_activity(
796        &self,
797        id: i64,
798        result: Option<&str>,
799        error: Option<&str>,
800        failed: bool,
801    ) -> Result<()> {
802        let status = if failed { "FAILED" } else { "COMPLETED" };
803        sqlx::query(
804            "UPDATE workflow.activities SET status = $1, result = $2, error = $3, completed_at = $4 WHERE id = $5",
805        )
806        .bind(status)
807        .bind(result)
808        .bind(error)
809        .bind(timestamp_now())
810        .bind(id)
811        .execute(&self.pool)
812        .await?;
813        Ok(())
814    }
815
816    async fn heartbeat_activity(&self, id: i64, _details: Option<&str>) -> Result<()> {
817        sqlx::query("UPDATE workflow.activities SET last_heartbeat = $1 WHERE id = $2")
818            .bind(timestamp_now())
819            .bind(id)
820            .execute(&self.pool)
821            .await?;
822        Ok(())
823    }
824
825    async fn get_timed_out_activities(&self, now: f64) -> Result<Vec<WorkflowActivity>> {
826        let rows = sqlx::query_as::<_, PgActivityRow>(
827            "SELECT id, workflow_id, seq, name, task_queue, input, status, result, error, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, claimed_by, scheduled_at, started_at, completed_at, last_heartbeat
828             FROM workflow.activities
829             WHERE status = 'RUNNING'
830               AND heartbeat_timeout_secs IS NOT NULL
831               AND last_heartbeat IS NOT NULL
832               AND ($1 - last_heartbeat) > heartbeat_timeout_secs",
833        )
834        .bind(now)
835        .fetch_all(&self.pool)
836        .await?;
837        Ok(rows.into_iter().map(Into::into).collect())
838    }
839
840    // ── Timers ──────────────────────────────────────────────
841
842    async fn create_timer(&self, timer: &WorkflowTimer) -> Result<i64> {
843        // Idempotent: ON CONFLICT (workflow_id, seq) DO NOTHING.
844        // If a row already exists, RETURNING produces no rows — fall back to SELECT.
845        let inserted: Option<(i64,)> = sqlx::query_as(
846            "INSERT INTO workflow.timers (workflow_id, seq, fire_at, fired)
847             VALUES ($1, $2, $3, FALSE)
848             ON CONFLICT (workflow_id, seq) DO NOTHING
849             RETURNING id",
850        )
851        .bind(&timer.workflow_id)
852        .bind(timer.seq)
853        .bind(timer.fire_at)
854        .fetch_optional(&self.pool)
855        .await?;
856
857        if let Some((id,)) = inserted {
858            return Ok(id);
859        }
860
861        // Row already existed — return its id.
862        let (id,): (i64,) = sqlx::query_as(
863            "SELECT id FROM workflow.timers WHERE workflow_id = $1 AND seq = $2",
864        )
865        .bind(&timer.workflow_id)
866        .bind(timer.seq)
867        .fetch_one(&self.pool)
868        .await?;
869        Ok(id)
870    }
871
872    async fn cancel_pending_activities(&self, workflow_id: &str) -> Result<u64> {
873        let res = sqlx::query(
874            "UPDATE workflow.activities SET status = 'CANCELLED', completed_at = $1
875             WHERE workflow_id = $2 AND status = 'PENDING'",
876        )
877        .bind(timestamp_now())
878        .bind(workflow_id)
879        .execute(&self.pool)
880        .await?;
881        Ok(res.rows_affected())
882    }
883
884    async fn cancel_pending_timers(&self, workflow_id: &str) -> Result<u64> {
885        let res = sqlx::query(
886            "UPDATE workflow.timers SET fired = TRUE
887             WHERE workflow_id = $1 AND fired = FALSE",
888        )
889        .bind(workflow_id)
890        .execute(&self.pool)
891        .await?;
892        Ok(res.rows_affected())
893    }
894
895    async fn get_timer_by_workflow_seq(
896        &self,
897        workflow_id: &str,
898        seq: i32,
899    ) -> Result<Option<WorkflowTimer>> {
900        let row = sqlx::query_as::<_, PgTimerRow>(
901            "SELECT id, workflow_id, seq, fire_at, fired
902             FROM workflow.timers WHERE workflow_id = $1 AND seq = $2",
903        )
904        .bind(workflow_id)
905        .bind(seq)
906        .fetch_optional(&self.pool)
907        .await?;
908        Ok(row.map(Into::into))
909    }
910
911    async fn fire_due_timers(&self, now: f64) -> Result<Vec<WorkflowTimer>> {
912        let rows = sqlx::query_as::<_, PgTimerRow>(
913            "UPDATE workflow.timers SET fired = TRUE
914             WHERE fired = FALSE AND fire_at <= $1
915             RETURNING id, workflow_id, seq, fire_at, fired",
916        )
917        .bind(now)
918        .fetch_all(&self.pool)
919        .await?;
920        Ok(rows.into_iter().map(Into::into).collect())
921    }
922
923    // ── Signals ─────────────────────────────────────────────
924
925    async fn send_signal(&self, sig: &WorkflowSignal) -> Result<i64> {
926        let row: (i64,) = sqlx::query_as(
927            "INSERT INTO workflow.signals (workflow_id, name, payload, consumed, received_at) VALUES ($1, $2, $3, FALSE, $4) RETURNING id",
928        )
929        .bind(&sig.workflow_id)
930        .bind(&sig.name)
931        .bind(&sig.payload)
932        .bind(sig.received_at)
933        .fetch_one(&self.pool)
934        .await?;
935        Ok(row.0)
936    }
937
938    async fn consume_signals(
939        &self,
940        workflow_id: &str,
941        name: &str,
942    ) -> Result<Vec<WorkflowSignal>> {
943        let rows = sqlx::query_as::<_, PgSignalRow>(
944            "UPDATE workflow.signals SET consumed = TRUE
945             WHERE workflow_id = $1 AND name = $2 AND consumed = FALSE
946             RETURNING id, workflow_id, name, payload, consumed, received_at",
947        )
948        .bind(workflow_id)
949        .bind(name)
950        .fetch_all(&self.pool)
951        .await?;
952        Ok(rows.into_iter().map(Into::into).collect())
953    }
954
955    // ── Schedules ───────────────────────────────────────────
956
957    async fn create_schedule(&self, sched: &WorkflowSchedule) -> Result<()> {
958        sqlx::query(
959            "INSERT INTO workflow.schedules (namespace, name, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at)
960             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
961        )
962        .bind(&sched.namespace)
963        .bind(&sched.name)
964        .bind(&sched.workflow_type)
965        .bind(&sched.cron_expr)
966        .bind(&sched.timezone)
967        .bind(&sched.input)
968        .bind(&sched.task_queue)
969        .bind(&sched.overlap_policy)
970        .bind(sched.paused)
971        .bind(sched.last_run_at)
972        .bind(sched.next_run_at)
973        .bind(&sched.last_workflow_id)
974        .bind(sched.created_at)
975        .execute(&self.pool)
976        .await?;
977        Ok(())
978    }
979
980    async fn get_schedule(&self, namespace: &str, name: &str) -> Result<Option<WorkflowSchedule>> {
981        let row = sqlx::query_as::<_, PgScheduleRow>(
982            "SELECT namespace, name, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at FROM workflow.schedules WHERE namespace = $1 AND name = $2",
983        )
984        .bind(namespace)
985        .bind(name)
986        .fetch_optional(&self.pool)
987        .await?;
988        Ok(row.map(Into::into))
989    }
990
991    async fn list_schedules(&self, namespace: &str) -> Result<Vec<WorkflowSchedule>> {
992        let rows = sqlx::query_as::<_, PgScheduleRow>(
993            "SELECT namespace, name, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at FROM workflow.schedules WHERE namespace = $1 ORDER BY name",
994        )
995        .bind(namespace)
996        .fetch_all(&self.pool)
997        .await?;
998        Ok(rows.into_iter().map(Into::into).collect())
999    }
1000
1001    async fn update_schedule_last_run(
1002        &self,
1003        namespace: &str,
1004        name: &str,
1005        last_run_at: f64,
1006        next_run_at: f64,
1007        workflow_id: &str,
1008    ) -> Result<()> {
1009        sqlx::query(
1010            "UPDATE workflow.schedules SET last_run_at = $1, next_run_at = $2, last_workflow_id = $3 WHERE namespace = $4 AND name = $5",
1011        )
1012        .bind(last_run_at)
1013        .bind(next_run_at)
1014        .bind(workflow_id)
1015        .bind(namespace)
1016        .bind(name)
1017        .execute(&self.pool)
1018        .await?;
1019        Ok(())
1020    }
1021
1022    async fn delete_schedule(&self, namespace: &str, name: &str) -> Result<bool> {
1023        let res = sqlx::query("DELETE FROM workflow.schedules WHERE namespace = $1 AND name = $2")
1024            .bind(namespace)
1025            .bind(name)
1026            .execute(&self.pool)
1027            .await?;
1028        Ok(res.rows_affected() > 0)
1029    }
1030
1031    async fn list_archivable_workflows(
1032        &self,
1033        cutoff: f64,
1034        limit: i64,
1035    ) -> Result<Vec<WorkflowRecord>> {
1036        let rows = sqlx::query_as::<_, PgWorkflowRow>(
1037            "SELECT id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at
1038             FROM workflow.workflows
1039             WHERE status IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
1040               AND completed_at IS NOT NULL
1041               AND completed_at < $1
1042               AND archived_at IS NULL
1043             ORDER BY completed_at ASC
1044             LIMIT $2",
1045        )
1046        .bind(cutoff)
1047        .bind(limit)
1048        .fetch_all(&self.pool)
1049        .await?;
1050        Ok(rows.into_iter().map(Into::into).collect())
1051    }
1052
1053    async fn mark_archived_and_purge(
1054        &self,
1055        workflow_id: &str,
1056        archive_uri: &str,
1057        archived_at: f64,
1058    ) -> Result<()> {
1059        let mut tx = self.pool.begin().await?;
1060        sqlx::query("DELETE FROM workflow.events WHERE workflow_id = $1")
1061            .bind(workflow_id)
1062            .execute(&mut *tx)
1063            .await?;
1064        sqlx::query("DELETE FROM workflow.activities WHERE workflow_id = $1")
1065            .bind(workflow_id)
1066            .execute(&mut *tx)
1067            .await?;
1068        sqlx::query("DELETE FROM workflow.timers WHERE workflow_id = $1")
1069            .bind(workflow_id)
1070            .execute(&mut *tx)
1071            .await?;
1072        sqlx::query("DELETE FROM workflow.signals WHERE workflow_id = $1")
1073            .bind(workflow_id)
1074            .execute(&mut *tx)
1075            .await?;
1076        sqlx::query("DELETE FROM workflow.snapshots WHERE workflow_id = $1")
1077            .bind(workflow_id)
1078            .execute(&mut *tx)
1079            .await?;
1080        sqlx::query(
1081            "UPDATE workflow.workflows SET archived_at = $1, archive_uri = $2 WHERE id = $3",
1082        )
1083        .bind(archived_at)
1084        .bind(archive_uri)
1085        .bind(workflow_id)
1086        .execute(&mut *tx)
1087        .await?;
1088        tx.commit().await?;
1089        Ok(())
1090    }
1091
1092    async fn upsert_search_attributes(
1093        &self,
1094        workflow_id: &str,
1095        patch_json: &str,
1096    ) -> Result<()> {
1097        let current: Option<(Option<String>,)> =
1098            sqlx::query_as("SELECT search_attributes FROM workflow.workflows WHERE id = $1")
1099                .bind(workflow_id)
1100                .fetch_optional(&self.pool)
1101                .await?;
1102        let merged = crate::store::sqlite::merge_search_attrs(
1103            current.and_then(|(s,)| s).as_deref(),
1104            patch_json,
1105        )?;
1106        sqlx::query("UPDATE workflow.workflows SET search_attributes = $1 WHERE id = $2")
1107            .bind(merged)
1108            .bind(workflow_id)
1109            .execute(&self.pool)
1110            .await?;
1111        Ok(())
1112    }
1113
1114    async fn update_schedule(
1115        &self,
1116        namespace: &str,
1117        name: &str,
1118        patch: &SchedulePatch,
1119    ) -> Result<Option<WorkflowSchedule>> {
1120        let mut sets: Vec<String> = Vec::new();
1121        let mut idx = 1usize;
1122        if patch.cron_expr.is_some() {
1123            sets.push(format!("cron_expr = ${idx}"));
1124            idx += 1;
1125        }
1126        if patch.timezone.is_some() {
1127            sets.push(format!("timezone = ${idx}"));
1128            idx += 1;
1129        }
1130        if patch.input.is_some() {
1131            sets.push(format!("input = ${idx}"));
1132            idx += 1;
1133        }
1134        if patch.task_queue.is_some() {
1135            sets.push(format!("task_queue = ${idx}"));
1136            idx += 1;
1137        }
1138        if patch.overlap_policy.is_some() {
1139            sets.push(format!("overlap_policy = ${idx}"));
1140            idx += 1;
1141        }
1142        if sets.is_empty() {
1143            return self.get_schedule(namespace, name).await;
1144        }
1145        let sql = format!(
1146            "UPDATE workflow.schedules SET {} WHERE namespace = ${} AND name = ${}",
1147            sets.join(", "),
1148            idx,
1149            idx + 1
1150        );
1151        let mut q = sqlx::query(&sql);
1152        if let Some(ref v) = patch.cron_expr {
1153            q = q.bind(v);
1154        }
1155        if let Some(ref v) = patch.timezone {
1156            q = q.bind(v);
1157        }
1158        if let Some(ref v) = patch.input {
1159            q = q.bind(v.to_string());
1160        }
1161        if let Some(ref v) = patch.task_queue {
1162            q = q.bind(v);
1163        }
1164        if let Some(ref v) = patch.overlap_policy {
1165            q = q.bind(v);
1166        }
1167        let res = q
1168            .bind(namespace)
1169            .bind(name)
1170            .execute(&self.pool)
1171            .await?;
1172        if res.rows_affected() == 0 {
1173            return Ok(None);
1174        }
1175        self.get_schedule(namespace, name).await
1176    }
1177
1178    async fn set_schedule_paused(
1179        &self,
1180        namespace: &str,
1181        name: &str,
1182        paused: bool,
1183    ) -> Result<Option<WorkflowSchedule>> {
1184        let res = sqlx::query(
1185            "UPDATE workflow.schedules SET paused = $1 WHERE namespace = $2 AND name = $3",
1186        )
1187        .bind(paused)
1188        .bind(namespace)
1189        .bind(name)
1190        .execute(&self.pool)
1191        .await?;
1192        if res.rows_affected() == 0 {
1193            return Ok(None);
1194        }
1195        self.get_schedule(namespace, name).await
1196    }
1197
1198    // ── Workers ─────────────────────────────────────────────
1199
1200    async fn register_worker(&self, w: &WorkflowWorker) -> Result<()> {
1201        sqlx::query(
1202            "INSERT INTO workflow.workers (id, namespace, identity, task_queue, workflows, activities, max_concurrent_workflows, max_concurrent_activities, active_tasks, last_heartbeat, registered_at)
1203             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
1204             ON CONFLICT (id) DO UPDATE SET last_heartbeat = EXCLUDED.last_heartbeat, identity = EXCLUDED.identity",
1205        )
1206        .bind(&w.id)
1207        .bind(&w.namespace)
1208        .bind(&w.identity)
1209        .bind(&w.task_queue)
1210        .bind(&w.workflows)
1211        .bind(&w.activities)
1212        .bind(w.max_concurrent_workflows)
1213        .bind(w.max_concurrent_activities)
1214        .bind(w.active_tasks)
1215        .bind(w.last_heartbeat)
1216        .bind(w.registered_at)
1217        .execute(&self.pool)
1218        .await?;
1219        Ok(())
1220    }
1221
1222    async fn heartbeat_worker(&self, id: &str, now: f64) -> Result<()> {
1223        sqlx::query("UPDATE workflow.workers SET last_heartbeat = $1 WHERE id = $2")
1224            .bind(now)
1225            .bind(id)
1226            .execute(&self.pool)
1227            .await?;
1228        Ok(())
1229    }
1230
1231    async fn list_workers(&self, namespace: &str) -> Result<Vec<WorkflowWorker>> {
1232        let rows = sqlx::query_as::<_, PgWorkerRow>(
1233            "SELECT id, namespace, identity, task_queue, workflows, activities, max_concurrent_workflows, max_concurrent_activities, active_tasks, last_heartbeat, registered_at FROM workflow.workers WHERE namespace = $1 ORDER BY registered_at",
1234        )
1235        .bind(namespace)
1236        .fetch_all(&self.pool)
1237        .await?;
1238        Ok(rows.into_iter().map(Into::into).collect())
1239    }
1240
1241    async fn remove_dead_workers(&self, cutoff: f64) -> Result<Vec<String>> {
1242        let rows: Vec<(String,)> =
1243            sqlx::query_as("SELECT id FROM workflow.workers WHERE last_heartbeat < $1")
1244                .bind(cutoff)
1245                .fetch_all(&self.pool)
1246                .await?;
1247        let ids: Vec<String> = rows.into_iter().map(|r| r.0).collect();
1248        if !ids.is_empty() {
1249            sqlx::query("DELETE FROM workflow.workers WHERE last_heartbeat < $1")
1250                .bind(cutoff)
1251                .execute(&self.pool)
1252                .await?;
1253        }
1254        Ok(ids)
1255    }
1256
1257    // ── Child Workflows ─────────────────────────────────────
1258
1259    async fn list_child_workflows(&self, parent_id: &str) -> Result<Vec<WorkflowRecord>> {
1260        let rows = sqlx::query_as::<_, PgWorkflowRow>(
1261            "SELECT id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at
1262             FROM workflow.workflows WHERE parent_id = $1 ORDER BY created_at ASC",
1263        )
1264        .bind(parent_id)
1265        .fetch_all(&self.pool)
1266        .await?;
1267        Ok(rows.into_iter().map(Into::into).collect())
1268    }
1269
1270    // ── Snapshots ───────────────────────────────────────────
1271
1272    async fn create_snapshot(
1273        &self,
1274        workflow_id: &str,
1275        event_seq: i32,
1276        state_json: &str,
1277    ) -> Result<()> {
1278        sqlx::query(
1279            "INSERT INTO workflow.snapshots (workflow_id, event_seq, state_json, created_at)
1280             VALUES ($1, $2, $3, $4)
1281             ON CONFLICT (workflow_id, event_seq) DO UPDATE SET state_json = EXCLUDED.state_json, created_at = EXCLUDED.created_at",
1282        )
1283        .bind(workflow_id)
1284        .bind(event_seq)
1285        .bind(state_json)
1286        .bind(timestamp_now())
1287        .execute(&self.pool)
1288        .await?;
1289        Ok(())
1290    }
1291
1292    async fn get_latest_snapshot(
1293        &self,
1294        workflow_id: &str,
1295    ) -> Result<Option<WorkflowSnapshot>> {
1296        let row = sqlx::query_as::<_, (String, i32, String, f64)>(
1297            "SELECT workflow_id, event_seq, state_json, created_at
1298             FROM workflow.snapshots WHERE workflow_id = $1
1299             ORDER BY event_seq DESC LIMIT 1",
1300        )
1301        .bind(workflow_id)
1302        .fetch_optional(&self.pool)
1303        .await?;
1304
1305        Ok(row.map(|(workflow_id, event_seq, state_json, created_at)| WorkflowSnapshot {
1306            workflow_id,
1307            event_seq,
1308            state_json,
1309            created_at,
1310        }))
1311    }
1312
1313    // ── Queue Stats ─────────────────────────────────────────
1314
1315    async fn get_queue_stats(&self, namespace: &str) -> Result<Vec<crate::store::QueueStats>> {
1316        let rows = sqlx::query_as::<_, (String, i64, i64, i64)>(
1317            "SELECT
1318                a.task_queue AS queue,
1319                SUM(CASE WHEN a.status = 'PENDING' THEN 1 ELSE 0 END) AS pending,
1320                SUM(CASE WHEN a.status = 'RUNNING' THEN 1 ELSE 0 END) AS running,
1321                (SELECT COUNT(*) FROM workflow.workers w WHERE w.task_queue = a.task_queue AND w.namespace = $1) AS workers
1322             FROM workflow.activities a
1323             JOIN workflow.workflows wf ON a.workflow_id = wf.id AND wf.namespace = $1
1324             GROUP BY a.task_queue",
1325        )
1326        .bind(namespace)
1327        .fetch_all(&self.pool)
1328        .await?;
1329
1330        Ok(rows
1331            .into_iter()
1332            .map(|(queue, pending, running, workers)| crate::store::QueueStats {
1333                queue,
1334                pending_activities: pending,
1335                running_activities: running,
1336                workers,
1337            })
1338            .collect())
1339    }
1340
1341    // ── Leader Election ─────────────────────────────────────
1342
1343    async fn try_acquire_scheduler_lock(&self) -> Result<bool> {
1344        // pg_try_advisory_lock is session-scoped — only one connection
1345        // in the pool will hold the lock. In a multi-replica Kubernetes
1346        // deployment, only one pod's connection wins.
1347        let row: (bool,) =
1348            sqlx::query_as("SELECT pg_try_advisory_lock(42)")
1349                .fetch_one(&self.pool)
1350                .await?;
1351        Ok(row.0)
1352    }
1353
1354}
1355
1356fn timestamp_now() -> f64 {
1357    std::time::SystemTime::now()
1358        .duration_since(std::time::UNIX_EPOCH)
1359        .unwrap()
1360        .as_secs_f64()
1361}
1362
1363// ── Postgres row types (sqlx::FromRow) ──────────────────────
1364
1365#[derive(sqlx::FromRow)]
1366struct PgWorkflowRow {
1367    id: String,
1368    namespace: String,
1369    run_id: String,
1370    workflow_type: String,
1371    task_queue: String,
1372    status: String,
1373    input: Option<String>,
1374    result: Option<String>,
1375    error: Option<String>,
1376    parent_id: Option<String>,
1377    claimed_by: Option<String>,
1378    search_attributes: Option<String>,
1379    archived_at: Option<f64>,
1380    archive_uri: Option<String>,
1381    created_at: f64,
1382    updated_at: f64,
1383    completed_at: Option<f64>,
1384}
1385
1386impl From<PgWorkflowRow> for WorkflowRecord {
1387    fn from(r: PgWorkflowRow) -> Self {
1388        Self {
1389            id: r.id,
1390            namespace: r.namespace,
1391            run_id: r.run_id,
1392            workflow_type: r.workflow_type,
1393            task_queue: r.task_queue,
1394            status: r.status,
1395            input: r.input,
1396            result: r.result,
1397            error: r.error,
1398            parent_id: r.parent_id,
1399            claimed_by: r.claimed_by,
1400            search_attributes: r.search_attributes,
1401            archived_at: r.archived_at,
1402            archive_uri: r.archive_uri,
1403            created_at: r.created_at,
1404            updated_at: r.updated_at,
1405            completed_at: r.completed_at,
1406        }
1407    }
1408}
1409
1410#[derive(sqlx::FromRow)]
1411struct PgEventRow {
1412    id: i64,
1413    workflow_id: String,
1414    seq: i32,
1415    event_type: String,
1416    payload: Option<String>,
1417    timestamp: f64,
1418}
1419
1420impl From<PgEventRow> for WorkflowEvent {
1421    fn from(r: PgEventRow) -> Self {
1422        Self {
1423            id: Some(r.id),
1424            workflow_id: r.workflow_id,
1425            seq: r.seq,
1426            event_type: r.event_type,
1427            payload: r.payload,
1428            timestamp: r.timestamp,
1429        }
1430    }
1431}
1432
1433#[derive(sqlx::FromRow)]
1434struct PgActivityRow {
1435    id: i64,
1436    workflow_id: String,
1437    seq: i32,
1438    name: String,
1439    task_queue: String,
1440    input: Option<String>,
1441    status: String,
1442    result: Option<String>,
1443    error: Option<String>,
1444    attempt: i32,
1445    max_attempts: i32,
1446    initial_interval_secs: f64,
1447    backoff_coefficient: f64,
1448    start_to_close_secs: f64,
1449    heartbeat_timeout_secs: Option<f64>,
1450    claimed_by: Option<String>,
1451    scheduled_at: f64,
1452    started_at: Option<f64>,
1453    completed_at: Option<f64>,
1454    last_heartbeat: Option<f64>,
1455}
1456
1457impl From<PgActivityRow> for WorkflowActivity {
1458    fn from(r: PgActivityRow) -> Self {
1459        Self {
1460            id: Some(r.id),
1461            workflow_id: r.workflow_id,
1462            seq: r.seq,
1463            name: r.name,
1464            task_queue: r.task_queue,
1465            input: r.input,
1466            status: r.status,
1467            result: r.result,
1468            error: r.error,
1469            attempt: r.attempt,
1470            max_attempts: r.max_attempts,
1471            initial_interval_secs: r.initial_interval_secs,
1472            backoff_coefficient: r.backoff_coefficient,
1473            start_to_close_secs: r.start_to_close_secs,
1474            heartbeat_timeout_secs: r.heartbeat_timeout_secs,
1475            claimed_by: r.claimed_by,
1476            scheduled_at: r.scheduled_at,
1477            started_at: r.started_at,
1478            completed_at: r.completed_at,
1479            last_heartbeat: r.last_heartbeat,
1480        }
1481    }
1482}
1483
1484#[derive(sqlx::FromRow)]
1485struct PgTimerRow {
1486    id: i64,
1487    workflow_id: String,
1488    seq: i32,
1489    fire_at: f64,
1490    fired: bool,
1491}
1492
1493impl From<PgTimerRow> for WorkflowTimer {
1494    fn from(r: PgTimerRow) -> Self {
1495        Self {
1496            id: Some(r.id),
1497            workflow_id: r.workflow_id,
1498            seq: r.seq,
1499            fire_at: r.fire_at,
1500            fired: r.fired,
1501        }
1502    }
1503}
1504
1505#[derive(sqlx::FromRow)]
1506struct PgSignalRow {
1507    id: i64,
1508    workflow_id: String,
1509    name: String,
1510    payload: Option<String>,
1511    consumed: bool,
1512    received_at: f64,
1513}
1514
1515impl From<PgSignalRow> for WorkflowSignal {
1516    fn from(r: PgSignalRow) -> Self {
1517        Self {
1518            id: Some(r.id),
1519            workflow_id: r.workflow_id,
1520            name: r.name,
1521            payload: r.payload,
1522            consumed: r.consumed,
1523            received_at: r.received_at,
1524        }
1525    }
1526}
1527
1528#[derive(sqlx::FromRow)]
1529struct PgScheduleRow {
1530    namespace: String,
1531    name: String,
1532    workflow_type: String,
1533    cron_expr: String,
1534    timezone: String,
1535    input: Option<String>,
1536    task_queue: String,
1537    overlap_policy: String,
1538    paused: bool,
1539    last_run_at: Option<f64>,
1540    next_run_at: Option<f64>,
1541    last_workflow_id: Option<String>,
1542    created_at: f64,
1543}
1544
1545impl From<PgScheduleRow> for WorkflowSchedule {
1546    fn from(r: PgScheduleRow) -> Self {
1547        Self {
1548            namespace: r.namespace,
1549            name: r.name,
1550            workflow_type: r.workflow_type,
1551            cron_expr: r.cron_expr,
1552            timezone: r.timezone,
1553            input: r.input,
1554            task_queue: r.task_queue,
1555            overlap_policy: r.overlap_policy,
1556            paused: r.paused,
1557            last_run_at: r.last_run_at,
1558            next_run_at: r.next_run_at,
1559            last_workflow_id: r.last_workflow_id,
1560            created_at: r.created_at,
1561        }
1562    }
1563}
1564
1565#[derive(sqlx::FromRow)]
1566struct PgWorkerRow {
1567    id: String,
1568    namespace: String,
1569    identity: String,
1570    task_queue: String,
1571    workflows: Option<String>,
1572    activities: Option<String>,
1573    max_concurrent_workflows: i32,
1574    max_concurrent_activities: i32,
1575    active_tasks: i32,
1576    last_heartbeat: f64,
1577    registered_at: f64,
1578}
1579
1580impl From<PgWorkerRow> for WorkflowWorker {
1581    fn from(r: PgWorkerRow) -> Self {
1582        Self {
1583            id: r.id,
1584            namespace: r.namespace,
1585            identity: r.identity,
1586            task_queue: r.task_queue,
1587            workflows: r.workflows,
1588            activities: r.activities,
1589            max_concurrent_workflows: r.max_concurrent_workflows,
1590            max_concurrent_activities: r.max_concurrent_activities,
1591            active_tasks: r.active_tasks,
1592            last_heartbeat: r.last_heartbeat,
1593            registered_at: r.registered_at,
1594        }
1595    }
1596}
1597
1598#[cfg(test)]
1599mod tests {
1600    use super::*;
1601
1602    #[test]
1603    fn sanitise_schema_keeps_statements_intact() {
1604        let input = "CREATE TABLE foo (x INT);\nCREATE INDEX idx_foo ON foo(x);\n";
1605        let out = sanitise_schema(input);
1606        assert_eq!(out.len(), 2);
1607        assert!(out[0].starts_with("CREATE TABLE foo"));
1608        assert!(out[1].starts_with("CREATE INDEX idx_foo"));
1609    }
1610
1611    #[test]
1612    fn sanitise_schema_drops_pure_comment_lines() {
1613        let input = "-- header comment\nCREATE TABLE foo (x INT);\n-- trailing comment\n";
1614        let out = sanitise_schema(input);
1615        assert_eq!(out.len(), 1);
1616        assert!(out[0].starts_with("CREATE TABLE foo"));
1617    }
1618
1619    #[test]
1620    fn sanitise_schema_ignores_semicolons_inside_comment_prose() {
1621        // Regression: the exact shape that broke v0.11.3–v0.11.5 in production.
1622        // `-- foo; bar` used to split into "foo" and " bar" fragments, the second
1623        // of which was executed as SQL and rejected with `syntax error at or near "bar"`.
1624        let input = "\
1625CREATE TABLE foo (x INT);
1626-- Idempotent across startups; fresh installs pick the column up from the
1627-- CREATE TABLE above so the ADD is a no-op.
1628";
1629        let out = sanitise_schema(input);
1630        assert_eq!(
1631            out.len(),
1632            1,
1633            "expected 1 real statement, got {}: {:?}",
1634            out.len(),
1635            out
1636        );
1637        assert!(out[0].starts_with("CREATE TABLE foo"));
1638    }
1639
1640    #[test]
1641    fn sanitise_schema_drops_indented_comment_lines() {
1642        let input = "  -- indented comment\n\tCREATE TABLE foo (x INT);\n";
1643        let out = sanitise_schema(input);
1644        assert_eq!(out.len(), 1);
1645        assert!(out[0].contains("CREATE TABLE foo"));
1646    }
1647
1648    #[test]
1649    fn sanitise_schema_real_constant_produces_only_ddl() {
1650        // The real SCHEMA constant must not produce any statement whose first
1651        // token isn't a recognised SQL keyword. A prose fragment leaking in
1652        // (e.g. "fresh installs...") means the filter regressed.
1653        for stmt in sanitise_schema(SCHEMA) {
1654            let first_word = stmt
1655                .split_whitespace()
1656                .next()
1657                .expect("non-empty statement")
1658                .to_uppercase();
1659            assert!(
1660                matches!(
1661                    first_word.as_str(),
1662                    "CREATE" | "INSERT" | "UPDATE" | "DROP" | "ALTER" | "WITH"
1663                ),
1664                "SCHEMA produced non-DDL statement starting with {first_word:?}: {stmt:?}"
1665            );
1666        }
1667    }
1668}