1use anyhow::Result;
2use sqlx::SqlitePool;
3use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
4
5use crate::store::{NamespaceRecord, NamespaceStats, QueueStats, WorkflowStore};
6use crate::types::*;
7
8const 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
176const LOCK_STALE_SECS: f64 = 60.0;
179const LOCK_HEARTBEAT_SECS: u64 = 15;
181
182#[derive(Clone)]
189pub struct SqliteStore {
190 pool: SqlitePool,
191 instance_id: String,
192}
193
194async 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 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 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 pub async fn from_pool(pool: SqlitePool) -> Result<Self> {
275 Self::from_attached_pool(pool).await
276 }
277
278 pub fn pool(&self) -> &SqlitePool {
281 &self.pool
282 }
283
284 pub async fn acquire_engine_lock(&self) -> Result<()> {
287 let now = timestamp_now();
288
289 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 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 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 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 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 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 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 Ok(())
390 }
391
392 #[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 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 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 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 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 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 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 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 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 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 last_heartbeat IS NOT NULL
889 AND (? - last_heartbeat) > heartbeat_timeout_secs",
890 )
891 .bind(now)
892 .fetch_all(&self.pool)
893 .await?;
894 Ok(rows.into_iter().map(Into::into).collect())
895 }
896
897 async fn create_timer(&self, timer: &WorkflowTimer) -> Result<i64> {
900 let res = sqlx::query(
903 "INSERT OR IGNORE INTO workflow.timers (workflow_id, seq, fire_at, fired) VALUES (?, ?, ?, 0)",
904 )
905 .bind(&timer.workflow_id)
906 .bind(timer.seq)
907 .bind(timer.fire_at)
908 .execute(&self.pool)
909 .await?;
910
911 let id = res.last_insert_rowid();
912 if id != 0 {
913 return Ok(id);
914 }
915
916 let (existing_id,): (i64,) =
918 sqlx::query_as("SELECT id FROM workflow.timers WHERE workflow_id = ? AND seq = ?")
919 .bind(&timer.workflow_id)
920 .bind(timer.seq)
921 .fetch_one(&self.pool)
922 .await?;
923 Ok(existing_id)
924 }
925
926 async fn cancel_pending_activities(&self, workflow_id: &str) -> Result<u64> {
927 let res = sqlx::query(
928 "UPDATE workflow.activities SET status = 'CANCELLED', completed_at = ?
929 WHERE workflow_id = ? AND status = 'PENDING'",
930 )
931 .bind(timestamp_now())
932 .bind(workflow_id)
933 .execute(&self.pool)
934 .await?;
935 Ok(res.rows_affected())
936 }
937
938 async fn cancel_pending_timers(&self, workflow_id: &str) -> Result<u64> {
939 let res = sqlx::query(
940 "UPDATE workflow.timers SET fired = 1
941 WHERE workflow_id = ? AND fired = 0",
942 )
943 .bind(workflow_id)
944 .execute(&self.pool)
945 .await?;
946 Ok(res.rows_affected())
947 }
948
949 async fn get_timer_by_workflow_seq(
950 &self,
951 workflow_id: &str,
952 seq: i32,
953 ) -> Result<Option<WorkflowTimer>> {
954 let row = sqlx::query_as::<_, SqliteTimerRow>(
955 "SELECT id, workflow_id, seq, fire_at, fired
956 FROM workflow.timers WHERE workflow_id = ? AND seq = ?",
957 )
958 .bind(workflow_id)
959 .bind(seq)
960 .fetch_optional(&self.pool)
961 .await?;
962 Ok(row.map(Into::into))
963 }
964
965 async fn fire_due_timers(&self, now: f64) -> Result<Vec<WorkflowTimer>> {
966 let rows = sqlx::query_as::<_, SqliteTimerRow>(
967 "UPDATE workflow.timers SET fired = 1
968 WHERE fired = 0 AND fire_at <= ?
969 RETURNING id, workflow_id, seq, fire_at, fired",
970 )
971 .bind(now)
972 .fetch_all(&self.pool)
973 .await?;
974 Ok(rows.into_iter().map(Into::into).collect())
975 }
976
977 async fn send_signal(&self, sig: &WorkflowSignal) -> Result<i64> {
980 let res = sqlx::query(
981 "INSERT INTO workflow.signals (workflow_id, name, payload, consumed, received_at) VALUES (?, ?, ?, 0, ?)",
982 )
983 .bind(&sig.workflow_id)
984 .bind(&sig.name)
985 .bind(&sig.payload)
986 .bind(sig.received_at)
987 .execute(&self.pool)
988 .await?;
989 Ok(res.last_insert_rowid())
990 }
991
992 async fn consume_signals(&self, workflow_id: &str, name: &str) -> Result<Vec<WorkflowSignal>> {
993 let rows = sqlx::query_as::<_, SqliteSignalRow>(
994 "UPDATE workflow.signals SET consumed = 1
995 WHERE workflow_id = ? AND name = ? AND consumed = 0
996 RETURNING id, workflow_id, name, payload, consumed, received_at",
997 )
998 .bind(workflow_id)
999 .bind(name)
1000 .fetch_all(&self.pool)
1001 .await?;
1002 Ok(rows.into_iter().map(Into::into).collect())
1003 }
1004
1005 async fn create_schedule(&self, sched: &WorkflowSchedule) -> Result<()> {
1008 sqlx::query(
1009 "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)
1010 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1011 )
1012 .bind(&sched.name)
1013 .bind(&sched.namespace)
1014 .bind(&sched.workflow_type)
1015 .bind(&sched.cron_expr)
1016 .bind(&sched.timezone)
1017 .bind(&sched.input)
1018 .bind(&sched.task_queue)
1019 .bind(&sched.overlap_policy)
1020 .bind(sched.paused)
1021 .bind(sched.last_run_at)
1022 .bind(sched.next_run_at)
1023 .bind(&sched.last_workflow_id)
1024 .bind(sched.created_at)
1025 .execute(&self.pool)
1026 .await?;
1027 Ok(())
1028 }
1029
1030 async fn get_schedule(&self, namespace: &str, name: &str) -> Result<Option<WorkflowSchedule>> {
1031 let row = sqlx::query_as::<_, SqliteScheduleRow>(
1032 "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
1033 FROM workflow.schedules WHERE namespace = ? AND name = ?",
1034 )
1035 .bind(namespace)
1036 .bind(name)
1037 .fetch_optional(&self.pool)
1038 .await?;
1039 Ok(row.map(Into::into))
1040 }
1041
1042 async fn list_schedules(&self, namespace: &str) -> Result<Vec<WorkflowSchedule>> {
1043 let rows = sqlx::query_as::<_, SqliteScheduleRow>(
1044 "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
1045 FROM workflow.schedules WHERE namespace = ? ORDER BY name",
1046 )
1047 .bind(namespace)
1048 .fetch_all(&self.pool)
1049 .await?;
1050 Ok(rows.into_iter().map(Into::into).collect())
1051 }
1052
1053 async fn update_schedule_last_run(
1054 &self,
1055 namespace: &str,
1056 name: &str,
1057 last_run_at: f64,
1058 next_run_at: f64,
1059 workflow_id: &str,
1060 ) -> Result<()> {
1061 sqlx::query(
1062 "UPDATE workflow.schedules SET last_run_at = ?, next_run_at = ?, last_workflow_id = ? WHERE namespace = ? AND name = ?",
1063 )
1064 .bind(last_run_at)
1065 .bind(next_run_at)
1066 .bind(workflow_id)
1067 .bind(namespace)
1068 .bind(name)
1069 .execute(&self.pool)
1070 .await?;
1071 Ok(())
1072 }
1073
1074 async fn delete_schedule(&self, namespace: &str, name: &str) -> Result<bool> {
1075 let res = sqlx::query("DELETE FROM workflow.schedules WHERE namespace = ? AND name = ?")
1076 .bind(namespace)
1077 .bind(name)
1078 .execute(&self.pool)
1079 .await?;
1080 Ok(res.rows_affected() > 0)
1081 }
1082
1083 async fn list_archivable_workflows(
1084 &self,
1085 cutoff: f64,
1086 limit: i64,
1087 ) -> Result<Vec<WorkflowRecord>> {
1088 let rows = sqlx::query_as::<_, SqliteWorkflowRow>(
1089 "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
1090 FROM workflow.workflows
1091 WHERE status IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
1092 AND completed_at IS NOT NULL
1093 AND completed_at < ?
1094 AND archived_at IS NULL
1095 ORDER BY completed_at ASC
1096 LIMIT ?",
1097 )
1098 .bind(cutoff)
1099 .bind(limit)
1100 .fetch_all(&self.pool)
1101 .await?;
1102 Ok(rows.into_iter().map(Into::into).collect())
1103 }
1104
1105 async fn mark_archived_and_purge(
1106 &self,
1107 workflow_id: &str,
1108 archive_uri: &str,
1109 archived_at: f64,
1110 ) -> Result<()> {
1111 let mut tx = self.pool.begin().await?;
1112 sqlx::query("DELETE FROM workflow.events WHERE workflow_id = ?")
1113 .bind(workflow_id)
1114 .execute(&mut *tx)
1115 .await?;
1116 sqlx::query("DELETE FROM workflow.activities WHERE workflow_id = ?")
1117 .bind(workflow_id)
1118 .execute(&mut *tx)
1119 .await?;
1120 sqlx::query("DELETE FROM workflow.timers WHERE workflow_id = ?")
1121 .bind(workflow_id)
1122 .execute(&mut *tx)
1123 .await?;
1124 sqlx::query("DELETE FROM workflow.signals WHERE workflow_id = ?")
1125 .bind(workflow_id)
1126 .execute(&mut *tx)
1127 .await?;
1128 sqlx::query("DELETE FROM workflow.snapshots WHERE workflow_id = ?")
1129 .bind(workflow_id)
1130 .execute(&mut *tx)
1131 .await?;
1132 sqlx::query("UPDATE workflow.workflows SET archived_at = ?, archive_uri = ? WHERE id = ?")
1133 .bind(archived_at)
1134 .bind(archive_uri)
1135 .bind(workflow_id)
1136 .execute(&mut *tx)
1137 .await?;
1138 tx.commit().await?;
1139 Ok(())
1140 }
1141
1142 async fn upsert_search_attributes(&self, workflow_id: &str, patch_json: &str) -> Result<()> {
1143 let current: Option<(Option<String>,)> =
1146 sqlx::query_as("SELECT search_attributes FROM workflow.workflows WHERE id = ?")
1147 .bind(workflow_id)
1148 .fetch_optional(&self.pool)
1149 .await?;
1150 let merged = merge_search_attrs(current.and_then(|(s,)| s).as_deref(), patch_json)?;
1151 sqlx::query("UPDATE workflow.workflows SET search_attributes = ? WHERE id = ?")
1152 .bind(merged)
1153 .bind(workflow_id)
1154 .execute(&self.pool)
1155 .await?;
1156 Ok(())
1157 }
1158
1159 async fn update_schedule(
1160 &self,
1161 namespace: &str,
1162 name: &str,
1163 patch: &SchedulePatch,
1164 ) -> Result<Option<WorkflowSchedule>> {
1165 let mut sets: Vec<&'static str> = Vec::new();
1168 if patch.cron_expr.is_some() {
1169 sets.push("cron_expr = ?");
1170 }
1171 if patch.timezone.is_some() {
1172 sets.push("timezone = ?");
1173 }
1174 if patch.input.is_some() {
1175 sets.push("input = ?");
1176 }
1177 if patch.task_queue.is_some() {
1178 sets.push("task_queue = ?");
1179 }
1180 if patch.overlap_policy.is_some() {
1181 sets.push("overlap_policy = ?");
1182 }
1183 if sets.is_empty() {
1185 return self.get_schedule(namespace, name).await;
1186 }
1187
1188 let sql = format!(
1189 "UPDATE workflow.schedules SET {} WHERE namespace = ? AND name = ?",
1190 sets.join(", ")
1191 );
1192 let mut q = sqlx::query(&sql);
1193 if let Some(ref v) = patch.cron_expr {
1194 q = q.bind(v);
1195 }
1196 if let Some(ref v) = patch.timezone {
1197 q = q.bind(v);
1198 }
1199 if let Some(ref v) = patch.input {
1200 q = q.bind(v.to_string());
1201 }
1202 if let Some(ref v) = patch.task_queue {
1203 q = q.bind(v);
1204 }
1205 if let Some(ref v) = patch.overlap_policy {
1206 q = q.bind(v);
1207 }
1208 let res = q.bind(namespace).bind(name).execute(&self.pool).await?;
1209 if res.rows_affected() == 0 {
1210 return Ok(None);
1211 }
1212 self.get_schedule(namespace, name).await
1213 }
1214
1215 async fn set_schedule_paused(
1216 &self,
1217 namespace: &str,
1218 name: &str,
1219 paused: bool,
1220 ) -> Result<Option<WorkflowSchedule>> {
1221 let res = sqlx::query(
1222 "UPDATE workflow.schedules SET paused = ? WHERE namespace = ? AND name = ?",
1223 )
1224 .bind(paused)
1225 .bind(namespace)
1226 .bind(name)
1227 .execute(&self.pool)
1228 .await?;
1229 if res.rows_affected() == 0 {
1230 return Ok(None);
1231 }
1232 self.get_schedule(namespace, name).await
1233 }
1234
1235 async fn register_worker(&self, w: &WorkflowWorker) -> Result<()> {
1238 sqlx::query(
1239 "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)
1240 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1241 )
1242 .bind(&w.id)
1243 .bind(&w.namespace)
1244 .bind(&w.identity)
1245 .bind(&w.task_queue)
1246 .bind(&w.workflows)
1247 .bind(&w.activities)
1248 .bind(w.max_concurrent_workflows)
1249 .bind(w.max_concurrent_activities)
1250 .bind(w.active_tasks)
1251 .bind(w.last_heartbeat)
1252 .bind(w.registered_at)
1253 .execute(&self.pool)
1254 .await?;
1255 Ok(())
1256 }
1257
1258 async fn heartbeat_worker(&self, id: &str, now: f64) -> Result<()> {
1259 sqlx::query("UPDATE workflow.workers SET last_heartbeat = ? WHERE id = ?")
1260 .bind(now)
1261 .bind(id)
1262 .execute(&self.pool)
1263 .await?;
1264 Ok(())
1265 }
1266
1267 async fn list_workers(&self, namespace: &str) -> Result<Vec<WorkflowWorker>> {
1268 let rows = sqlx::query_as::<_, SqliteWorkerRow>(
1269 "SELECT id, namespace, identity, task_queue, workflows, activities, max_concurrent_workflows, max_concurrent_activities, active_tasks, last_heartbeat, registered_at
1270 FROM workflow.workers WHERE namespace = ? ORDER BY registered_at",
1271 )
1272 .bind(namespace)
1273 .fetch_all(&self.pool)
1274 .await?;
1275 Ok(rows.into_iter().map(Into::into).collect())
1276 }
1277
1278 async fn remove_dead_workers(&self, cutoff: f64) -> Result<Vec<String>> {
1279 let rows: Vec<(String,)> =
1280 sqlx::query_as("SELECT id FROM workflow.workers WHERE last_heartbeat < ?")
1281 .bind(cutoff)
1282 .fetch_all(&self.pool)
1283 .await?;
1284 let ids: Vec<String> = rows.into_iter().map(|r| r.0).collect();
1285 if !ids.is_empty() {
1286 sqlx::query("DELETE FROM workflow.workers WHERE last_heartbeat < ?")
1287 .bind(cutoff)
1288 .execute(&self.pool)
1289 .await?;
1290 }
1291 Ok(ids)
1292 }
1293
1294 async fn list_child_workflows(&self, parent_id: &str) -> Result<Vec<WorkflowRecord>> {
1297 let rows = sqlx::query_as::<_, SqliteWorkflowRow>(
1298 "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
1299 FROM workflow.workflows WHERE parent_id = ? ORDER BY created_at ASC",
1300 )
1301 .bind(parent_id)
1302 .fetch_all(&self.pool)
1303 .await?;
1304 Ok(rows.into_iter().map(Into::into).collect())
1305 }
1306
1307 async fn create_snapshot(
1310 &self,
1311 workflow_id: &str,
1312 event_seq: i32,
1313 state_json: &str,
1314 ) -> Result<()> {
1315 sqlx::query(
1316 "INSERT OR REPLACE INTO workflow.snapshots (workflow_id, event_seq, state_json, created_at)
1317 VALUES (?, ?, ?, ?)",
1318 )
1319 .bind(workflow_id)
1320 .bind(event_seq)
1321 .bind(state_json)
1322 .bind(timestamp_now())
1323 .execute(&self.pool)
1324 .await?;
1325 Ok(())
1326 }
1327
1328 async fn get_latest_snapshot(&self, workflow_id: &str) -> Result<Option<WorkflowSnapshot>> {
1329 let row = sqlx::query_as::<_, (String, i32, String, f64)>(
1330 "SELECT workflow_id, event_seq, state_json, created_at
1331 FROM workflow.snapshots WHERE workflow_id = ?
1332 ORDER BY event_seq DESC LIMIT 1",
1333 )
1334 .bind(workflow_id)
1335 .fetch_optional(&self.pool)
1336 .await?;
1337
1338 Ok(row.map(
1339 |(workflow_id, event_seq, state_json, created_at)| WorkflowSnapshot {
1340 workflow_id,
1341 event_seq,
1342 state_json,
1343 created_at,
1344 },
1345 ))
1346 }
1347
1348 async fn get_queue_stats(&self, namespace: &str) -> Result<Vec<QueueStats>> {
1351 let rows = sqlx::query_as::<_, (String, i64, i64)>(
1353 "SELECT a.task_queue,
1354 SUM(CASE WHEN a.status = 'PENDING' THEN 1 ELSE 0 END),
1355 SUM(CASE WHEN a.status = 'RUNNING' THEN 1 ELSE 0 END)
1356 FROM workflow.activities a
1357 INNER JOIN workflow.workflows w ON w.id = a.workflow_id
1358 WHERE w.namespace = ?
1359 GROUP BY a.task_queue",
1360 )
1361 .bind(namespace)
1362 .fetch_all(&self.pool)
1363 .await?;
1364
1365 let mut stats: Vec<QueueStats> = rows
1366 .into_iter()
1367 .map(|(queue, pending, running)| QueueStats {
1368 queue,
1369 pending_activities: pending,
1370 running_activities: running,
1371 workers: 0,
1372 })
1373 .collect();
1374
1375 let worker_rows = sqlx::query_as::<_, (String, i64)>(
1377 "SELECT task_queue, COUNT(*) FROM workflow.workers WHERE namespace = ? GROUP BY task_queue",
1378 )
1379 .bind(namespace)
1380 .fetch_all(&self.pool)
1381 .await?;
1382
1383 for (queue, count) in worker_rows {
1384 if let Some(s) = stats.iter_mut().find(|s| s.queue == queue) {
1385 s.workers = count;
1386 } else {
1387 stats.push(QueueStats {
1388 queue,
1389 pending_activities: 0,
1390 running_activities: 0,
1391 workers: count,
1392 });
1393 }
1394 }
1395
1396 stats.sort_by(|a, b| a.queue.cmp(&b.queue));
1397 Ok(stats)
1398 }
1399
1400 async fn try_acquire_scheduler_lock(&self) -> Result<bool> {
1403 self.refresh_engine_lock().await.ok();
1406 Ok(true)
1407 }
1408}
1409
1410fn timestamp_now() -> f64 {
1411 std::time::SystemTime::now()
1412 .duration_since(std::time::UNIX_EPOCH)
1413 .unwrap()
1414 .as_secs_f64()
1415}
1416
1417pub(crate) fn merge_search_attrs(current: Option<&str>, patch_json: &str) -> Result<String> {
1420 let mut current_map: serde_json::Map<String, serde_json::Value> = current
1421 .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
1422 .and_then(|v| v.as_object().cloned())
1423 .unwrap_or_default();
1424 let patch: serde_json::Value = serde_json::from_str(patch_json)
1425 .map_err(|e| anyhow::anyhow!("invalid search_attributes patch: {e}"))?;
1426 let patch_obj = patch
1427 .as_object()
1428 .ok_or_else(|| anyhow::anyhow!("search_attributes patch must be a JSON object"))?;
1429 for (k, v) in patch_obj {
1430 current_map.insert(k.clone(), v.clone());
1431 }
1432 Ok(serde_json::Value::Object(current_map).to_string())
1433}
1434
1435#[derive(sqlx::FromRow)]
1438struct SqliteWorkflowRow {
1439 id: String,
1440 namespace: String,
1441 run_id: String,
1442 workflow_type: String,
1443 task_queue: String,
1444 status: String,
1445 input: Option<String>,
1446 result: Option<String>,
1447 error: Option<String>,
1448 parent_id: Option<String>,
1449 claimed_by: Option<String>,
1450 search_attributes: Option<String>,
1451 archived_at: Option<f64>,
1452 archive_uri: Option<String>,
1453 created_at: f64,
1454 updated_at: f64,
1455 completed_at: Option<f64>,
1456}
1457
1458impl From<SqliteWorkflowRow> for WorkflowRecord {
1459 fn from(r: SqliteWorkflowRow) -> Self {
1460 Self {
1461 id: r.id,
1462 namespace: r.namespace,
1463 run_id: r.run_id,
1464 workflow_type: r.workflow_type,
1465 task_queue: r.task_queue,
1466 status: r.status,
1467 input: r.input,
1468 result: r.result,
1469 error: r.error,
1470 parent_id: r.parent_id,
1471 claimed_by: r.claimed_by,
1472 search_attributes: r.search_attributes,
1473 archived_at: r.archived_at,
1474 archive_uri: r.archive_uri,
1475 created_at: r.created_at,
1476 updated_at: r.updated_at,
1477 completed_at: r.completed_at,
1478 }
1479 }
1480}
1481
1482#[derive(sqlx::FromRow)]
1483struct SqliteEventRow {
1484 id: i64,
1485 workflow_id: String,
1486 seq: i32,
1487 event_type: String,
1488 payload: Option<String>,
1489 timestamp: f64,
1490}
1491
1492impl From<SqliteEventRow> for WorkflowEvent {
1493 fn from(r: SqliteEventRow) -> Self {
1494 Self {
1495 id: Some(r.id),
1496 workflow_id: r.workflow_id,
1497 seq: r.seq,
1498 event_type: r.event_type,
1499 payload: r.payload,
1500 timestamp: r.timestamp,
1501 }
1502 }
1503}
1504
1505#[derive(sqlx::FromRow)]
1506struct SqliteActivityRow {
1507 id: i64,
1508 workflow_id: String,
1509 seq: i32,
1510 name: String,
1511 task_queue: String,
1512 input: Option<String>,
1513 status: String,
1514 result: Option<String>,
1515 error: Option<String>,
1516 attempt: i32,
1517 max_attempts: i32,
1518 initial_interval_secs: f64,
1519 backoff_coefficient: f64,
1520 start_to_close_secs: f64,
1521 heartbeat_timeout_secs: Option<f64>,
1522 claimed_by: Option<String>,
1523 scheduled_at: f64,
1524 started_at: Option<f64>,
1525 completed_at: Option<f64>,
1526 last_heartbeat: Option<f64>,
1527}
1528
1529impl From<SqliteActivityRow> for WorkflowActivity {
1530 fn from(r: SqliteActivityRow) -> Self {
1531 Self {
1532 id: Some(r.id),
1533 workflow_id: r.workflow_id,
1534 seq: r.seq,
1535 name: r.name,
1536 task_queue: r.task_queue,
1537 input: r.input,
1538 status: r.status,
1539 result: r.result,
1540 error: r.error,
1541 attempt: r.attempt,
1542 max_attempts: r.max_attempts,
1543 initial_interval_secs: r.initial_interval_secs,
1544 backoff_coefficient: r.backoff_coefficient,
1545 start_to_close_secs: r.start_to_close_secs,
1546 heartbeat_timeout_secs: r.heartbeat_timeout_secs,
1547 claimed_by: r.claimed_by,
1548 scheduled_at: r.scheduled_at,
1549 started_at: r.started_at,
1550 completed_at: r.completed_at,
1551 last_heartbeat: r.last_heartbeat,
1552 }
1553 }
1554}
1555
1556#[derive(sqlx::FromRow)]
1557struct SqliteTimerRow {
1558 id: i64,
1559 workflow_id: String,
1560 seq: i32,
1561 fire_at: f64,
1562 fired: bool,
1563}
1564
1565impl From<SqliteTimerRow> for WorkflowTimer {
1566 fn from(r: SqliteTimerRow) -> Self {
1567 Self {
1568 id: Some(r.id),
1569 workflow_id: r.workflow_id,
1570 seq: r.seq,
1571 fire_at: r.fire_at,
1572 fired: r.fired,
1573 }
1574 }
1575}
1576
1577#[derive(sqlx::FromRow)]
1578struct SqliteSignalRow {
1579 id: i64,
1580 workflow_id: String,
1581 name: String,
1582 payload: Option<String>,
1583 consumed: bool,
1584 received_at: f64,
1585}
1586
1587impl From<SqliteSignalRow> for WorkflowSignal {
1588 fn from(r: SqliteSignalRow) -> Self {
1589 Self {
1590 id: Some(r.id),
1591 workflow_id: r.workflow_id,
1592 name: r.name,
1593 payload: r.payload,
1594 consumed: r.consumed,
1595 received_at: r.received_at,
1596 }
1597 }
1598}
1599
1600#[derive(sqlx::FromRow)]
1601struct SqliteScheduleRow {
1602 name: String,
1603 namespace: String,
1604 workflow_type: String,
1605 cron_expr: String,
1606 timezone: String,
1607 input: Option<String>,
1608 task_queue: String,
1609 overlap_policy: String,
1610 paused: bool,
1611 last_run_at: Option<f64>,
1612 next_run_at: Option<f64>,
1613 last_workflow_id: Option<String>,
1614 created_at: f64,
1615}
1616
1617impl From<SqliteScheduleRow> for WorkflowSchedule {
1618 fn from(r: SqliteScheduleRow) -> Self {
1619 Self {
1620 name: r.name,
1621 namespace: r.namespace,
1622 workflow_type: r.workflow_type,
1623 cron_expr: r.cron_expr,
1624 timezone: r.timezone,
1625 input: r.input,
1626 task_queue: r.task_queue,
1627 overlap_policy: r.overlap_policy,
1628 paused: r.paused,
1629 last_run_at: r.last_run_at,
1630 next_run_at: r.next_run_at,
1631 last_workflow_id: r.last_workflow_id,
1632 created_at: r.created_at,
1633 }
1634 }
1635}
1636
1637#[derive(sqlx::FromRow)]
1638struct SqliteWorkerRow {
1639 id: String,
1640 namespace: String,
1641 identity: String,
1642 task_queue: String,
1643 workflows: Option<String>,
1644 activities: Option<String>,
1645 max_concurrent_workflows: i32,
1646 max_concurrent_activities: i32,
1647 active_tasks: i32,
1648 last_heartbeat: f64,
1649 registered_at: f64,
1650}
1651
1652impl From<SqliteWorkerRow> for WorkflowWorker {
1653 fn from(r: SqliteWorkerRow) -> Self {
1654 Self {
1655 id: r.id,
1656 namespace: r.namespace,
1657 identity: r.identity,
1658 task_queue: r.task_queue,
1659 workflows: r.workflows,
1660 activities: r.activities,
1661 max_concurrent_workflows: r.max_concurrent_workflows,
1662 max_concurrent_activities: r.max_concurrent_activities,
1663 active_tasks: r.active_tasks,
1664 last_heartbeat: r.last_heartbeat,
1665 registered_at: r.registered_at,
1666 }
1667 }
1668}