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