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 (? - 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 async fn create_timer(&self, timer: &WorkflowTimer) -> Result<i64> {
899 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 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 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 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 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 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 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 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 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 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 async fn get_queue_stats(&self, namespace: &str) -> Result<Vec<QueueStats>> {
1350 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 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 async fn try_acquire_scheduler_lock(&self) -> Result<bool> {
1402 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
1416pub(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#[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}