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,) = sqlx::query_as("SELECT pg_try_advisory_lock(1)")
370            .fetch_one(&self.pool)
371            .await?;
372        Ok(row.0)
373    }
374}
375
376impl WorkflowStore for PostgresStore {
377    // ── Namespaces ─────────────────────────────────────────
378
379    async fn create_namespace(&self, name: &str) -> Result<()> {
380        sqlx::query("INSERT INTO workflow.namespaces (name, created_at) VALUES ($1, EXTRACT(EPOCH FROM NOW()))")
381            .bind(name)
382            .execute(&self.pool)
383            .await?;
384        Ok(())
385    }
386
387    async fn list_namespaces(&self) -> Result<Vec<crate::store::NamespaceRecord>> {
388        let rows = sqlx::query_as::<_, (String, f64)>(
389            "SELECT name, created_at FROM workflow.namespaces ORDER BY name",
390        )
391        .fetch_all(&self.pool)
392        .await?;
393        Ok(rows
394            .into_iter()
395            .map(|(name, created_at)| crate::store::NamespaceRecord { name, created_at })
396            .collect())
397    }
398
399    async fn delete_namespace(&self, name: &str) -> Result<bool> {
400        let res = sqlx::query("DELETE FROM workflow.namespaces WHERE name = $1 AND name != 'main'")
401            .bind(name)
402            .execute(&self.pool)
403            .await?;
404        Ok(res.rows_affected() > 0)
405    }
406
407    async fn get_namespace_stats(&self, namespace: &str) -> Result<crate::store::NamespaceStats> {
408        let total: (i64,) =
409            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!(
534            " ORDER BY created_at DESC LIMIT ${} OFFSET ${}",
535            idx,
536            idx + 1
537        ));
538
539        let mut q = sqlx::query_as::<_, PgWorkflowRow>(&sql)
540            .bind(namespace)
541            .bind(&status_str)
542            .bind(workflow_type);
543        for (key, value) in &filter_pairs {
544            q = q.bind(key.clone());
545            // JSONB ->> always returns TEXT; compare by stringified value.
546            let as_text = match value {
547                serde_json::Value::String(s) => s.clone(),
548                other => other.to_string(),
549            };
550            q = q.bind(as_text);
551        }
552        let rows = q.bind(limit).bind(offset).fetch_all(&self.pool).await?;
553        Ok(rows.into_iter().map(Into::into).collect())
554    }
555
556    async fn update_workflow_status(
557        &self,
558        id: &str,
559        status: WorkflowStatus,
560        result: Option<&str>,
561        error: Option<&str>,
562    ) -> Result<()> {
563        let now = timestamp_now();
564        let completed_at = if status.is_terminal() {
565            Some(now)
566        } else {
567            None
568        };
569        sqlx::query(
570            "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",
571        )
572        .bind(status.to_string())
573        .bind(result)
574        .bind(error)
575        .bind(now)
576        .bind(completed_at)
577        .bind(id)
578        .execute(&self.pool)
579        .await?;
580        Ok(())
581    }
582
583    async fn claim_workflow(&self, id: &str, worker_id: &str) -> Result<bool> {
584        let res = sqlx::query(
585            "UPDATE workflow.workflows SET claimed_by = $1, status = 'RUNNING', updated_at = $2 WHERE id = $3 AND claimed_by IS NULL",
586        )
587        .bind(worker_id)
588        .bind(timestamp_now())
589        .bind(id)
590        .execute(&self.pool)
591        .await?;
592        Ok(res.rows_affected() > 0)
593    }
594
595    async fn mark_workflow_dispatchable(&self, workflow_id: &str) -> Result<()> {
596        sqlx::query("UPDATE workflow.workflows SET needs_dispatch = TRUE WHERE id = $1")
597            .bind(workflow_id)
598            .execute(&self.pool)
599            .await?;
600        Ok(())
601    }
602
603    async fn claim_workflow_task(
604        &self,
605        task_queue: &str,
606        worker_id: &str,
607    ) -> Result<Option<WorkflowRecord>> {
608        let now = timestamp_now();
609        // Atomic claim with FOR UPDATE SKIP LOCKED so multiple engine
610        // replicas don't fight over the same workflow task.
611        let row = sqlx::query_as::<_, PgWorkflowRow>(
612            "UPDATE workflow.workflows
613             SET dispatch_claimed_by = $1, dispatch_last_heartbeat = $2, needs_dispatch = FALSE
614             WHERE id = (
615                SELECT id FROM workflow.workflows
616                WHERE task_queue = $3
617                  AND needs_dispatch = TRUE
618                  AND dispatch_claimed_by IS NULL
619                  AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
620                ORDER BY updated_at ASC
621                FOR UPDATE SKIP LOCKED
622                LIMIT 1
623             )
624             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",
625        )
626        .bind(worker_id)
627        .bind(now)
628        .bind(task_queue)
629        .fetch_optional(&self.pool)
630        .await?;
631        Ok(row.map(Into::into))
632    }
633
634    async fn release_workflow_task(&self, workflow_id: &str, worker_id: &str) -> Result<()> {
635        sqlx::query(
636            "UPDATE workflow.workflows
637             SET dispatch_claimed_by = NULL, dispatch_last_heartbeat = NULL
638             WHERE id = $1 AND dispatch_claimed_by = $2",
639        )
640        .bind(workflow_id)
641        .bind(worker_id)
642        .execute(&self.pool)
643        .await?;
644        Ok(())
645    }
646
647    async fn release_stale_dispatch_leases(&self, now: f64, timeout_secs: f64) -> Result<u64> {
648        let res = sqlx::query(
649            "UPDATE workflow.workflows
650             SET dispatch_claimed_by = NULL,
651                 dispatch_last_heartbeat = NULL,
652                 needs_dispatch = TRUE
653             WHERE dispatch_claimed_by IS NOT NULL
654               AND ($1 - dispatch_last_heartbeat) > $2
655               AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')",
656        )
657        .bind(now)
658        .bind(timeout_secs)
659        .execute(&self.pool)
660        .await?;
661        Ok(res.rows_affected())
662    }
663
664    // ── Events ─────────────────────────────────────────────
665
666    async fn append_event(&self, ev: &WorkflowEvent) -> Result<i64> {
667        let row: (i64,) = sqlx::query_as(
668            "INSERT INTO workflow.events (workflow_id, seq, event_type, payload, timestamp) VALUES ($1, $2, $3, $4, $5) RETURNING id",
669        )
670        .bind(&ev.workflow_id)
671        .bind(ev.seq)
672        .bind(&ev.event_type)
673        .bind(&ev.payload)
674        .bind(ev.timestamp)
675        .fetch_one(&self.pool)
676        .await?;
677        Ok(row.0)
678    }
679
680    async fn list_events(&self, workflow_id: &str) -> Result<Vec<WorkflowEvent>> {
681        let rows = sqlx::query_as::<_, PgEventRow>(
682            "SELECT id, workflow_id, seq, event_type, payload, timestamp FROM workflow.events WHERE workflow_id = $1 ORDER BY seq ASC",
683        )
684        .bind(workflow_id)
685        .fetch_all(&self.pool)
686        .await?;
687        Ok(rows.into_iter().map(Into::into).collect())
688    }
689
690    async fn list_events_page(
691        &self,
692        workflow_id: &str,
693        cursor: Option<i32>,
694        limit: i64,
695        descending: bool,
696    ) -> Result<Vec<WorkflowEvent>> {
697        let limit = limit.clamp(0, 1_000);
698        if limit == 0 {
699            return Ok(Vec::new());
700        }
701        let rows = if descending {
702            sqlx::query_as::<_, PgEventRow>(
703                "SELECT id, workflow_id, seq, event_type, payload, timestamp
704                 FROM workflow.events
705                 WHERE workflow_id = $1 AND ($2::INTEGER IS NULL OR seq < $2)
706                 ORDER BY seq DESC LIMIT $3",
707            )
708            .bind(workflow_id)
709            .bind(cursor)
710            .bind(limit)
711            .fetch_all(&self.pool)
712            .await?
713        } else {
714            sqlx::query_as::<_, PgEventRow>(
715                "SELECT id, workflow_id, seq, event_type, payload, timestamp
716                 FROM workflow.events
717                 WHERE workflow_id = $1 AND ($2::INTEGER IS NULL OR seq > $2)
718                 ORDER BY seq ASC LIMIT $3",
719            )
720            .bind(workflow_id)
721            .bind(cursor)
722            .bind(limit)
723            .fetch_all(&self.pool)
724            .await?
725        };
726        Ok(rows.into_iter().map(Into::into).collect())
727    }
728
729    async fn get_event_count(&self, workflow_id: &str) -> Result<i64> {
730        let row: (i64,) =
731            sqlx::query_as("SELECT COUNT(*) FROM workflow.events WHERE workflow_id = $1")
732                .bind(workflow_id)
733                .fetch_one(&self.pool)
734                .await?;
735        Ok(row.0)
736    }
737
738    // ── Activities ──────────────────────────────────────────
739
740    async fn create_activity(&self, act: &WorkflowActivity) -> Result<i64> {
741        let row: (i64,) = sqlx::query_as(
742            "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)
743             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id",
744        )
745        .bind(&act.workflow_id)
746        .bind(act.seq)
747        .bind(&act.name)
748        .bind(&act.task_queue)
749        .bind(&act.input)
750        .bind(&act.status)
751        .bind(act.attempt)
752        .bind(act.max_attempts)
753        .bind(act.initial_interval_secs)
754        .bind(act.backoff_coefficient)
755        .bind(act.start_to_close_secs)
756        .bind(act.heartbeat_timeout_secs)
757        .bind(act.scheduled_at)
758        .fetch_one(&self.pool)
759        .await?;
760        Ok(row.0)
761    }
762
763    async fn get_activity(&self, id: i64) -> Result<Option<WorkflowActivity>> {
764        let row = sqlx::query_as::<_, PgActivityRow>(
765            "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
766             FROM workflow.activities WHERE id = $1",
767        )
768        .bind(id)
769        .fetch_optional(&self.pool)
770        .await?;
771        Ok(row.map(Into::into))
772    }
773
774    async fn get_activity_by_workflow_seq(
775        &self,
776        workflow_id: &str,
777        seq: i32,
778    ) -> Result<Option<WorkflowActivity>> {
779        let row = sqlx::query_as::<_, PgActivityRow>(
780            "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
781             FROM workflow.activities WHERE workflow_id = $1 AND seq = $2",
782        )
783        .bind(workflow_id)
784        .bind(seq)
785        .fetch_optional(&self.pool)
786        .await?;
787        Ok(row.map(Into::into))
788    }
789
790    async fn claim_activity(
791        &self,
792        task_queue: &str,
793        worker_id: &str,
794    ) -> Result<Option<WorkflowActivity>> {
795        let now = timestamp_now();
796        // Atomic claim using FOR UPDATE SKIP LOCKED — prevents contention
797        // between multiple assay serve instances claiming the same activity
798        let row = sqlx::query_as::<_, PgActivityRow>(
799            "UPDATE workflow.activities SET status = 'RUNNING', claimed_by = $1, started_at = $2
800             WHERE id = (
801                SELECT id FROM workflow.activities
802                WHERE task_queue = $3 AND status = 'PENDING'
803                ORDER BY scheduled_at ASC
804                FOR UPDATE SKIP LOCKED
805                LIMIT 1
806             )
807             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",
808        )
809        .bind(worker_id)
810        .bind(now)
811        .bind(task_queue)
812        .fetch_optional(&self.pool)
813        .await?;
814        Ok(row.map(Into::into))
815    }
816
817    async fn requeue_activity_for_retry(
818        &self,
819        id: i64,
820        next_attempt: i32,
821        next_scheduled_at: f64,
822    ) -> Result<()> {
823        sqlx::query(
824            "UPDATE workflow.activities
825             SET status = 'PENDING', attempt = $1, scheduled_at = $2,
826                 claimed_by = NULL, started_at = NULL, last_heartbeat = NULL,
827                 error = NULL
828             WHERE id = $3",
829        )
830        .bind(next_attempt)
831        .bind(next_scheduled_at)
832        .bind(id)
833        .execute(&self.pool)
834        .await?;
835        Ok(())
836    }
837
838    async fn complete_activity(
839        &self,
840        id: i64,
841        result: Option<&str>,
842        error: Option<&str>,
843        failed: bool,
844    ) -> Result<()> {
845        let status = if failed { "FAILED" } else { "COMPLETED" };
846        sqlx::query(
847            "UPDATE workflow.activities SET status = $1, result = $2, error = $3, completed_at = $4 WHERE id = $5",
848        )
849        .bind(status)
850        .bind(result)
851        .bind(error)
852        .bind(timestamp_now())
853        .bind(id)
854        .execute(&self.pool)
855        .await?;
856        Ok(())
857    }
858
859    async fn heartbeat_activity(&self, id: i64, _details: Option<&str>) -> Result<()> {
860        sqlx::query("UPDATE workflow.activities SET last_heartbeat = $1 WHERE id = $2")
861            .bind(timestamp_now())
862            .bind(id)
863            .execute(&self.pool)
864            .await?;
865        Ok(())
866    }
867
868    async fn get_timed_out_activities(&self, now: f64) -> Result<Vec<WorkflowActivity>> {
869        let rows = sqlx::query_as::<_, PgActivityRow>(
870            "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
871             FROM workflow.activities
872             WHERE status = 'RUNNING'
873               AND heartbeat_timeout_secs IS NOT NULL
874               AND ($1 - COALESCE(last_heartbeat, started_at)) > heartbeat_timeout_secs",
875        )
876        .bind(now)
877        .fetch_all(&self.pool)
878        .await?;
879        Ok(rows.into_iter().map(Into::into).collect())
880    }
881
882    // ── Timers ──────────────────────────────────────────────
883
884    async fn create_timer(&self, timer: &WorkflowTimer) -> Result<i64> {
885        // Idempotent: ON CONFLICT (workflow_id, seq) DO NOTHING.
886        // If a row already exists, RETURNING produces no rows — fall back to SELECT.
887        let inserted: Option<(i64,)> = sqlx::query_as(
888            "INSERT INTO workflow.timers (workflow_id, seq, fire_at, fired)
889             VALUES ($1, $2, $3, FALSE)
890             ON CONFLICT (workflow_id, seq) DO NOTHING
891             RETURNING id",
892        )
893        .bind(&timer.workflow_id)
894        .bind(timer.seq)
895        .bind(timer.fire_at)
896        .fetch_optional(&self.pool)
897        .await?;
898
899        if let Some((id,)) = inserted {
900            return Ok(id);
901        }
902
903        // Row already existed — return its id.
904        let (id,): (i64,) =
905            sqlx::query_as("SELECT id FROM workflow.timers WHERE workflow_id = $1 AND seq = $2")
906                .bind(&timer.workflow_id)
907                .bind(timer.seq)
908                .fetch_one(&self.pool)
909                .await?;
910        Ok(id)
911    }
912
913    async fn cancel_pending_activities(&self, workflow_id: &str) -> Result<u64> {
914        let res = sqlx::query(
915            "UPDATE workflow.activities SET status = 'CANCELLED', completed_at = $1
916             WHERE workflow_id = $2 AND status = 'PENDING'",
917        )
918        .bind(timestamp_now())
919        .bind(workflow_id)
920        .execute(&self.pool)
921        .await?;
922        Ok(res.rows_affected())
923    }
924
925    async fn cancel_pending_timers(&self, workflow_id: &str) -> Result<u64> {
926        let res = sqlx::query(
927            "UPDATE workflow.timers SET fired = TRUE
928             WHERE workflow_id = $1 AND fired = FALSE",
929        )
930        .bind(workflow_id)
931        .execute(&self.pool)
932        .await?;
933        Ok(res.rows_affected())
934    }
935
936    async fn get_timer_by_workflow_seq(
937        &self,
938        workflow_id: &str,
939        seq: i32,
940    ) -> Result<Option<WorkflowTimer>> {
941        let row = sqlx::query_as::<_, PgTimerRow>(
942            "SELECT id, workflow_id, seq, fire_at, fired
943             FROM workflow.timers WHERE workflow_id = $1 AND seq = $2",
944        )
945        .bind(workflow_id)
946        .bind(seq)
947        .fetch_optional(&self.pool)
948        .await?;
949        Ok(row.map(Into::into))
950    }
951
952    async fn fire_due_timers(&self, now: f64) -> Result<Vec<WorkflowTimer>> {
953        let rows = sqlx::query_as::<_, PgTimerRow>(
954            "UPDATE workflow.timers SET fired = TRUE
955             WHERE fired = FALSE AND fire_at <= $1
956             RETURNING id, workflow_id, seq, fire_at, fired",
957        )
958        .bind(now)
959        .fetch_all(&self.pool)
960        .await?;
961        Ok(rows.into_iter().map(Into::into).collect())
962    }
963
964    // ── Signals ─────────────────────────────────────────────
965
966    async fn send_signal(&self, sig: &WorkflowSignal) -> Result<i64> {
967        let row: (i64,) = sqlx::query_as(
968            "INSERT INTO workflow.signals (workflow_id, name, payload, consumed, received_at) VALUES ($1, $2, $3, FALSE, $4) RETURNING id",
969        )
970        .bind(&sig.workflow_id)
971        .bind(&sig.name)
972        .bind(&sig.payload)
973        .bind(sig.received_at)
974        .fetch_one(&self.pool)
975        .await?;
976        Ok(row.0)
977    }
978
979    async fn consume_signals(&self, workflow_id: &str, name: &str) -> Result<Vec<WorkflowSignal>> {
980        let rows = sqlx::query_as::<_, PgSignalRow>(
981            "UPDATE workflow.signals SET consumed = TRUE
982             WHERE workflow_id = $1 AND name = $2 AND consumed = FALSE
983             RETURNING id, workflow_id, name, payload, consumed, received_at",
984        )
985        .bind(workflow_id)
986        .bind(name)
987        .fetch_all(&self.pool)
988        .await?;
989        Ok(rows.into_iter().map(Into::into).collect())
990    }
991
992    // ── Schedules ───────────────────────────────────────────
993
994    async fn create_schedule(&self, sched: &WorkflowSchedule) -> Result<()> {
995        sqlx::query(
996            "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)
997             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
998        )
999        .bind(&sched.namespace)
1000        .bind(&sched.name)
1001        .bind(&sched.workflow_type)
1002        .bind(&sched.cron_expr)
1003        .bind(&sched.timezone)
1004        .bind(&sched.input)
1005        .bind(&sched.task_queue)
1006        .bind(&sched.overlap_policy)
1007        .bind(sched.paused)
1008        .bind(sched.last_run_at)
1009        .bind(sched.next_run_at)
1010        .bind(&sched.last_workflow_id)
1011        .bind(sched.created_at)
1012        .execute(&self.pool)
1013        .await?;
1014        Ok(())
1015    }
1016
1017    async fn get_schedule(&self, namespace: &str, name: &str) -> Result<Option<WorkflowSchedule>> {
1018        let row = sqlx::query_as::<_, PgScheduleRow>(
1019            "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",
1020        )
1021        .bind(namespace)
1022        .bind(name)
1023        .fetch_optional(&self.pool)
1024        .await?;
1025        Ok(row.map(Into::into))
1026    }
1027
1028    async fn list_schedules(&self, namespace: &str) -> Result<Vec<WorkflowSchedule>> {
1029        let rows = sqlx::query_as::<_, PgScheduleRow>(
1030            "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",
1031        )
1032        .bind(namespace)
1033        .fetch_all(&self.pool)
1034        .await?;
1035        Ok(rows.into_iter().map(Into::into).collect())
1036    }
1037
1038    async fn update_schedule_last_run(
1039        &self,
1040        namespace: &str,
1041        name: &str,
1042        last_run_at: f64,
1043        next_run_at: f64,
1044        workflow_id: &str,
1045    ) -> Result<()> {
1046        sqlx::query(
1047            "UPDATE workflow.schedules SET last_run_at = $1, next_run_at = $2, last_workflow_id = $3 WHERE namespace = $4 AND name = $5",
1048        )
1049        .bind(last_run_at)
1050        .bind(next_run_at)
1051        .bind(workflow_id)
1052        .bind(namespace)
1053        .bind(name)
1054        .execute(&self.pool)
1055        .await?;
1056        Ok(())
1057    }
1058
1059    async fn delete_schedule(&self, namespace: &str, name: &str) -> Result<bool> {
1060        let res = sqlx::query("DELETE FROM workflow.schedules WHERE namespace = $1 AND name = $2")
1061            .bind(namespace)
1062            .bind(name)
1063            .execute(&self.pool)
1064            .await?;
1065        Ok(res.rows_affected() > 0)
1066    }
1067
1068    async fn list_archivable_workflows(
1069        &self,
1070        cutoff: f64,
1071        limit: i64,
1072    ) -> Result<Vec<WorkflowRecord>> {
1073        let rows = sqlx::query_as::<_, PgWorkflowRow>(
1074            "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
1075             FROM workflow.workflows
1076             WHERE status IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
1077               AND completed_at IS NOT NULL
1078               AND completed_at < $1
1079               AND archived_at IS NULL
1080             ORDER BY completed_at ASC
1081             LIMIT $2",
1082        )
1083        .bind(cutoff)
1084        .bind(limit)
1085        .fetch_all(&self.pool)
1086        .await?;
1087        Ok(rows.into_iter().map(Into::into).collect())
1088    }
1089
1090    async fn mark_archived_and_purge(
1091        &self,
1092        workflow_id: &str,
1093        archive_uri: &str,
1094        archived_at: f64,
1095    ) -> Result<()> {
1096        let mut tx = self.pool.begin().await?;
1097        sqlx::query("DELETE FROM workflow.events WHERE workflow_id = $1")
1098            .bind(workflow_id)
1099            .execute(&mut *tx)
1100            .await?;
1101        sqlx::query("DELETE FROM workflow.activities WHERE workflow_id = $1")
1102            .bind(workflow_id)
1103            .execute(&mut *tx)
1104            .await?;
1105        sqlx::query("DELETE FROM workflow.timers WHERE workflow_id = $1")
1106            .bind(workflow_id)
1107            .execute(&mut *tx)
1108            .await?;
1109        sqlx::query("DELETE FROM workflow.signals WHERE workflow_id = $1")
1110            .bind(workflow_id)
1111            .execute(&mut *tx)
1112            .await?;
1113        sqlx::query("DELETE FROM workflow.snapshots WHERE workflow_id = $1")
1114            .bind(workflow_id)
1115            .execute(&mut *tx)
1116            .await?;
1117        sqlx::query(
1118            "UPDATE workflow.workflows SET archived_at = $1, archive_uri = $2 WHERE id = $3",
1119        )
1120        .bind(archived_at)
1121        .bind(archive_uri)
1122        .bind(workflow_id)
1123        .execute(&mut *tx)
1124        .await?;
1125        tx.commit().await?;
1126        Ok(())
1127    }
1128
1129    async fn upsert_search_attributes(&self, workflow_id: &str, patch_json: &str) -> Result<()> {
1130        let current: Option<(Option<String>,)> =
1131            sqlx::query_as("SELECT search_attributes FROM workflow.workflows WHERE id = $1")
1132                .bind(workflow_id)
1133                .fetch_optional(&self.pool)
1134                .await?;
1135        let merged = crate::store::sqlite::merge_search_attrs(
1136            current.and_then(|(s,)| s).as_deref(),
1137            patch_json,
1138        )?;
1139        sqlx::query("UPDATE workflow.workflows SET search_attributes = $1 WHERE id = $2")
1140            .bind(merged)
1141            .bind(workflow_id)
1142            .execute(&self.pool)
1143            .await?;
1144        Ok(())
1145    }
1146
1147    async fn update_schedule(
1148        &self,
1149        namespace: &str,
1150        name: &str,
1151        patch: &SchedulePatch,
1152    ) -> Result<Option<WorkflowSchedule>> {
1153        let mut sets: Vec<String> = Vec::new();
1154        let mut idx = 1usize;
1155        if patch.cron_expr.is_some() {
1156            sets.push(format!("cron_expr = ${idx}"));
1157            idx += 1;
1158        }
1159        if patch.timezone.is_some() {
1160            sets.push(format!("timezone = ${idx}"));
1161            idx += 1;
1162        }
1163        if patch.input.is_some() {
1164            sets.push(format!("input = ${idx}"));
1165            idx += 1;
1166        }
1167        if patch.task_queue.is_some() {
1168            sets.push(format!("task_queue = ${idx}"));
1169            idx += 1;
1170        }
1171        if patch.overlap_policy.is_some() {
1172            sets.push(format!("overlap_policy = ${idx}"));
1173            idx += 1;
1174        }
1175        if sets.is_empty() {
1176            return self.get_schedule(namespace, name).await;
1177        }
1178        let sql = format!(
1179            "UPDATE workflow.schedules SET {} WHERE namespace = ${} AND name = ${}",
1180            sets.join(", "),
1181            idx,
1182            idx + 1
1183        );
1184        let mut q = sqlx::query(&sql);
1185        if let Some(ref v) = patch.cron_expr {
1186            q = q.bind(v);
1187        }
1188        if let Some(ref v) = patch.timezone {
1189            q = q.bind(v);
1190        }
1191        if let Some(ref v) = patch.input {
1192            q = q.bind(v.to_string());
1193        }
1194        if let Some(ref v) = patch.task_queue {
1195            q = q.bind(v);
1196        }
1197        if let Some(ref v) = patch.overlap_policy {
1198            q = q.bind(v);
1199        }
1200        let res = q.bind(namespace).bind(name).execute(&self.pool).await?;
1201        if res.rows_affected() == 0 {
1202            return Ok(None);
1203        }
1204        self.get_schedule(namespace, name).await
1205    }
1206
1207    async fn set_schedule_paused(
1208        &self,
1209        namespace: &str,
1210        name: &str,
1211        paused: bool,
1212    ) -> Result<Option<WorkflowSchedule>> {
1213        let res = sqlx::query(
1214            "UPDATE workflow.schedules SET paused = $1 WHERE namespace = $2 AND name = $3",
1215        )
1216        .bind(paused)
1217        .bind(namespace)
1218        .bind(name)
1219        .execute(&self.pool)
1220        .await?;
1221        if res.rows_affected() == 0 {
1222            return Ok(None);
1223        }
1224        self.get_schedule(namespace, name).await
1225    }
1226
1227    // ── Workers ─────────────────────────────────────────────
1228
1229    async fn register_worker(&self, w: &WorkflowWorker) -> Result<()> {
1230        sqlx::query(
1231            "INSERT INTO workflow.workers (id, namespace, identity, task_queue, workflows, activities, max_concurrent_workflows, max_concurrent_activities, active_tasks, last_heartbeat, registered_at)
1232             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
1233             ON CONFLICT (id) DO UPDATE SET last_heartbeat = EXCLUDED.last_heartbeat, identity = EXCLUDED.identity",
1234        )
1235        .bind(&w.id)
1236        .bind(&w.namespace)
1237        .bind(&w.identity)
1238        .bind(&w.task_queue)
1239        .bind(&w.workflows)
1240        .bind(&w.activities)
1241        .bind(w.max_concurrent_workflows)
1242        .bind(w.max_concurrent_activities)
1243        .bind(w.active_tasks)
1244        .bind(w.last_heartbeat)
1245        .bind(w.registered_at)
1246        .execute(&self.pool)
1247        .await?;
1248        Ok(())
1249    }
1250
1251    async fn heartbeat_worker(&self, id: &str, now: f64) -> Result<()> {
1252        sqlx::query("UPDATE workflow.workers SET last_heartbeat = $1 WHERE id = $2")
1253            .bind(now)
1254            .bind(id)
1255            .execute(&self.pool)
1256            .await?;
1257        Ok(())
1258    }
1259
1260    async fn list_workers(&self, namespace: &str) -> Result<Vec<WorkflowWorker>> {
1261        let rows = sqlx::query_as::<_, PgWorkerRow>(
1262            "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",
1263        )
1264        .bind(namespace)
1265        .fetch_all(&self.pool)
1266        .await?;
1267        Ok(rows.into_iter().map(Into::into).collect())
1268    }
1269
1270    async fn remove_dead_workers(&self, cutoff: f64) -> Result<Vec<String>> {
1271        let rows: Vec<(String,)> =
1272            sqlx::query_as("SELECT id FROM workflow.workers WHERE last_heartbeat < $1")
1273                .bind(cutoff)
1274                .fetch_all(&self.pool)
1275                .await?;
1276        let ids: Vec<String> = rows.into_iter().map(|r| r.0).collect();
1277        if !ids.is_empty() {
1278            sqlx::query("DELETE FROM workflow.workers WHERE last_heartbeat < $1")
1279                .bind(cutoff)
1280                .execute(&self.pool)
1281                .await?;
1282        }
1283        Ok(ids)
1284    }
1285
1286    // ── Child Workflows ─────────────────────────────────────
1287
1288    async fn list_child_workflows(&self, parent_id: &str) -> Result<Vec<WorkflowRecord>> {
1289        let rows = sqlx::query_as::<_, PgWorkflowRow>(
1290            "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
1291             FROM workflow.workflows WHERE parent_id = $1 ORDER BY created_at ASC",
1292        )
1293        .bind(parent_id)
1294        .fetch_all(&self.pool)
1295        .await?;
1296        Ok(rows.into_iter().map(Into::into).collect())
1297    }
1298
1299    // ── Snapshots ───────────────────────────────────────────
1300
1301    async fn create_snapshot(
1302        &self,
1303        workflow_id: &str,
1304        event_seq: i32,
1305        state_json: &str,
1306    ) -> Result<()> {
1307        sqlx::query(
1308            "INSERT INTO workflow.snapshots (workflow_id, event_seq, state_json, created_at)
1309             VALUES ($1, $2, $3, $4)
1310             ON CONFLICT (workflow_id, event_seq) DO UPDATE SET state_json = EXCLUDED.state_json, created_at = EXCLUDED.created_at",
1311        )
1312        .bind(workflow_id)
1313        .bind(event_seq)
1314        .bind(state_json)
1315        .bind(timestamp_now())
1316        .execute(&self.pool)
1317        .await?;
1318        Ok(())
1319    }
1320
1321    async fn get_latest_snapshot(&self, workflow_id: &str) -> Result<Option<WorkflowSnapshot>> {
1322        let row = sqlx::query_as::<_, (String, i32, String, f64)>(
1323            "SELECT workflow_id, event_seq, state_json, created_at
1324             FROM workflow.snapshots WHERE workflow_id = $1
1325             ORDER BY event_seq DESC LIMIT 1",
1326        )
1327        .bind(workflow_id)
1328        .fetch_optional(&self.pool)
1329        .await?;
1330
1331        Ok(row.map(
1332            |(workflow_id, event_seq, state_json, created_at)| WorkflowSnapshot {
1333                workflow_id,
1334                event_seq,
1335                state_json,
1336                created_at,
1337            },
1338        ))
1339    }
1340
1341    // ── Queue Stats ─────────────────────────────────────────
1342
1343    async fn get_queue_stats(&self, namespace: &str) -> Result<Vec<crate::store::QueueStats>> {
1344        let rows = sqlx::query_as::<_, (String, i64, i64, i64)>(
1345            "SELECT
1346                a.task_queue AS queue,
1347                SUM(CASE WHEN a.status = 'PENDING' THEN 1 ELSE 0 END) AS pending,
1348                SUM(CASE WHEN a.status = 'RUNNING' THEN 1 ELSE 0 END) AS running,
1349                (SELECT COUNT(*) FROM workflow.workers w WHERE w.task_queue = a.task_queue AND w.namespace = $1) AS workers
1350             FROM workflow.activities a
1351             JOIN workflow.workflows wf ON a.workflow_id = wf.id AND wf.namespace = $1
1352             GROUP BY a.task_queue",
1353        )
1354        .bind(namespace)
1355        .fetch_all(&self.pool)
1356        .await?;
1357
1358        Ok(rows
1359            .into_iter()
1360            .map(
1361                |(queue, pending, running, workers)| crate::store::QueueStats {
1362                    queue,
1363                    pending_activities: pending,
1364                    running_activities: running,
1365                    workers,
1366                },
1367            )
1368            .collect())
1369    }
1370
1371    // ── Leader Election ─────────────────────────────────────
1372
1373    async fn try_acquire_scheduler_lock(&self) -> Result<bool> {
1374        // pg_try_advisory_lock is session-scoped — only one connection
1375        // in the pool will hold the lock. In a multi-replica Kubernetes
1376        // deployment, only one pod's connection wins.
1377        let row: (bool,) = sqlx::query_as("SELECT pg_try_advisory_lock(42)")
1378            .fetch_one(&self.pool)
1379            .await?;
1380        Ok(row.0)
1381    }
1382}
1383
1384fn timestamp_now() -> f64 {
1385    std::time::SystemTime::now()
1386        .duration_since(std::time::UNIX_EPOCH)
1387        .unwrap()
1388        .as_secs_f64()
1389}
1390
1391// ── Postgres row types (sqlx::FromRow) ──────────────────────
1392
1393#[derive(sqlx::FromRow)]
1394struct PgWorkflowRow {
1395    id: String,
1396    namespace: String,
1397    run_id: String,
1398    workflow_type: String,
1399    task_queue: String,
1400    status: String,
1401    input: Option<String>,
1402    result: Option<String>,
1403    error: Option<String>,
1404    parent_id: Option<String>,
1405    claimed_by: Option<String>,
1406    search_attributes: Option<String>,
1407    archived_at: Option<f64>,
1408    archive_uri: Option<String>,
1409    created_at: f64,
1410    updated_at: f64,
1411    completed_at: Option<f64>,
1412}
1413
1414impl From<PgWorkflowRow> for WorkflowRecord {
1415    fn from(r: PgWorkflowRow) -> Self {
1416        Self {
1417            id: r.id,
1418            namespace: r.namespace,
1419            run_id: r.run_id,
1420            workflow_type: r.workflow_type,
1421            task_queue: r.task_queue,
1422            status: r.status,
1423            input: r.input,
1424            result: r.result,
1425            error: r.error,
1426            parent_id: r.parent_id,
1427            claimed_by: r.claimed_by,
1428            search_attributes: r.search_attributes,
1429            archived_at: r.archived_at,
1430            archive_uri: r.archive_uri,
1431            created_at: r.created_at,
1432            updated_at: r.updated_at,
1433            completed_at: r.completed_at,
1434        }
1435    }
1436}
1437
1438#[derive(sqlx::FromRow)]
1439struct PgEventRow {
1440    id: i64,
1441    workflow_id: String,
1442    seq: i32,
1443    event_type: String,
1444    payload: Option<String>,
1445    timestamp: f64,
1446}
1447
1448impl From<PgEventRow> for WorkflowEvent {
1449    fn from(r: PgEventRow) -> Self {
1450        Self {
1451            id: Some(r.id),
1452            workflow_id: r.workflow_id,
1453            seq: r.seq,
1454            event_type: r.event_type,
1455            payload: r.payload,
1456            timestamp: r.timestamp,
1457        }
1458    }
1459}
1460
1461#[derive(sqlx::FromRow)]
1462struct PgActivityRow {
1463    id: i64,
1464    workflow_id: String,
1465    seq: i32,
1466    name: String,
1467    task_queue: String,
1468    input: Option<String>,
1469    status: String,
1470    result: Option<String>,
1471    error: Option<String>,
1472    attempt: i32,
1473    max_attempts: i32,
1474    initial_interval_secs: f64,
1475    backoff_coefficient: f64,
1476    start_to_close_secs: f64,
1477    heartbeat_timeout_secs: Option<f64>,
1478    claimed_by: Option<String>,
1479    scheduled_at: f64,
1480    started_at: Option<f64>,
1481    completed_at: Option<f64>,
1482    last_heartbeat: Option<f64>,
1483}
1484
1485impl From<PgActivityRow> for WorkflowActivity {
1486    fn from(r: PgActivityRow) -> Self {
1487        Self {
1488            id: Some(r.id),
1489            workflow_id: r.workflow_id,
1490            seq: r.seq,
1491            name: r.name,
1492            task_queue: r.task_queue,
1493            input: r.input,
1494            status: r.status,
1495            result: r.result,
1496            error: r.error,
1497            attempt: r.attempt,
1498            max_attempts: r.max_attempts,
1499            initial_interval_secs: r.initial_interval_secs,
1500            backoff_coefficient: r.backoff_coefficient,
1501            start_to_close_secs: r.start_to_close_secs,
1502            heartbeat_timeout_secs: r.heartbeat_timeout_secs,
1503            claimed_by: r.claimed_by,
1504            scheduled_at: r.scheduled_at,
1505            started_at: r.started_at,
1506            completed_at: r.completed_at,
1507            last_heartbeat: r.last_heartbeat,
1508        }
1509    }
1510}
1511
1512#[derive(sqlx::FromRow)]
1513struct PgTimerRow {
1514    id: i64,
1515    workflow_id: String,
1516    seq: i32,
1517    fire_at: f64,
1518    fired: bool,
1519}
1520
1521impl From<PgTimerRow> for WorkflowTimer {
1522    fn from(r: PgTimerRow) -> Self {
1523        Self {
1524            id: Some(r.id),
1525            workflow_id: r.workflow_id,
1526            seq: r.seq,
1527            fire_at: r.fire_at,
1528            fired: r.fired,
1529        }
1530    }
1531}
1532
1533#[derive(sqlx::FromRow)]
1534struct PgSignalRow {
1535    id: i64,
1536    workflow_id: String,
1537    name: String,
1538    payload: Option<String>,
1539    consumed: bool,
1540    received_at: f64,
1541}
1542
1543impl From<PgSignalRow> for WorkflowSignal {
1544    fn from(r: PgSignalRow) -> Self {
1545        Self {
1546            id: Some(r.id),
1547            workflow_id: r.workflow_id,
1548            name: r.name,
1549            payload: r.payload,
1550            consumed: r.consumed,
1551            received_at: r.received_at,
1552        }
1553    }
1554}
1555
1556#[derive(sqlx::FromRow)]
1557struct PgScheduleRow {
1558    namespace: String,
1559    name: String,
1560    workflow_type: String,
1561    cron_expr: String,
1562    timezone: String,
1563    input: Option<String>,
1564    task_queue: String,
1565    overlap_policy: String,
1566    paused: bool,
1567    last_run_at: Option<f64>,
1568    next_run_at: Option<f64>,
1569    last_workflow_id: Option<String>,
1570    created_at: f64,
1571}
1572
1573impl From<PgScheduleRow> for WorkflowSchedule {
1574    fn from(r: PgScheduleRow) -> Self {
1575        Self {
1576            namespace: r.namespace,
1577            name: r.name,
1578            workflow_type: r.workflow_type,
1579            cron_expr: r.cron_expr,
1580            timezone: r.timezone,
1581            input: r.input,
1582            task_queue: r.task_queue,
1583            overlap_policy: r.overlap_policy,
1584            paused: r.paused,
1585            last_run_at: r.last_run_at,
1586            next_run_at: r.next_run_at,
1587            last_workflow_id: r.last_workflow_id,
1588            created_at: r.created_at,
1589        }
1590    }
1591}
1592
1593#[derive(sqlx::FromRow)]
1594struct PgWorkerRow {
1595    id: String,
1596    namespace: String,
1597    identity: String,
1598    task_queue: String,
1599    workflows: Option<String>,
1600    activities: Option<String>,
1601    max_concurrent_workflows: i32,
1602    max_concurrent_activities: i32,
1603    active_tasks: i32,
1604    last_heartbeat: f64,
1605    registered_at: f64,
1606}
1607
1608impl From<PgWorkerRow> for WorkflowWorker {
1609    fn from(r: PgWorkerRow) -> Self {
1610        Self {
1611            id: r.id,
1612            namespace: r.namespace,
1613            identity: r.identity,
1614            task_queue: r.task_queue,
1615            workflows: r.workflows,
1616            activities: r.activities,
1617            max_concurrent_workflows: r.max_concurrent_workflows,
1618            max_concurrent_activities: r.max_concurrent_activities,
1619            active_tasks: r.active_tasks,
1620            last_heartbeat: r.last_heartbeat,
1621            registered_at: r.registered_at,
1622        }
1623    }
1624}
1625
1626#[cfg(test)]
1627mod tests {
1628    use super::*;
1629
1630    #[test]
1631    fn sanitise_schema_keeps_statements_intact() {
1632        let input = "CREATE TABLE foo (x INT);\nCREATE INDEX idx_foo ON foo(x);\n";
1633        let out = sanitise_schema(input);
1634        assert_eq!(out.len(), 2);
1635        assert!(out[0].starts_with("CREATE TABLE foo"));
1636        assert!(out[1].starts_with("CREATE INDEX idx_foo"));
1637    }
1638
1639    #[test]
1640    fn sanitise_schema_drops_pure_comment_lines() {
1641        let input = "-- header comment\nCREATE TABLE foo (x INT);\n-- trailing comment\n";
1642        let out = sanitise_schema(input);
1643        assert_eq!(out.len(), 1);
1644        assert!(out[0].starts_with("CREATE TABLE foo"));
1645    }
1646
1647    #[test]
1648    fn sanitise_schema_ignores_semicolons_inside_comment_prose() {
1649        // Regression: the exact shape that broke v0.11.3–v0.11.5 in production.
1650        // `-- foo; bar` used to split into "foo" and " bar" fragments, the second
1651        // of which was executed as SQL and rejected with `syntax error at or near "bar"`.
1652        let input = "\
1653CREATE TABLE foo (x INT);
1654-- Idempotent across startups; fresh installs pick the column up from the
1655-- CREATE TABLE above so the ADD is a no-op.
1656";
1657        let out = sanitise_schema(input);
1658        assert_eq!(
1659            out.len(),
1660            1,
1661            "expected 1 real statement, got {}: {:?}",
1662            out.len(),
1663            out
1664        );
1665        assert!(out[0].starts_with("CREATE TABLE foo"));
1666    }
1667
1668    #[test]
1669    fn sanitise_schema_drops_indented_comment_lines() {
1670        let input = "  -- indented comment\n\tCREATE TABLE foo (x INT);\n";
1671        let out = sanitise_schema(input);
1672        assert_eq!(out.len(), 1);
1673        assert!(out[0].contains("CREATE TABLE foo"));
1674    }
1675
1676    #[test]
1677    fn sanitise_schema_real_constant_produces_only_ddl() {
1678        // The real SCHEMA constant must not produce any statement whose first
1679        // token isn't a recognised SQL keyword. A prose fragment leaking in
1680        // (e.g. "fresh installs...") means the filter regressed.
1681        for stmt in sanitise_schema(SCHEMA) {
1682            let first_word = stmt
1683                .split_whitespace()
1684                .next()
1685                .expect("non-empty statement")
1686                .to_uppercase();
1687            assert!(
1688                matches!(
1689                    first_word.as_str(),
1690                    "CREATE" | "INSERT" | "UPDATE" | "DROP" | "ALTER" | "WITH"
1691                ),
1692                "SCHEMA produced non-DDL statement starting with {first_word:?}: {stmt:?}"
1693            );
1694        }
1695    }
1696}