Skip to main content

assay_workflow/store/
sqlite.rs

1use anyhow::Result;
2use sqlx::SqlitePool;
3use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
4
5use crate::store::{NamespaceRecord, NamespaceStats, QueueStats, WorkflowStore};
6use crate::types::*;
7
8/// Workflow-module DDL. v0.1.2 schema-qualifies every table to the
9/// `workflow` schema, which on SQLite is an attached database (one
10/// `workflow.db` file per data dir, attached on connect). On PG the
11/// same DDL targets the `workflow` schema.
12///
13/// `engine.events` and `engine.lock` (engine-core infrastructure) live
14/// in the `engine` attachment; engine-core DDL is owned by
15/// `assay_domain::engine::SqliteEngineSchema`. We still bootstrap them
16/// here for v0.1.2 because the workflow store is the embedder for the
17/// `engine.events` notification outbox and for the SQLite single-instance
18/// lock — both pre-date the engine-core schema and stay co-located on
19/// SQLite to keep `SqliteStore::new(url)` self-sufficient for tests.
20const SCHEMA: &str = r#"
21CREATE TABLE IF NOT EXISTS workflow.namespaces (
22    name            TEXT PRIMARY KEY,
23    created_at      REAL NOT NULL
24);
25
26INSERT OR IGNORE INTO workflow.namespaces (name, created_at)
27    VALUES ('main', strftime('%s', 'now'));
28
29CREATE TABLE IF NOT EXISTS workflow.workflows (
30    id              TEXT PRIMARY KEY,
31    namespace       TEXT NOT NULL DEFAULT 'main',
32    run_id          TEXT NOT NULL,
33    workflow_type   TEXT NOT NULL,
34    task_queue      TEXT NOT NULL DEFAULT 'main',
35    status          TEXT NOT NULL DEFAULT 'PENDING',
36    input           TEXT,
37    result          TEXT,
38    error           TEXT,
39    parent_id       TEXT,
40    claimed_by      TEXT,
41    search_attributes TEXT,
42    archived_at     REAL,
43    archive_uri     TEXT,
44    -- Workflow-task dispatch (Phase 9): a workflow is "dispatchable" when
45    -- it has new events a worker needs to replay against. Set true on
46    -- start, on activity completion, on timer fire, on signal arrival.
47    -- Cleared when a worker claims the dispatch lease.
48    needs_dispatch  INTEGER NOT NULL DEFAULT 0,
49    dispatch_claimed_by    TEXT,
50    dispatch_last_heartbeat REAL,
51    created_at      REAL NOT NULL,
52    updated_at      REAL NOT NULL,
53    completed_at    REAL
54);
55CREATE INDEX IF NOT EXISTS workflow.idx_wf_status_queue ON workflows(status, task_queue);
56CREATE INDEX IF NOT EXISTS workflow.idx_wf_namespace ON workflows(namespace);
57CREATE INDEX IF NOT EXISTS workflow.idx_wf_dispatch ON workflows(task_queue, needs_dispatch, dispatch_claimed_by);
58
59CREATE TABLE IF NOT EXISTS workflow.events (
60    id              INTEGER PRIMARY KEY AUTOINCREMENT,
61    workflow_id     TEXT NOT NULL REFERENCES workflows(id),
62    seq             INTEGER NOT NULL,
63    event_type      TEXT NOT NULL,
64    payload         TEXT,
65    timestamp       REAL NOT NULL
66);
67CREATE INDEX IF NOT EXISTS workflow.idx_wf_events_lookup ON events(workflow_id, seq);
68
69CREATE TABLE IF NOT EXISTS workflow.activities (
70    id              INTEGER PRIMARY KEY AUTOINCREMENT,
71    workflow_id     TEXT NOT NULL REFERENCES workflows(id),
72    seq             INTEGER NOT NULL,
73    name            TEXT NOT NULL,
74    task_queue      TEXT NOT NULL DEFAULT 'main',
75    input           TEXT,
76    status          TEXT NOT NULL DEFAULT 'PENDING',
77    result          TEXT,
78    error           TEXT,
79    attempt         INTEGER NOT NULL DEFAULT 1,
80    max_attempts    INTEGER NOT NULL DEFAULT 3,
81    initial_interval_secs   REAL NOT NULL DEFAULT 1,
82    backoff_coefficient     REAL NOT NULL DEFAULT 2,
83    start_to_close_secs     REAL NOT NULL DEFAULT 300,
84    heartbeat_timeout_secs  REAL,
85    claimed_by      TEXT,
86    scheduled_at    REAL NOT NULL,
87    started_at      REAL,
88    completed_at    REAL,
89    last_heartbeat  REAL,
90    UNIQUE (workflow_id, seq)
91);
92CREATE INDEX IF NOT EXISTS workflow.idx_wf_act_pending ON activities(task_queue, status, scheduled_at);
93
94CREATE TABLE IF NOT EXISTS workflow.timers (
95    id              INTEGER PRIMARY KEY AUTOINCREMENT,
96    workflow_id     TEXT NOT NULL REFERENCES workflows(id),
97    seq             INTEGER NOT NULL,
98    fire_at         REAL NOT NULL,
99    fired           INTEGER NOT NULL DEFAULT 0,
100    UNIQUE (workflow_id, seq)
101);
102CREATE INDEX IF NOT EXISTS workflow.idx_wf_timers_due ON timers(fire_at);
103
104CREATE TABLE IF NOT EXISTS workflow.signals (
105    id              INTEGER PRIMARY KEY AUTOINCREMENT,
106    workflow_id     TEXT NOT NULL REFERENCES workflows(id),
107    name            TEXT NOT NULL,
108    payload         TEXT,
109    consumed        INTEGER NOT NULL DEFAULT 0,
110    received_at     REAL NOT NULL
111);
112CREATE INDEX IF NOT EXISTS workflow.idx_wf_signals_lookup ON signals(workflow_id, name, consumed);
113
114CREATE TABLE IF NOT EXISTS workflow.schedules (
115    name            TEXT NOT NULL,
116    namespace       TEXT NOT NULL DEFAULT 'main',
117    workflow_type   TEXT NOT NULL,
118    cron_expr       TEXT NOT NULL,
119    timezone        TEXT NOT NULL DEFAULT 'UTC',
120    input           TEXT,
121    task_queue      TEXT NOT NULL DEFAULT 'main',
122    overlap_policy  TEXT NOT NULL DEFAULT 'skip',
123    paused          INTEGER NOT NULL DEFAULT 0,
124    last_run_at     REAL,
125    next_run_at     REAL,
126    last_workflow_id TEXT,
127    created_at      REAL NOT NULL,
128    PRIMARY KEY (namespace, name)
129);
130
131CREATE TABLE IF NOT EXISTS workflow.workers (
132    id              TEXT PRIMARY KEY,
133    namespace       TEXT NOT NULL DEFAULT 'main',
134    identity        TEXT NOT NULL,
135    task_queue      TEXT NOT NULL,
136    workflows       TEXT,
137    activities      TEXT,
138    max_concurrent_workflows  INTEGER NOT NULL DEFAULT 10,
139    max_concurrent_activities INTEGER NOT NULL DEFAULT 10,
140    active_tasks    INTEGER NOT NULL DEFAULT 0,
141    last_heartbeat  REAL NOT NULL,
142    registered_at   REAL NOT NULL
143);
144
145CREATE TABLE IF NOT EXISTS workflow.snapshots (
146    workflow_id     TEXT NOT NULL REFERENCES workflows(id),
147    event_seq       INTEGER NOT NULL,
148    state_json      TEXT NOT NULL,
149    created_at      REAL NOT NULL,
150    PRIMARY KEY (workflow_id, event_seq)
151);
152
153-- workflow.api_keys retired in plan-15 slice 3 (auth tokens come from
154-- the auth module).
155DROP TABLE IF EXISTS workflow.api_keys;
156
157CREATE TABLE IF NOT EXISTS engine.lock (
158    id              INTEGER PRIMARY KEY CHECK (id = 1),
159    instance_id     TEXT NOT NULL,
160    started_at      REAL NOT NULL,
161    last_heartbeat  REAL NOT NULL
162);
163
164CREATE TABLE IF NOT EXISTS engine.events (
165    id              INTEGER PRIMARY KEY AUTOINCREMENT,
166    ts              REAL NOT NULL DEFAULT (CAST(strftime('%s','now') AS REAL)),
167    namespace       TEXT NOT NULL,
168    subsystem       TEXT NOT NULL,
169    kind            TEXT NOT NULL,
170    payload         TEXT NOT NULL DEFAULT '{}'
171);
172CREATE INDEX IF NOT EXISTS engine.idx_engine_events_ns_id ON events(namespace, id);
173CREATE INDEX IF NOT EXISTS engine.idx_engine_events_ts_prune ON events(ts);
174"#;
175
176/// Stale lock timeout — if the lock holder hasn't heartbeated in this
177/// many seconds, assume it's dead and allow takeover.
178const LOCK_STALE_SECS: f64 = 60.0;
179/// How often to refresh the lock heartbeat.
180const LOCK_HEARTBEAT_SECS: u64 = 15;
181
182/// `Clone` is derived because the underlying `SqlitePool` is itself
183/// `Clone` (it's `Arc<PoolInner>` internally) — cloning the store hands
184/// back a new wrapper around the same connection pool. The
185/// `instance_id` is per-store identity (heartbeat row tag), shared
186/// across clones so all clones look like the same instance to
187/// `engine.lock`.
188#[derive(Clone)]
189pub struct SqliteStore {
190    pool: SqlitePool,
191    instance_id: String,
192}
193
194/// Build a fresh [`SqlitePool`] with `engine` + `workflow` ATTACHed to
195/// in-memory shared-cache databases (one alias per pool). Each connection
196/// in the pool inherits the same ATTACHed databases via `after_connect`.
197///
198/// This is the test-friendly path for `SqliteStore::new(url)` callers that
199/// pass `sqlite::memory:` or any path-based URL — every connection sees
200/// the same `engine.*` / `workflow.*` data because the shared-cache URI
201/// pins the in-memory DB to a process-global name.
202///
203/// Production embedders (the engine binary) build their own pool with
204/// file-backed ATTACHes (`<data_dir>/engine.db`, `<data_dir>/workflow.db`)
205/// and call [`SqliteStore::from_attached_pool`] instead.
206async fn build_default_pool(url: &str) -> Result<SqlitePool> {
207    use std::str::FromStr;
208    use std::sync::atomic::{AtomicU64, Ordering};
209
210    static SEQ: AtomicU64 = AtomicU64::new(0);
211    let suffix = format!(
212        "{}_{}",
213        std::process::id(),
214        SEQ.fetch_add(1, Ordering::Relaxed)
215    );
216    let engine_alias = format!("file:assay_engine_{suffix}?mode=memory&cache=shared");
217    let workflow_alias = format!("file:assay_workflow_{suffix}?mode=memory&cache=shared");
218
219    let opts = SqliteConnectOptions::from_str(url)?.create_if_missing(true);
220
221    let pool = SqlitePoolOptions::new()
222        .max_connections(1)
223        .after_connect(move |conn, _meta| {
224            let engine_alias = engine_alias.clone();
225            let workflow_alias = workflow_alias.clone();
226            Box::pin(async move {
227                use sqlx::Executor;
228                conn.execute(format!("ATTACH DATABASE '{engine_alias}' AS engine").as_str())
229                    .await?;
230                conn.execute(format!("ATTACH DATABASE '{workflow_alias}' AS workflow").as_str())
231                    .await?;
232                Ok(())
233            })
234        })
235        .connect_with(opts)
236        .await?;
237    Ok(pool)
238}
239
240impl SqliteStore {
241    /// Open a SqliteStore at `url`. Provisions an in-memory `engine` +
242    /// `workflow` ATTACH automatically — convenient for tests and
243    /// embedders that don't need persistent module isolation. Production
244    /// deployments use [`SqliteStore::from_attached_pool`] with the
245    /// engine-controlled pool that ATTACHes to `<data_dir>/*.db` files.
246    pub async fn new(url: &str) -> Result<Self> {
247        let pool = build_default_pool(url).await?;
248        Self::from_attached_pool(pool).await
249    }
250
251    /// Construct from an externally-managed pool that already has the
252    /// `engine` and `workflow` databases ATTACHed. The engine binary
253    /// uses this — its pool's `after_connect` hook ATTACHes the
254    /// per-module file paths from `[backend].data_dir`.
255    pub async fn from_attached_pool(pool: SqlitePool) -> Result<Self> {
256        let instance_id = format!("assay-{:016x}", {
257            use std::collections::hash_map::DefaultHasher;
258            use std::hash::{Hash, Hasher};
259            let mut h = DefaultHasher::new();
260            std::time::SystemTime::now().hash(&mut h);
261            std::process::id().hash(&mut h);
262            h.finish()
263        });
264        let store = Self { pool, instance_id };
265        store.migrate().await?;
266        Ok(store)
267    }
268
269    /// Backward-compat alias for [`SqliteStore::from_attached_pool`].
270    /// Older call sites passed a bare pool from `SqlitePool::connect()`;
271    /// after v0.1.2 the pool must already have the engine + workflow
272    /// databases attached. The implementation is identical, kept under
273    /// the legacy name so external embedders don't break on upgrade.
274    pub async fn from_pool(pool: SqlitePool) -> Result<Self> {
275        Self::from_attached_pool(pool).await
276    }
277
278    /// Expose the underlying pool (used by the engine to build an
279    /// `SqliteEngineEventBus` that shares the same connection).
280    pub fn pool(&self) -> &SqlitePool {
281        &self.pool
282    }
283
284    /// Acquire the single-instance engine lock.
285    /// Returns an error if another instance is already running.
286    pub async fn acquire_engine_lock(&self) -> Result<()> {
287        let now = timestamp_now();
288
289        // Try to insert the lock
290        let result = sqlx::query(
291            "INSERT INTO engine.lock (id, instance_id, started_at, last_heartbeat) VALUES (1, ?, ?, ?)",
292        )
293        .bind(&self.instance_id)
294        .bind(now)
295        .bind(now)
296        .execute(&self.pool)
297        .await;
298
299        match result {
300            Ok(_) => Ok(()),
301            Err(_) => {
302                // Lock exists — check if it's stale
303                let row: Option<(String, f64)> = sqlx::query_as(
304                    "SELECT instance_id, last_heartbeat FROM engine.lock WHERE id = 1",
305                )
306                .fetch_optional(&self.pool)
307                .await?;
308
309                if let Some((existing_id, last_hb)) = row {
310                    if now - last_hb > LOCK_STALE_SECS {
311                        // Stale lock — take over
312                        sqlx::query(
313                            "UPDATE engine.lock SET instance_id = ?, started_at = ?, last_heartbeat = ? WHERE id = 1",
314                        )
315                        .bind(&self.instance_id)
316                        .bind(now)
317                        .bind(now)
318                        .execute(&self.pool)
319                        .await?;
320                        tracing::warn!(
321                            "Took over stale engine lock from {existing_id} (last heartbeat {:.0}s ago)",
322                            now - last_hb
323                        );
324                        Ok(())
325                    } else {
326                        let age = now - last_hb;
327                        anyhow::bail!(
328                            "Another assay engine instance is already running (id: {existing_id}, \
329                             last heartbeat {age:.0}s ago).\n\n\
330                             SQLite only supports a single engine instance. For multi-instance \
331                             deployment (Kubernetes, Docker Swarm), use PostgreSQL:\n\n\
332                             \x20 assay serve --backend postgres://user:pass@host:5432/dbname"
333                        );
334                    }
335                } else {
336                    anyhow::bail!("Unexpected engine lock state");
337                }
338            }
339        }
340    }
341
342    /// Refresh the engine lock heartbeat. Called periodically by the engine.
343    pub async fn refresh_engine_lock(&self) -> Result<()> {
344        sqlx::query("UPDATE engine.lock SET last_heartbeat = ? WHERE id = 1 AND instance_id = ?")
345            .bind(timestamp_now())
346            .bind(&self.instance_id)
347            .execute(&self.pool)
348            .await?;
349        Ok(())
350    }
351
352    /// Release the engine lock on shutdown.
353    pub async fn release_engine_lock(&self) -> Result<()> {
354        sqlx::query("DELETE FROM engine.lock WHERE id = 1 AND instance_id = ?")
355            .bind(&self.instance_id)
356            .execute(&self.pool)
357            .await?;
358        Ok(())
359    }
360
361    /// Start background task to keep the lock alive.
362    pub fn spawn_lock_heartbeat(self: &std::sync::Arc<Self>) {
363        let store = std::sync::Arc::clone(self);
364        tokio::spawn(async move {
365            let mut tick =
366                tokio::time::interval(std::time::Duration::from_secs(LOCK_HEARTBEAT_SECS));
367            loop {
368                tick.tick().await;
369                if let Err(e) = store.refresh_engine_lock().await {
370                    tracing::error!("Engine lock heartbeat failed: {e}");
371                }
372            }
373        });
374    }
375
376    /// Apply the baseline schema. SCHEMA's `CREATE TABLE IF NOT EXISTS`
377    /// statements are the source of truth — pre-1.0 we don't carry
378    /// `ALTER TABLE ADD COLUMN` history. For additive migrations later,
379    /// chain a `Self::add_column_if_missing(&self.pool, "<table>",
380    /// "<column>", "<type_def>")` call here before returning.
381    async fn migrate(&self) -> Result<()> {
382        for statement in SCHEMA.split(';') {
383            let trimmed = statement.trim();
384            if !trimmed.is_empty() {
385                sqlx::query(trimmed).execute(&self.pool).await?;
386            }
387        }
388        // Future additive migrations go here; see doc-comment above.
389        Ok(())
390    }
391
392    /// Add a column to an existing table if it's not already there.
393    ///
394    /// SQLite (unlike Postgres) doesn't support `ADD COLUMN IF NOT EXISTS`,
395    /// so we check via `pragma_table_info` before issuing the ALTER. Each
396    /// call is idempotent across startups.
397    ///
398    /// Currently unused — kept as the documented pattern for the first
399    /// additive migration after v0.11.3. Remove `#[allow(dead_code)]` when
400    /// a caller is added.
401    #[allow(dead_code)]
402    async fn add_column_if_missing(
403        pool: &SqlitePool,
404        table: &str,
405        column: &str,
406        type_def: &str,
407    ) -> Result<()> {
408        let exists: Option<(String,)> =
409            sqlx::query_as("SELECT name FROM pragma_table_info(?) WHERE name = ?")
410                .bind(table)
411                .bind(column)
412                .fetch_optional(pool)
413                .await?;
414        if exists.is_none() {
415            let sql = format!("ALTER TABLE {table} ADD COLUMN {column} {type_def}");
416            sqlx::query(&sql).execute(pool).await?;
417        }
418        Ok(())
419    }
420}
421
422impl WorkflowStore for SqliteStore {
423    // ── Namespaces ─────────────────────────────────────────
424
425    async fn create_namespace(&self, name: &str) -> Result<()> {
426        sqlx::query("INSERT INTO workflow.namespaces (name, created_at) VALUES (?, ?)")
427            .bind(name)
428            .bind(timestamp_now())
429            .execute(&self.pool)
430            .await?;
431        Ok(())
432    }
433
434    async fn list_namespaces(&self) -> Result<Vec<NamespaceRecord>> {
435        let rows = sqlx::query_as::<_, (String, f64)>(
436            "SELECT name, created_at FROM workflow.namespaces ORDER BY name",
437        )
438        .fetch_all(&self.pool)
439        .await?;
440        Ok(rows
441            .into_iter()
442            .map(|(name, created_at)| NamespaceRecord { name, created_at })
443            .collect())
444    }
445
446    async fn delete_namespace(&self, name: &str) -> Result<bool> {
447        // Mirror PG: 'main' is always available, can't be deleted.
448        let res = sqlx::query("DELETE FROM workflow.namespaces WHERE name = ? AND name != 'main'")
449            .bind(name)
450            .execute(&self.pool)
451            .await?;
452        Ok(res.rows_affected() > 0)
453    }
454
455    async fn get_namespace_stats(&self, namespace: &str) -> Result<NamespaceStats> {
456        let total: (i64,) =
457            sqlx::query_as("SELECT COUNT(*) FROM workflow.workflows WHERE namespace = ?")
458                .bind(namespace)
459                .fetch_one(&self.pool)
460                .await?;
461        let running: (i64,) = sqlx::query_as(
462            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = ? AND status = 'RUNNING'",
463        )
464        .bind(namespace)
465        .fetch_one(&self.pool)
466        .await?;
467        let pending: (i64,) = sqlx::query_as(
468            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = ? AND status = 'PENDING'",
469        )
470        .bind(namespace)
471        .fetch_one(&self.pool)
472        .await?;
473        let completed: (i64,) = sqlx::query_as(
474            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = ? AND status = 'COMPLETED'",
475        )
476        .bind(namespace)
477        .fetch_one(&self.pool)
478        .await?;
479        let failed: (i64,) = sqlx::query_as(
480            "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = ? AND status = 'FAILED'",
481        )
482        .bind(namespace)
483        .fetch_one(&self.pool)
484        .await?;
485        let schedules: (i64,) =
486            sqlx::query_as("SELECT COUNT(*) FROM workflow.schedules WHERE namespace = ?")
487                .bind(namespace)
488                .fetch_one(&self.pool)
489                .await?;
490        let workers: (i64,) =
491            sqlx::query_as("SELECT COUNT(*) FROM workflow.workers WHERE namespace = ?")
492                .bind(namespace)
493                .fetch_one(&self.pool)
494                .await?;
495
496        Ok(NamespaceStats {
497            namespace: namespace.to_string(),
498            total_workflows: total.0,
499            running: running.0,
500            pending: pending.0,
501            completed: completed.0,
502            failed: failed.0,
503            schedules: schedules.0,
504            workers: workers.0,
505        })
506    }
507
508    // ── Workflows ──────────────────────────────────────────
509
510    async fn create_workflow(&self, wf: &WorkflowRecord) -> Result<()> {
511        sqlx::query(
512            "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)
513             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
514        )
515        .bind(&wf.id)
516        .bind(&wf.namespace)
517        .bind(&wf.run_id)
518        .bind(&wf.workflow_type)
519        .bind(&wf.task_queue)
520        .bind(&wf.status)
521        .bind(&wf.input)
522        .bind(&wf.result)
523        .bind(&wf.error)
524        .bind(&wf.parent_id)
525        .bind(&wf.claimed_by)
526        .bind(&wf.search_attributes)
527        .bind(wf.archived_at)
528        .bind(&wf.archive_uri)
529        .bind(wf.created_at)
530        .bind(wf.updated_at)
531        .bind(wf.completed_at)
532        .execute(&self.pool)
533        .await?;
534        Ok(())
535    }
536
537    async fn get_workflow(&self, id: &str) -> Result<Option<WorkflowRecord>> {
538        let row = sqlx::query_as::<_, SqliteWorkflowRow>(
539            "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 = ?",
540        )
541        .bind(id)
542        .fetch_optional(&self.pool)
543        .await?;
544        Ok(row.map(Into::into))
545    }
546
547    async fn list_workflows(
548        &self,
549        namespace: &str,
550        status: Option<WorkflowStatus>,
551        workflow_type: Option<&str>,
552        search_attrs_filter: Option<&str>,
553        limit: i64,
554        offset: i64,
555    ) -> Result<Vec<WorkflowRecord>> {
556        let status_str = status.map(|s| s.to_string());
557
558        // Parse search filter into (key, value) pairs. Each pair adds a
559        // `json_extract(search_attributes, '$.key') = value` predicate so
560        // matches require every filter key to be present in the stored
561        // attributes. Invalid/empty JSON → no filter (all pass).
562        let filter_pairs: Vec<(String, serde_json::Value)> = search_attrs_filter
563            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
564            .and_then(|v| v.as_object().cloned())
565            .map(|m| m.into_iter().collect())
566            .unwrap_or_default();
567
568        let mut sql = String::from(
569            "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
570             FROM workflow.workflows
571             WHERE namespace = ?
572               AND (? IS NULL OR status = ?)
573               AND (? IS NULL OR workflow_type = ?)",
574        );
575        for _ in &filter_pairs {
576            sql.push_str(" AND json_extract(search_attributes, '$.' || ?) = ?");
577        }
578        sql.push_str(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
579
580        let mut q = sqlx::query_as::<_, SqliteWorkflowRow>(&sql)
581            .bind(namespace)
582            .bind(&status_str)
583            .bind(&status_str)
584            .bind(workflow_type)
585            .bind(workflow_type);
586        for (key, value) in &filter_pairs {
587            q = q.bind(key.clone());
588            // Bind the JSON value as its string/number representation.
589            // json_extract on a stored JSON string returns its "natural"
590            // SQLite type (text for strings, numeric for numbers), so we
591            // match by the same type.
592            match value {
593                serde_json::Value::String(s) => q = q.bind(s.clone()),
594                serde_json::Value::Number(n) => {
595                    if let Some(i) = n.as_i64() {
596                        q = q.bind(i);
597                    } else if let Some(f) = n.as_f64() {
598                        q = q.bind(f);
599                    } else {
600                        q = q.bind(n.to_string());
601                    }
602                }
603                serde_json::Value::Bool(b) => q = q.bind(*b as i64),
604                _ => q = q.bind(value.to_string()),
605            }
606        }
607        let rows = q.bind(limit).bind(offset).fetch_all(&self.pool).await?;
608        Ok(rows.into_iter().map(Into::into).collect())
609    }
610
611    async fn update_workflow_status(
612        &self,
613        id: &str,
614        status: WorkflowStatus,
615        result: Option<&str>,
616        error: Option<&str>,
617    ) -> Result<()> {
618        let now = timestamp_now();
619        let completed_at = if status.is_terminal() {
620            Some(now)
621        } else {
622            None
623        };
624        sqlx::query(
625            "UPDATE workflow.workflows SET status = ?, result = COALESCE(?, result), error = COALESCE(?, error), updated_at = ?, completed_at = COALESCE(?, completed_at) WHERE id = ?",
626        )
627        .bind(status.to_string())
628        .bind(result)
629        .bind(error)
630        .bind(now)
631        .bind(completed_at)
632        .bind(id)
633        .execute(&self.pool)
634        .await?;
635        Ok(())
636    }
637
638    async fn claim_workflow(&self, id: &str, worker_id: &str) -> Result<bool> {
639        let res = sqlx::query(
640            "UPDATE workflow.workflows SET claimed_by = ?, status = 'RUNNING', updated_at = ? WHERE id = ? AND claimed_by IS NULL",
641        )
642        .bind(worker_id)
643        .bind(timestamp_now())
644        .bind(id)
645        .execute(&self.pool)
646        .await?;
647        Ok(res.rows_affected() > 0)
648    }
649
650    async fn mark_workflow_dispatchable(&self, workflow_id: &str) -> Result<()> {
651        sqlx::query("UPDATE workflow.workflows SET needs_dispatch = 1 WHERE id = ?")
652            .bind(workflow_id)
653            .execute(&self.pool)
654            .await?;
655        Ok(())
656    }
657
658    async fn claim_workflow_task(
659        &self,
660        task_queue: &str,
661        worker_id: &str,
662    ) -> Result<Option<WorkflowRecord>> {
663        let now = timestamp_now();
664        // Atomic: pick the oldest dispatchable + unclaimed workflow on the queue
665        let row = sqlx::query_as::<_, SqliteWorkflowRow>(
666            "UPDATE workflow.workflows
667             SET dispatch_claimed_by = ?, dispatch_last_heartbeat = ?, needs_dispatch = 0
668             WHERE id = (
669                SELECT id FROM workflow.workflows
670                WHERE task_queue = ?
671                  AND needs_dispatch = 1
672                  AND dispatch_claimed_by IS NULL
673                  AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
674                ORDER BY updated_at ASC
675                LIMIT 1
676             )
677             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",
678        )
679        .bind(worker_id)
680        .bind(now)
681        .bind(task_queue)
682        .fetch_optional(&self.pool)
683        .await?;
684        Ok(row.map(Into::into))
685    }
686
687    async fn release_workflow_task(&self, workflow_id: &str, worker_id: &str) -> Result<()> {
688        sqlx::query(
689            "UPDATE workflow.workflows
690             SET dispatch_claimed_by = NULL, dispatch_last_heartbeat = NULL
691             WHERE id = ? AND dispatch_claimed_by = ?",
692        )
693        .bind(workflow_id)
694        .bind(worker_id)
695        .execute(&self.pool)
696        .await?;
697        Ok(())
698    }
699
700    async fn release_stale_dispatch_leases(&self, now: f64, timeout_secs: f64) -> Result<u64> {
701        // Re-arm needs_dispatch so the work goes back into the pool. Don't
702        // touch workflows that have reached a terminal state — those should
703        // never be re-dispatched.
704        let res = sqlx::query(
705            "UPDATE workflow.workflows
706             SET dispatch_claimed_by = NULL,
707                 dispatch_last_heartbeat = NULL,
708                 needs_dispatch = 1
709             WHERE dispatch_claimed_by IS NOT NULL
710               AND (? - dispatch_last_heartbeat) > ?
711               AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')",
712        )
713        .bind(now)
714        .bind(timeout_secs)
715        .execute(&self.pool)
716        .await?;
717        Ok(res.rows_affected())
718    }
719
720    // ── Events ─────────────────────────────────────────────
721
722    async fn append_event(&self, ev: &WorkflowEvent) -> Result<i64> {
723        let res = sqlx::query(
724            "INSERT INTO workflow.events (workflow_id, seq, event_type, payload, timestamp) VALUES (?, ?, ?, ?, ?)",
725        )
726        .bind(&ev.workflow_id)
727        .bind(ev.seq)
728        .bind(&ev.event_type)
729        .bind(&ev.payload)
730        .bind(ev.timestamp)
731        .execute(&self.pool)
732        .await?;
733        Ok(res.last_insert_rowid())
734    }
735
736    async fn list_events(&self, workflow_id: &str) -> Result<Vec<WorkflowEvent>> {
737        let rows = sqlx::query_as::<_, SqliteEventRow>(
738            "SELECT id, workflow_id, seq, event_type, payload, timestamp FROM workflow.events WHERE workflow_id = ? ORDER BY seq ASC",
739        )
740        .bind(workflow_id)
741        .fetch_all(&self.pool)
742        .await?;
743        Ok(rows.into_iter().map(Into::into).collect())
744    }
745
746    async fn get_event_count(&self, workflow_id: &str) -> Result<i64> {
747        let row: (i64,) =
748            sqlx::query_as("SELECT COUNT(*) FROM workflow.events WHERE workflow_id = ?")
749                .bind(workflow_id)
750                .fetch_one(&self.pool)
751                .await?;
752        Ok(row.0)
753    }
754
755    // ── Activities ──────────────────────────────────────────
756
757    async fn create_activity(&self, act: &WorkflowActivity) -> Result<i64> {
758        let res = sqlx::query(
759            "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)
760             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
761        )
762        .bind(&act.workflow_id)
763        .bind(act.seq)
764        .bind(&act.name)
765        .bind(&act.task_queue)
766        .bind(&act.input)
767        .bind(&act.status)
768        .bind(act.attempt)
769        .bind(act.max_attempts)
770        .bind(act.initial_interval_secs)
771        .bind(act.backoff_coefficient)
772        .bind(act.start_to_close_secs)
773        .bind(act.heartbeat_timeout_secs)
774        .bind(act.scheduled_at)
775        .execute(&self.pool)
776        .await?;
777        Ok(res.last_insert_rowid())
778    }
779
780    async fn get_activity(&self, id: i64) -> Result<Option<WorkflowActivity>> {
781        let row = sqlx::query_as::<_, SqliteActivityRow>(
782            "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
783             FROM workflow.activities WHERE id = ?",
784        )
785        .bind(id)
786        .fetch_optional(&self.pool)
787        .await?;
788        Ok(row.map(Into::into))
789    }
790
791    async fn get_activity_by_workflow_seq(
792        &self,
793        workflow_id: &str,
794        seq: i32,
795    ) -> Result<Option<WorkflowActivity>> {
796        let row = sqlx::query_as::<_, SqliteActivityRow>(
797            "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
798             FROM workflow.activities WHERE workflow_id = ? AND seq = ?",
799        )
800        .bind(workflow_id)
801        .bind(seq)
802        .fetch_optional(&self.pool)
803        .await?;
804        Ok(row.map(Into::into))
805    }
806
807    async fn claim_activity(
808        &self,
809        task_queue: &str,
810        worker_id: &str,
811    ) -> Result<Option<WorkflowActivity>> {
812        let now = timestamp_now();
813        let row = sqlx::query_as::<_, SqliteActivityRow>(
814            "UPDATE workflow.activities SET status = 'RUNNING', claimed_by = ?, started_at = ?
815             WHERE id = (
816                SELECT id FROM workflow.activities
817                WHERE task_queue = ? AND status = 'PENDING'
818                ORDER BY scheduled_at ASC
819                LIMIT 1
820             )
821             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",
822        )
823        .bind(worker_id)
824        .bind(now)
825        .bind(task_queue)
826        .fetch_optional(&self.pool)
827        .await?;
828        Ok(row.map(Into::into))
829    }
830
831    async fn requeue_activity_for_retry(
832        &self,
833        id: i64,
834        next_attempt: i32,
835        next_scheduled_at: f64,
836    ) -> Result<()> {
837        sqlx::query(
838            "UPDATE workflow.activities
839             SET status = 'PENDING', attempt = ?, scheduled_at = ?,
840                 claimed_by = NULL, started_at = NULL, last_heartbeat = NULL,
841                 error = NULL
842             WHERE id = ?",
843        )
844        .bind(next_attempt)
845        .bind(next_scheduled_at)
846        .bind(id)
847        .execute(&self.pool)
848        .await?;
849        Ok(())
850    }
851
852    async fn complete_activity(
853        &self,
854        id: i64,
855        result: Option<&str>,
856        error: Option<&str>,
857        failed: bool,
858    ) -> Result<()> {
859        let status = if failed { "FAILED" } else { "COMPLETED" };
860        sqlx::query(
861            "UPDATE workflow.activities SET status = ?, result = ?, error = ?, completed_at = ? WHERE id = ?",
862        )
863        .bind(status)
864        .bind(result)
865        .bind(error)
866        .bind(timestamp_now())
867        .bind(id)
868        .execute(&self.pool)
869        .await?;
870        Ok(())
871    }
872
873    async fn heartbeat_activity(&self, id: i64, _details: Option<&str>) -> Result<()> {
874        sqlx::query("UPDATE workflow.activities SET last_heartbeat = ? WHERE id = ?")
875            .bind(timestamp_now())
876            .bind(id)
877            .execute(&self.pool)
878            .await?;
879        Ok(())
880    }
881
882    async fn get_timed_out_activities(&self, now: f64) -> Result<Vec<WorkflowActivity>> {
883        let rows = sqlx::query_as::<_, SqliteActivityRow>(
884            "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
885             FROM workflow.activities
886             WHERE status = 'RUNNING'
887               AND heartbeat_timeout_secs IS NOT NULL
888               AND (? - COALESCE(last_heartbeat, started_at)) > heartbeat_timeout_secs",
889        )
890        .bind(now)
891        .fetch_all(&self.pool)
892        .await?;
893        Ok(rows.into_iter().map(Into::into).collect())
894    }
895
896    // ── Timers ──────────────────────────────────────────────
897
898    async fn create_timer(&self, timer: &WorkflowTimer) -> Result<i64> {
899        // Idempotent: INSERT OR IGNORE on UNIQUE (workflow_id, seq).
900        // If the row already existed, last_insert_rowid() is 0 — fall back to SELECT.
901        let res = sqlx::query(
902            "INSERT OR IGNORE INTO workflow.timers (workflow_id, seq, fire_at, fired) VALUES (?, ?, ?, 0)",
903        )
904        .bind(&timer.workflow_id)
905        .bind(timer.seq)
906        .bind(timer.fire_at)
907        .execute(&self.pool)
908        .await?;
909
910        let id = res.last_insert_rowid();
911        if id != 0 {
912            return Ok(id);
913        }
914
915        // Row already existed — return its id.
916        let (existing_id,): (i64,) =
917            sqlx::query_as("SELECT id FROM workflow.timers WHERE workflow_id = ? AND seq = ?")
918                .bind(&timer.workflow_id)
919                .bind(timer.seq)
920                .fetch_one(&self.pool)
921                .await?;
922        Ok(existing_id)
923    }
924
925    async fn cancel_pending_activities(&self, workflow_id: &str) -> Result<u64> {
926        let res = sqlx::query(
927            "UPDATE workflow.activities SET status = 'CANCELLED', completed_at = ?
928             WHERE workflow_id = ? AND status = 'PENDING'",
929        )
930        .bind(timestamp_now())
931        .bind(workflow_id)
932        .execute(&self.pool)
933        .await?;
934        Ok(res.rows_affected())
935    }
936
937    async fn cancel_pending_timers(&self, workflow_id: &str) -> Result<u64> {
938        let res = sqlx::query(
939            "UPDATE workflow.timers SET fired = 1
940             WHERE workflow_id = ? AND fired = 0",
941        )
942        .bind(workflow_id)
943        .execute(&self.pool)
944        .await?;
945        Ok(res.rows_affected())
946    }
947
948    async fn get_timer_by_workflow_seq(
949        &self,
950        workflow_id: &str,
951        seq: i32,
952    ) -> Result<Option<WorkflowTimer>> {
953        let row = sqlx::query_as::<_, SqliteTimerRow>(
954            "SELECT id, workflow_id, seq, fire_at, fired
955             FROM workflow.timers WHERE workflow_id = ? AND seq = ?",
956        )
957        .bind(workflow_id)
958        .bind(seq)
959        .fetch_optional(&self.pool)
960        .await?;
961        Ok(row.map(Into::into))
962    }
963
964    async fn fire_due_timers(&self, now: f64) -> Result<Vec<WorkflowTimer>> {
965        let rows = sqlx::query_as::<_, SqliteTimerRow>(
966            "UPDATE workflow.timers SET fired = 1
967             WHERE fired = 0 AND fire_at <= ?
968             RETURNING id, workflow_id, seq, fire_at, fired",
969        )
970        .bind(now)
971        .fetch_all(&self.pool)
972        .await?;
973        Ok(rows.into_iter().map(Into::into).collect())
974    }
975
976    // ── Signals ─────────────────────────────────────────────
977
978    async fn send_signal(&self, sig: &WorkflowSignal) -> Result<i64> {
979        let res = sqlx::query(
980            "INSERT INTO workflow.signals (workflow_id, name, payload, consumed, received_at) VALUES (?, ?, ?, 0, ?)",
981        )
982        .bind(&sig.workflow_id)
983        .bind(&sig.name)
984        .bind(&sig.payload)
985        .bind(sig.received_at)
986        .execute(&self.pool)
987        .await?;
988        Ok(res.last_insert_rowid())
989    }
990
991    async fn consume_signals(&self, workflow_id: &str, name: &str) -> Result<Vec<WorkflowSignal>> {
992        let rows = sqlx::query_as::<_, SqliteSignalRow>(
993            "UPDATE workflow.signals SET consumed = 1
994             WHERE workflow_id = ? AND name = ? AND consumed = 0
995             RETURNING id, workflow_id, name, payload, consumed, received_at",
996        )
997        .bind(workflow_id)
998        .bind(name)
999        .fetch_all(&self.pool)
1000        .await?;
1001        Ok(rows.into_iter().map(Into::into).collect())
1002    }
1003
1004    // ── Schedules ───────────────────────────────────────────
1005
1006    async fn create_schedule(&self, sched: &WorkflowSchedule) -> Result<()> {
1007        sqlx::query(
1008            "INSERT INTO workflow.schedules (name, namespace, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at)
1009             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1010        )
1011        .bind(&sched.name)
1012        .bind(&sched.namespace)
1013        .bind(&sched.workflow_type)
1014        .bind(&sched.cron_expr)
1015        .bind(&sched.timezone)
1016        .bind(&sched.input)
1017        .bind(&sched.task_queue)
1018        .bind(&sched.overlap_policy)
1019        .bind(sched.paused)
1020        .bind(sched.last_run_at)
1021        .bind(sched.next_run_at)
1022        .bind(&sched.last_workflow_id)
1023        .bind(sched.created_at)
1024        .execute(&self.pool)
1025        .await?;
1026        Ok(())
1027    }
1028
1029    async fn get_schedule(&self, namespace: &str, name: &str) -> Result<Option<WorkflowSchedule>> {
1030        let row = sqlx::query_as::<_, SqliteScheduleRow>(
1031            "SELECT name, namespace, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at
1032             FROM workflow.schedules WHERE namespace = ? AND name = ?",
1033        )
1034        .bind(namespace)
1035        .bind(name)
1036        .fetch_optional(&self.pool)
1037        .await?;
1038        Ok(row.map(Into::into))
1039    }
1040
1041    async fn list_schedules(&self, namespace: &str) -> Result<Vec<WorkflowSchedule>> {
1042        let rows = sqlx::query_as::<_, SqliteScheduleRow>(
1043            "SELECT name, namespace, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at
1044             FROM workflow.schedules WHERE namespace = ? ORDER BY name",
1045        )
1046        .bind(namespace)
1047        .fetch_all(&self.pool)
1048        .await?;
1049        Ok(rows.into_iter().map(Into::into).collect())
1050    }
1051
1052    async fn update_schedule_last_run(
1053        &self,
1054        namespace: &str,
1055        name: &str,
1056        last_run_at: f64,
1057        next_run_at: f64,
1058        workflow_id: &str,
1059    ) -> Result<()> {
1060        sqlx::query(
1061            "UPDATE workflow.schedules SET last_run_at = ?, next_run_at = ?, last_workflow_id = ? WHERE namespace = ? AND name = ?",
1062        )
1063        .bind(last_run_at)
1064        .bind(next_run_at)
1065        .bind(workflow_id)
1066        .bind(namespace)
1067        .bind(name)
1068        .execute(&self.pool)
1069        .await?;
1070        Ok(())
1071    }
1072
1073    async fn delete_schedule(&self, namespace: &str, name: &str) -> Result<bool> {
1074        let res = sqlx::query("DELETE FROM workflow.schedules WHERE namespace = ? AND name = ?")
1075            .bind(namespace)
1076            .bind(name)
1077            .execute(&self.pool)
1078            .await?;
1079        Ok(res.rows_affected() > 0)
1080    }
1081
1082    async fn list_archivable_workflows(
1083        &self,
1084        cutoff: f64,
1085        limit: i64,
1086    ) -> Result<Vec<WorkflowRecord>> {
1087        let rows = sqlx::query_as::<_, SqliteWorkflowRow>(
1088            "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
1089             FROM workflow.workflows
1090             WHERE status IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
1091               AND completed_at IS NOT NULL
1092               AND completed_at < ?
1093               AND archived_at IS NULL
1094             ORDER BY completed_at ASC
1095             LIMIT ?",
1096        )
1097        .bind(cutoff)
1098        .bind(limit)
1099        .fetch_all(&self.pool)
1100        .await?;
1101        Ok(rows.into_iter().map(Into::into).collect())
1102    }
1103
1104    async fn mark_archived_and_purge(
1105        &self,
1106        workflow_id: &str,
1107        archive_uri: &str,
1108        archived_at: f64,
1109    ) -> Result<()> {
1110        let mut tx = self.pool.begin().await?;
1111        sqlx::query("DELETE FROM workflow.events WHERE workflow_id = ?")
1112            .bind(workflow_id)
1113            .execute(&mut *tx)
1114            .await?;
1115        sqlx::query("DELETE FROM workflow.activities WHERE workflow_id = ?")
1116            .bind(workflow_id)
1117            .execute(&mut *tx)
1118            .await?;
1119        sqlx::query("DELETE FROM workflow.timers WHERE workflow_id = ?")
1120            .bind(workflow_id)
1121            .execute(&mut *tx)
1122            .await?;
1123        sqlx::query("DELETE FROM workflow.signals WHERE workflow_id = ?")
1124            .bind(workflow_id)
1125            .execute(&mut *tx)
1126            .await?;
1127        sqlx::query("DELETE FROM workflow.snapshots WHERE workflow_id = ?")
1128            .bind(workflow_id)
1129            .execute(&mut *tx)
1130            .await?;
1131        sqlx::query("UPDATE workflow.workflows SET archived_at = ?, archive_uri = ? WHERE id = ?")
1132            .bind(archived_at)
1133            .bind(archive_uri)
1134            .bind(workflow_id)
1135            .execute(&mut *tx)
1136            .await?;
1137        tx.commit().await?;
1138        Ok(())
1139    }
1140
1141    async fn upsert_search_attributes(&self, workflow_id: &str, patch_json: &str) -> Result<()> {
1142        // Merge at the application layer so we don't depend on SQLite's
1143        // `json_patch`, which is only available with the json1 extension.
1144        let current: Option<(Option<String>,)> =
1145            sqlx::query_as("SELECT search_attributes FROM workflow.workflows WHERE id = ?")
1146                .bind(workflow_id)
1147                .fetch_optional(&self.pool)
1148                .await?;
1149        let merged = merge_search_attrs(current.and_then(|(s,)| s).as_deref(), patch_json)?;
1150        sqlx::query("UPDATE workflow.workflows SET search_attributes = ? WHERE id = ?")
1151            .bind(merged)
1152            .bind(workflow_id)
1153            .execute(&self.pool)
1154            .await?;
1155        Ok(())
1156    }
1157
1158    async fn update_schedule(
1159        &self,
1160        namespace: &str,
1161        name: &str,
1162        patch: &SchedulePatch,
1163    ) -> Result<Option<WorkflowSchedule>> {
1164        // Build the UPDATE dynamically so unchanged fields aren't touched
1165        // and NULL from `serde_json::Value::Null` round-trips cleanly.
1166        let mut sets: Vec<&'static str> = Vec::new();
1167        if patch.cron_expr.is_some() {
1168            sets.push("cron_expr = ?");
1169        }
1170        if patch.timezone.is_some() {
1171            sets.push("timezone = ?");
1172        }
1173        if patch.input.is_some() {
1174            sets.push("input = ?");
1175        }
1176        if patch.task_queue.is_some() {
1177            sets.push("task_queue = ?");
1178        }
1179        if patch.overlap_policy.is_some() {
1180            sets.push("overlap_policy = ?");
1181        }
1182        // Updating last_run_at/next_run_at is internal only (update_schedule_last_run).
1183        if sets.is_empty() {
1184            return self.get_schedule(namespace, name).await;
1185        }
1186
1187        let sql = format!(
1188            "UPDATE workflow.schedules SET {} WHERE namespace = ? AND name = ?",
1189            sets.join(", ")
1190        );
1191        let mut q = sqlx::query(&sql);
1192        if let Some(ref v) = patch.cron_expr {
1193            q = q.bind(v);
1194        }
1195        if let Some(ref v) = patch.timezone {
1196            q = q.bind(v);
1197        }
1198        if let Some(ref v) = patch.input {
1199            q = q.bind(v.to_string());
1200        }
1201        if let Some(ref v) = patch.task_queue {
1202            q = q.bind(v);
1203        }
1204        if let Some(ref v) = patch.overlap_policy {
1205            q = q.bind(v);
1206        }
1207        let res = q.bind(namespace).bind(name).execute(&self.pool).await?;
1208        if res.rows_affected() == 0 {
1209            return Ok(None);
1210        }
1211        self.get_schedule(namespace, name).await
1212    }
1213
1214    async fn set_schedule_paused(
1215        &self,
1216        namespace: &str,
1217        name: &str,
1218        paused: bool,
1219    ) -> Result<Option<WorkflowSchedule>> {
1220        let res = sqlx::query(
1221            "UPDATE workflow.schedules SET paused = ? WHERE namespace = ? AND name = ?",
1222        )
1223        .bind(paused)
1224        .bind(namespace)
1225        .bind(name)
1226        .execute(&self.pool)
1227        .await?;
1228        if res.rows_affected() == 0 {
1229            return Ok(None);
1230        }
1231        self.get_schedule(namespace, name).await
1232    }
1233
1234    // ── Workers ─────────────────────────────────────────────
1235
1236    async fn register_worker(&self, w: &WorkflowWorker) -> Result<()> {
1237        sqlx::query(
1238            "INSERT OR REPLACE INTO workflow.workers (id, namespace, identity, task_queue, workflows, activities, max_concurrent_workflows, max_concurrent_activities, active_tasks, last_heartbeat, registered_at)
1239             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1240        )
1241        .bind(&w.id)
1242        .bind(&w.namespace)
1243        .bind(&w.identity)
1244        .bind(&w.task_queue)
1245        .bind(&w.workflows)
1246        .bind(&w.activities)
1247        .bind(w.max_concurrent_workflows)
1248        .bind(w.max_concurrent_activities)
1249        .bind(w.active_tasks)
1250        .bind(w.last_heartbeat)
1251        .bind(w.registered_at)
1252        .execute(&self.pool)
1253        .await?;
1254        Ok(())
1255    }
1256
1257    async fn heartbeat_worker(&self, id: &str, now: f64) -> Result<()> {
1258        sqlx::query("UPDATE workflow.workers SET last_heartbeat = ? WHERE id = ?")
1259            .bind(now)
1260            .bind(id)
1261            .execute(&self.pool)
1262            .await?;
1263        Ok(())
1264    }
1265
1266    async fn list_workers(&self, namespace: &str) -> Result<Vec<WorkflowWorker>> {
1267        let rows = sqlx::query_as::<_, SqliteWorkerRow>(
1268            "SELECT id, namespace, identity, task_queue, workflows, activities, max_concurrent_workflows, max_concurrent_activities, active_tasks, last_heartbeat, registered_at
1269             FROM workflow.workers WHERE namespace = ? ORDER BY registered_at",
1270        )
1271        .bind(namespace)
1272        .fetch_all(&self.pool)
1273        .await?;
1274        Ok(rows.into_iter().map(Into::into).collect())
1275    }
1276
1277    async fn remove_dead_workers(&self, cutoff: f64) -> Result<Vec<String>> {
1278        let rows: Vec<(String,)> =
1279            sqlx::query_as("SELECT id FROM workflow.workers WHERE last_heartbeat < ?")
1280                .bind(cutoff)
1281                .fetch_all(&self.pool)
1282                .await?;
1283        let ids: Vec<String> = rows.into_iter().map(|r| r.0).collect();
1284        if !ids.is_empty() {
1285            sqlx::query("DELETE FROM workflow.workers WHERE last_heartbeat < ?")
1286                .bind(cutoff)
1287                .execute(&self.pool)
1288                .await?;
1289        }
1290        Ok(ids)
1291    }
1292
1293    // ── Child Workflows ─────────────────────────────────────
1294
1295    async fn list_child_workflows(&self, parent_id: &str) -> Result<Vec<WorkflowRecord>> {
1296        let rows = sqlx::query_as::<_, SqliteWorkflowRow>(
1297            "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
1298             FROM workflow.workflows WHERE parent_id = ? ORDER BY created_at ASC",
1299        )
1300        .bind(parent_id)
1301        .fetch_all(&self.pool)
1302        .await?;
1303        Ok(rows.into_iter().map(Into::into).collect())
1304    }
1305
1306    // ── Snapshots ───────────────────────────────────────────
1307
1308    async fn create_snapshot(
1309        &self,
1310        workflow_id: &str,
1311        event_seq: i32,
1312        state_json: &str,
1313    ) -> Result<()> {
1314        sqlx::query(
1315            "INSERT OR REPLACE INTO workflow.snapshots (workflow_id, event_seq, state_json, created_at)
1316             VALUES (?, ?, ?, ?)",
1317        )
1318        .bind(workflow_id)
1319        .bind(event_seq)
1320        .bind(state_json)
1321        .bind(timestamp_now())
1322        .execute(&self.pool)
1323        .await?;
1324        Ok(())
1325    }
1326
1327    async fn get_latest_snapshot(&self, workflow_id: &str) -> Result<Option<WorkflowSnapshot>> {
1328        let row = sqlx::query_as::<_, (String, i32, String, f64)>(
1329            "SELECT workflow_id, event_seq, state_json, created_at
1330             FROM workflow.snapshots WHERE workflow_id = ?
1331             ORDER BY event_seq DESC LIMIT 1",
1332        )
1333        .bind(workflow_id)
1334        .fetch_optional(&self.pool)
1335        .await?;
1336
1337        Ok(row.map(
1338            |(workflow_id, event_seq, state_json, created_at)| WorkflowSnapshot {
1339                workflow_id,
1340                event_seq,
1341                state_json,
1342                created_at,
1343            },
1344        ))
1345    }
1346
1347    // ── Queue Stats ─────────────────────────────────────────
1348
1349    async fn get_queue_stats(&self, namespace: &str) -> Result<Vec<QueueStats>> {
1350        // Gather activity stats per queue for workflows in this namespace
1351        let rows = sqlx::query_as::<_, (String, i64, i64)>(
1352            "SELECT a.task_queue,
1353                    SUM(CASE WHEN a.status = 'PENDING' THEN 1 ELSE 0 END),
1354                    SUM(CASE WHEN a.status = 'RUNNING' THEN 1 ELSE 0 END)
1355             FROM workflow.activities a
1356             INNER JOIN workflow.workflows w ON w.id = a.workflow_id
1357             WHERE w.namespace = ?
1358             GROUP BY a.task_queue",
1359        )
1360        .bind(namespace)
1361        .fetch_all(&self.pool)
1362        .await?;
1363
1364        let mut stats: Vec<QueueStats> = rows
1365            .into_iter()
1366            .map(|(queue, pending, running)| QueueStats {
1367                queue,
1368                pending_activities: pending,
1369                running_activities: running,
1370                workers: 0,
1371            })
1372            .collect();
1373
1374        // Gather worker counts per queue in this namespace
1375        let worker_rows = sqlx::query_as::<_, (String, i64)>(
1376            "SELECT task_queue, COUNT(*) FROM workflow.workers WHERE namespace = ? GROUP BY task_queue",
1377        )
1378        .bind(namespace)
1379        .fetch_all(&self.pool)
1380        .await?;
1381
1382        for (queue, count) in worker_rows {
1383            if let Some(s) = stats.iter_mut().find(|s| s.queue == queue) {
1384                s.workers = count;
1385            } else {
1386                stats.push(QueueStats {
1387                    queue,
1388                    pending_activities: 0,
1389                    running_activities: 0,
1390                    workers: count,
1391                });
1392            }
1393        }
1394
1395        stats.sort_by(|a, b| a.queue.cmp(&b.queue));
1396        Ok(stats)
1397    }
1398
1399    // ── Leader Election ─────────────────────────────────────
1400
1401    async fn try_acquire_scheduler_lock(&self) -> Result<bool> {
1402        // SQLite is single-instance — always the leader.
1403        // Also refresh the engine lock heartbeat on each scheduler tick.
1404        self.refresh_engine_lock().await.ok();
1405        Ok(true)
1406    }
1407}
1408
1409fn timestamp_now() -> f64 {
1410    std::time::SystemTime::now()
1411        .duration_since(std::time::UNIX_EPOCH)
1412        .unwrap()
1413        .as_secs_f64()
1414}
1415
1416/// Merge a JSON-object patch into a (possibly-null) current JSON object,
1417/// returning the serialised result. Shared by SQLite and Postgres stores.
1418pub(crate) fn merge_search_attrs(current: Option<&str>, patch_json: &str) -> Result<String> {
1419    let mut current_map: serde_json::Map<String, serde_json::Value> = current
1420        .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
1421        .and_then(|v| v.as_object().cloned())
1422        .unwrap_or_default();
1423    let patch: serde_json::Value = serde_json::from_str(patch_json)
1424        .map_err(|e| anyhow::anyhow!("invalid search_attributes patch: {e}"))?;
1425    let patch_obj = patch
1426        .as_object()
1427        .ok_or_else(|| anyhow::anyhow!("search_attributes patch must be a JSON object"))?;
1428    for (k, v) in patch_obj {
1429        current_map.insert(k.clone(), v.clone());
1430    }
1431    Ok(serde_json::Value::Object(current_map).to_string())
1432}
1433
1434// ── SQLite row types (sqlx::FromRow) ────────────────────────
1435
1436#[derive(sqlx::FromRow)]
1437struct SqliteWorkflowRow {
1438    id: String,
1439    namespace: String,
1440    run_id: String,
1441    workflow_type: String,
1442    task_queue: String,
1443    status: String,
1444    input: Option<String>,
1445    result: Option<String>,
1446    error: Option<String>,
1447    parent_id: Option<String>,
1448    claimed_by: Option<String>,
1449    search_attributes: Option<String>,
1450    archived_at: Option<f64>,
1451    archive_uri: Option<String>,
1452    created_at: f64,
1453    updated_at: f64,
1454    completed_at: Option<f64>,
1455}
1456
1457impl From<SqliteWorkflowRow> for WorkflowRecord {
1458    fn from(r: SqliteWorkflowRow) -> Self {
1459        Self {
1460            id: r.id,
1461            namespace: r.namespace,
1462            run_id: r.run_id,
1463            workflow_type: r.workflow_type,
1464            task_queue: r.task_queue,
1465            status: r.status,
1466            input: r.input,
1467            result: r.result,
1468            error: r.error,
1469            parent_id: r.parent_id,
1470            claimed_by: r.claimed_by,
1471            search_attributes: r.search_attributes,
1472            archived_at: r.archived_at,
1473            archive_uri: r.archive_uri,
1474            created_at: r.created_at,
1475            updated_at: r.updated_at,
1476            completed_at: r.completed_at,
1477        }
1478    }
1479}
1480
1481#[derive(sqlx::FromRow)]
1482struct SqliteEventRow {
1483    id: i64,
1484    workflow_id: String,
1485    seq: i32,
1486    event_type: String,
1487    payload: Option<String>,
1488    timestamp: f64,
1489}
1490
1491impl From<SqliteEventRow> for WorkflowEvent {
1492    fn from(r: SqliteEventRow) -> Self {
1493        Self {
1494            id: Some(r.id),
1495            workflow_id: r.workflow_id,
1496            seq: r.seq,
1497            event_type: r.event_type,
1498            payload: r.payload,
1499            timestamp: r.timestamp,
1500        }
1501    }
1502}
1503
1504#[derive(sqlx::FromRow)]
1505struct SqliteActivityRow {
1506    id: i64,
1507    workflow_id: String,
1508    seq: i32,
1509    name: String,
1510    task_queue: String,
1511    input: Option<String>,
1512    status: String,
1513    result: Option<String>,
1514    error: Option<String>,
1515    attempt: i32,
1516    max_attempts: i32,
1517    initial_interval_secs: f64,
1518    backoff_coefficient: f64,
1519    start_to_close_secs: f64,
1520    heartbeat_timeout_secs: Option<f64>,
1521    claimed_by: Option<String>,
1522    scheduled_at: f64,
1523    started_at: Option<f64>,
1524    completed_at: Option<f64>,
1525    last_heartbeat: Option<f64>,
1526}
1527
1528impl From<SqliteActivityRow> for WorkflowActivity {
1529    fn from(r: SqliteActivityRow) -> Self {
1530        Self {
1531            id: Some(r.id),
1532            workflow_id: r.workflow_id,
1533            seq: r.seq,
1534            name: r.name,
1535            task_queue: r.task_queue,
1536            input: r.input,
1537            status: r.status,
1538            result: r.result,
1539            error: r.error,
1540            attempt: r.attempt,
1541            max_attempts: r.max_attempts,
1542            initial_interval_secs: r.initial_interval_secs,
1543            backoff_coefficient: r.backoff_coefficient,
1544            start_to_close_secs: r.start_to_close_secs,
1545            heartbeat_timeout_secs: r.heartbeat_timeout_secs,
1546            claimed_by: r.claimed_by,
1547            scheduled_at: r.scheduled_at,
1548            started_at: r.started_at,
1549            completed_at: r.completed_at,
1550            last_heartbeat: r.last_heartbeat,
1551        }
1552    }
1553}
1554
1555#[derive(sqlx::FromRow)]
1556struct SqliteTimerRow {
1557    id: i64,
1558    workflow_id: String,
1559    seq: i32,
1560    fire_at: f64,
1561    fired: bool,
1562}
1563
1564impl From<SqliteTimerRow> for WorkflowTimer {
1565    fn from(r: SqliteTimerRow) -> Self {
1566        Self {
1567            id: Some(r.id),
1568            workflow_id: r.workflow_id,
1569            seq: r.seq,
1570            fire_at: r.fire_at,
1571            fired: r.fired,
1572        }
1573    }
1574}
1575
1576#[derive(sqlx::FromRow)]
1577struct SqliteSignalRow {
1578    id: i64,
1579    workflow_id: String,
1580    name: String,
1581    payload: Option<String>,
1582    consumed: bool,
1583    received_at: f64,
1584}
1585
1586impl From<SqliteSignalRow> for WorkflowSignal {
1587    fn from(r: SqliteSignalRow) -> Self {
1588        Self {
1589            id: Some(r.id),
1590            workflow_id: r.workflow_id,
1591            name: r.name,
1592            payload: r.payload,
1593            consumed: r.consumed,
1594            received_at: r.received_at,
1595        }
1596    }
1597}
1598
1599#[derive(sqlx::FromRow)]
1600struct SqliteScheduleRow {
1601    name: String,
1602    namespace: String,
1603    workflow_type: String,
1604    cron_expr: String,
1605    timezone: String,
1606    input: Option<String>,
1607    task_queue: String,
1608    overlap_policy: String,
1609    paused: bool,
1610    last_run_at: Option<f64>,
1611    next_run_at: Option<f64>,
1612    last_workflow_id: Option<String>,
1613    created_at: f64,
1614}
1615
1616impl From<SqliteScheduleRow> for WorkflowSchedule {
1617    fn from(r: SqliteScheduleRow) -> Self {
1618        Self {
1619            name: r.name,
1620            namespace: r.namespace,
1621            workflow_type: r.workflow_type,
1622            cron_expr: r.cron_expr,
1623            timezone: r.timezone,
1624            input: r.input,
1625            task_queue: r.task_queue,
1626            overlap_policy: r.overlap_policy,
1627            paused: r.paused,
1628            last_run_at: r.last_run_at,
1629            next_run_at: r.next_run_at,
1630            last_workflow_id: r.last_workflow_id,
1631            created_at: r.created_at,
1632        }
1633    }
1634}
1635
1636#[derive(sqlx::FromRow)]
1637struct SqliteWorkerRow {
1638    id: String,
1639    namespace: String,
1640    identity: String,
1641    task_queue: String,
1642    workflows: Option<String>,
1643    activities: Option<String>,
1644    max_concurrent_workflows: i32,
1645    max_concurrent_activities: i32,
1646    active_tasks: i32,
1647    last_heartbeat: f64,
1648    registered_at: f64,
1649}
1650
1651impl From<SqliteWorkerRow> for WorkflowWorker {
1652    fn from(r: SqliteWorkerRow) -> Self {
1653        Self {
1654            id: r.id,
1655            namespace: r.namespace,
1656            identity: r.identity,
1657            task_queue: r.task_queue,
1658            workflows: r.workflows,
1659            activities: r.activities,
1660            max_concurrent_workflows: r.max_concurrent_workflows,
1661            max_concurrent_activities: r.max_concurrent_activities,
1662            active_tasks: r.active_tasks,
1663            last_heartbeat: r.last_heartbeat,
1664            registered_at: r.registered_at,
1665        }
1666    }
1667}