1use anyhow::Result;
2use sqlx::PgPool;
3
4use crate::store::WorkflowStore;
5use crate::types::*;
6
7const SCHEMA: &str = r#"
13CREATE SCHEMA IF NOT EXISTS workflow;
14CREATE SCHEMA IF NOT EXISTS engine;
15
16CREATE TABLE IF NOT EXISTS workflow.namespaces (
17 name TEXT PRIMARY KEY,
18 created_at DOUBLE PRECISION NOT NULL
19);
20INSERT INTO workflow.namespaces (name, created_at)
21 VALUES ('main', EXTRACT(EPOCH FROM NOW()))
22 ON CONFLICT DO NOTHING;
23
24CREATE TABLE IF NOT EXISTS workflow.workflows (
25 id TEXT PRIMARY KEY,
26 namespace TEXT NOT NULL DEFAULT 'main',
27 run_id TEXT NOT NULL,
28 workflow_type TEXT NOT NULL,
29 task_queue TEXT NOT NULL DEFAULT 'main',
30 status TEXT NOT NULL DEFAULT 'PENDING',
31 input TEXT,
32 result TEXT,
33 error TEXT,
34 parent_id TEXT,
35 claimed_by TEXT,
36 search_attributes TEXT,
37 archived_at DOUBLE PRECISION,
38 archive_uri TEXT,
39 -- Workflow-task dispatch (Phase 9): see sqlite.rs for the full comment.
40 needs_dispatch BOOLEAN NOT NULL DEFAULT FALSE,
41 dispatch_claimed_by TEXT,
42 dispatch_last_heartbeat DOUBLE PRECISION,
43 created_at DOUBLE PRECISION NOT NULL,
44 updated_at DOUBLE PRECISION NOT NULL,
45 completed_at DOUBLE PRECISION
46);
47CREATE INDEX IF NOT EXISTS idx_wf_status_queue ON workflow.workflows(status, task_queue);
48CREATE INDEX IF NOT EXISTS idx_wf_namespace ON workflow.workflows(namespace);
49CREATE INDEX IF NOT EXISTS idx_wf_dispatch ON workflow.workflows(task_queue, needs_dispatch, dispatch_claimed_by);
50
51CREATE TABLE IF NOT EXISTS workflow.events (
52 id BIGSERIAL PRIMARY KEY,
53 workflow_id TEXT NOT NULL REFERENCES workflow.workflows(id),
54 seq INTEGER NOT NULL,
55 event_type TEXT NOT NULL,
56 payload TEXT,
57 timestamp DOUBLE PRECISION NOT NULL
58);
59CREATE INDEX IF NOT EXISTS idx_wf_events_lookup ON workflow.events(workflow_id, seq);
60
61CREATE TABLE IF NOT EXISTS workflow.activities (
62 id BIGSERIAL PRIMARY KEY,
63 workflow_id TEXT NOT NULL REFERENCES workflow.workflows(id),
64 seq INTEGER NOT NULL,
65 name TEXT NOT NULL,
66 task_queue TEXT NOT NULL DEFAULT 'main',
67 input TEXT,
68 status TEXT NOT NULL DEFAULT 'PENDING',
69 result TEXT,
70 error TEXT,
71 attempt INTEGER NOT NULL DEFAULT 1,
72 max_attempts INTEGER NOT NULL DEFAULT 3,
73 initial_interval_secs DOUBLE PRECISION NOT NULL DEFAULT 1,
74 backoff_coefficient DOUBLE PRECISION NOT NULL DEFAULT 2,
75 start_to_close_secs DOUBLE PRECISION NOT NULL DEFAULT 300,
76 heartbeat_timeout_secs DOUBLE PRECISION,
77 claimed_by TEXT,
78 scheduled_at DOUBLE PRECISION NOT NULL,
79 started_at DOUBLE PRECISION,
80 completed_at DOUBLE PRECISION,
81 last_heartbeat DOUBLE PRECISION,
82 UNIQUE (workflow_id, seq)
83);
84CREATE INDEX IF NOT EXISTS idx_wf_act_pending ON workflow.activities(task_queue, status, scheduled_at);
85
86CREATE TABLE IF NOT EXISTS workflow.timers (
87 id BIGSERIAL PRIMARY KEY,
88 workflow_id TEXT NOT NULL REFERENCES workflow.workflows(id),
89 seq INTEGER NOT NULL,
90 fire_at DOUBLE PRECISION NOT NULL,
91 fired BOOLEAN NOT NULL DEFAULT FALSE,
92 UNIQUE (workflow_id, seq)
93);
94CREATE INDEX IF NOT EXISTS idx_wf_timers_due ON workflow.timers(fire_at) WHERE fired = FALSE;
95
96CREATE TABLE IF NOT EXISTS workflow.signals (
97 id BIGSERIAL PRIMARY KEY,
98 workflow_id TEXT NOT NULL REFERENCES workflow.workflows(id),
99 name TEXT NOT NULL,
100 payload TEXT,
101 consumed BOOLEAN NOT NULL DEFAULT FALSE,
102 received_at DOUBLE PRECISION NOT NULL
103);
104CREATE INDEX IF NOT EXISTS idx_wf_signals_lookup ON workflow.signals(workflow_id, name, consumed);
105
106CREATE TABLE IF NOT EXISTS workflow.schedules (
107 namespace TEXT NOT NULL DEFAULT 'main',
108 name TEXT NOT NULL,
109 workflow_type TEXT NOT NULL,
110 cron_expr TEXT NOT NULL,
111 timezone TEXT NOT NULL DEFAULT 'UTC',
112 input TEXT,
113 task_queue TEXT NOT NULL DEFAULT 'main',
114 overlap_policy TEXT NOT NULL DEFAULT 'skip',
115 paused BOOLEAN NOT NULL DEFAULT FALSE,
116 last_run_at DOUBLE PRECISION,
117 next_run_at DOUBLE PRECISION,
118 last_workflow_id TEXT,
119 created_at DOUBLE PRECISION NOT NULL,
120 PRIMARY KEY (namespace, name)
121);
122
123CREATE TABLE IF NOT EXISTS workflow.workers (
124 id TEXT PRIMARY KEY,
125 namespace TEXT NOT NULL DEFAULT 'main',
126 identity TEXT NOT NULL,
127 task_queue TEXT NOT NULL,
128 workflows TEXT,
129 activities TEXT,
130 max_concurrent_workflows INTEGER NOT NULL DEFAULT 10,
131 max_concurrent_activities INTEGER NOT NULL DEFAULT 10,
132 active_tasks INTEGER NOT NULL DEFAULT 0,
133 last_heartbeat DOUBLE PRECISION NOT NULL,
134 registered_at DOUBLE PRECISION NOT NULL
135);
136
137CREATE TABLE IF NOT EXISTS workflow.snapshots (
138 workflow_id TEXT NOT NULL REFERENCES workflow.workflows(id),
139 event_seq INTEGER NOT NULL,
140 state_json TEXT NOT NULL,
141 created_at DOUBLE PRECISION NOT NULL,
142 PRIMARY KEY (workflow_id, event_seq)
143);
144
145-- Plan-15 slice 3: workflow.api_keys retired in favour of the auth
146-- module (sessions / JWT / Zanzibar tuples). Table is dropped on
147-- migration; nothing here re-creates it.
148DROP TABLE IF EXISTS workflow.api_keys CASCADE;
149
150CREATE TABLE IF NOT EXISTS engine.events (
151 id BIGSERIAL PRIMARY KEY,
152 ts DOUBLE PRECISION NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()),
153 namespace TEXT NOT NULL,
154 subsystem TEXT NOT NULL,
155 kind TEXT NOT NULL,
156 payload JSONB NOT NULL DEFAULT '{}'::jsonb
157);
158CREATE INDEX IF NOT EXISTS idx_engine_events_ns_id ON engine.events(namespace, id);
159CREATE INDEX IF NOT EXISTS idx_engine_events_ts_prune ON engine.events(ts);
160
161"#;
162
163const V0_13_2_RELOCATION_SQL: &str = r#"
184DO $$
185DECLARE
186 has_old BOOLEAN;
187BEGIN
188 -- Each table: if the legacy public.<old> exists, drop the empty
189 -- schema-qualified twin (created above by SCHEMA) and move the
190 -- legacy table into its new home.
191
192 -- workflows
193 SELECT to_regclass('public.workflows') IS NOT NULL INTO has_old;
194 IF has_old THEN
195 DROP TABLE IF EXISTS workflow.workflows CASCADE;
196 ALTER TABLE public.workflows SET SCHEMA workflow;
197 END IF;
198
199 -- workflow_events → workflow.events
200 SELECT to_regclass('public.workflow_events') IS NOT NULL INTO has_old;
201 IF has_old THEN
202 DROP TABLE IF EXISTS workflow.events CASCADE;
203 ALTER TABLE public.workflow_events SET SCHEMA workflow;
204 ALTER TABLE workflow.workflow_events RENAME TO events;
205 END IF;
206
207 -- workflow_activities → workflow.activities
208 SELECT to_regclass('public.workflow_activities') IS NOT NULL INTO has_old;
209 IF has_old THEN
210 DROP TABLE IF EXISTS workflow.activities CASCADE;
211 ALTER TABLE public.workflow_activities SET SCHEMA workflow;
212 ALTER TABLE workflow.workflow_activities RENAME TO activities;
213 END IF;
214
215 -- workflow_timers → workflow.timers
216 SELECT to_regclass('public.workflow_timers') IS NOT NULL INTO has_old;
217 IF has_old THEN
218 DROP TABLE IF EXISTS workflow.timers CASCADE;
219 ALTER TABLE public.workflow_timers SET SCHEMA workflow;
220 ALTER TABLE workflow.workflow_timers RENAME TO timers;
221 END IF;
222
223 -- workflow_signals → workflow.signals
224 SELECT to_regclass('public.workflow_signals') IS NOT NULL INTO has_old;
225 IF has_old THEN
226 DROP TABLE IF EXISTS workflow.signals CASCADE;
227 ALTER TABLE public.workflow_signals SET SCHEMA workflow;
228 ALTER TABLE workflow.workflow_signals RENAME TO signals;
229 END IF;
230
231 -- workflow_snapshots → workflow.snapshots
232 SELECT to_regclass('public.workflow_snapshots') IS NOT NULL INTO has_old;
233 IF has_old THEN
234 DROP TABLE IF EXISTS workflow.snapshots CASCADE;
235 ALTER TABLE public.workflow_snapshots SET SCHEMA workflow;
236 ALTER TABLE workflow.workflow_snapshots RENAME TO snapshots;
237 END IF;
238
239 -- workflow_schedules → workflow.schedules
240 SELECT to_regclass('public.workflow_schedules') IS NOT NULL INTO has_old;
241 IF has_old THEN
242 DROP TABLE IF EXISTS workflow.schedules CASCADE;
243 ALTER TABLE public.workflow_schedules SET SCHEMA workflow;
244 ALTER TABLE workflow.workflow_schedules RENAME TO schedules;
245 END IF;
246
247 -- workflow_workers → workflow.workers
248 SELECT to_regclass('public.workflow_workers') IS NOT NULL INTO has_old;
249 IF has_old THEN
250 DROP TABLE IF EXISTS workflow.workers CASCADE;
251 ALTER TABLE public.workflow_workers SET SCHEMA workflow;
252 ALTER TABLE workflow.workflow_workers RENAME TO workers;
253 END IF;
254
255 -- namespaces → workflow.namespaces
256 SELECT to_regclass('public.namespaces') IS NOT NULL INTO has_old;
257 IF has_old THEN
258 DROP TABLE IF EXISTS workflow.namespaces CASCADE;
259 ALTER TABLE public.namespaces SET SCHEMA workflow;
260 END IF;
261
262 -- public.api_keys: retired in plan-15 slice 3 (workflow REST API
263 -- auth moved to the auth module — see CHANGELOG). Drop any
264 -- orphaned legacy table so an upgraded v0.13.1 install doesn't
265 -- carry it forward.
266 SELECT to_regclass('public.api_keys') IS NOT NULL INTO has_old;
267 IF has_old THEN
268 DROP TABLE public.api_keys CASCADE;
269 END IF;
270
271 -- engine_events → engine.events (notification outbox; preserves the
272 -- v0.13.1 publish-on-commit guarantee since the new INSERT into
273 -- engine.events sits in the same transaction as the pg_notify call).
274 SELECT to_regclass('public.engine_events') IS NOT NULL INTO has_old;
275 IF has_old THEN
276 DROP TABLE IF EXISTS engine.events CASCADE;
277 ALTER TABLE public.engine_events SET SCHEMA engine;
278 ALTER TABLE engine.engine_events RENAME TO events;
279 END IF;
280END
281$$;
282"#;
283
284fn sanitise_schema(schema: &str) -> Vec<String> {
296 let without_comments: String = schema
297 .lines()
298 .filter(|line| !line.trim_start().starts_with("--"))
299 .collect::<Vec<_>>()
300 .join("\n");
301
302 without_comments
303 .split(';')
304 .map(|s| s.trim().to_string())
305 .filter(|s| !s.is_empty())
306 .collect()
307}
308
309#[derive(Clone)]
315pub struct PostgresStore {
316 pool: PgPool,
317}
318
319impl PostgresStore {
320 pub async fn new(url: &str) -> Result<Self> {
321 let pool = PgPool::connect(url).await?;
322 Self::from_pool(pool).await
323 }
324
325 pub async fn from_pool(pool: PgPool) -> Result<Self> {
330 let store = Self { pool };
331 store.migrate().await?;
332 Ok(store)
333 }
334
335 pub fn pool(&self) -> &PgPool {
338 &self.pool
339 }
340
341 async fn migrate(&self) -> Result<()> {
342 for statement in sanitise_schema(SCHEMA) {
349 sqlx::query(&statement).execute(&self.pool).await?;
350 }
351 sqlx::raw_sql(V0_13_2_RELOCATION_SQL)
354 .execute(&self.pool)
355 .await?;
356 sqlx::raw_sql(
364 r#"
365 DROP TRIGGER IF EXISTS workflow_runnable_notify ON workflow.workflows;
366 DROP TRIGGER IF EXISTS workflow_task_notify ON workflow.activities;
367 DROP FUNCTION IF EXISTS assay_notify_runnable();
368 DROP FUNCTION IF EXISTS assay_notify_task();
369 "#,
370 )
371 .execute(&self.pool)
372 .await?;
373 Ok(())
374 }
375
376 pub async fn try_acquire_leader_lock(&self) -> Result<bool> {
379 let row: (bool,) =
380 sqlx::query_as("SELECT pg_try_advisory_lock(1)")
381 .fetch_one(&self.pool)
382 .await?;
383 Ok(row.0)
384 }
385}
386
387impl WorkflowStore for PostgresStore {
388 async fn create_namespace(&self, name: &str) -> Result<()> {
391 sqlx::query("INSERT INTO workflow.namespaces (name, created_at) VALUES ($1, EXTRACT(EPOCH FROM NOW()))")
392 .bind(name)
393 .execute(&self.pool)
394 .await?;
395 Ok(())
396 }
397
398 async fn list_namespaces(&self) -> Result<Vec<crate::store::NamespaceRecord>> {
399 let rows = sqlx::query_as::<_, (String, f64)>(
400 "SELECT name, created_at FROM workflow.namespaces ORDER BY name",
401 )
402 .fetch_all(&self.pool)
403 .await?;
404 Ok(rows
405 .into_iter()
406 .map(|(name, created_at)| crate::store::NamespaceRecord { name, created_at })
407 .collect())
408 }
409
410 async fn delete_namespace(&self, name: &str) -> Result<bool> {
411 let res = sqlx::query("DELETE FROM workflow.namespaces WHERE name = $1 AND name != 'main'")
412 .bind(name)
413 .execute(&self.pool)
414 .await?;
415 Ok(res.rows_affected() > 0)
416 }
417
418 async fn get_namespace_stats(&self, namespace: &str) -> Result<crate::store::NamespaceStats> {
419 let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1")
420 .bind(namespace)
421 .fetch_one(&self.pool)
422 .await?;
423 let running: (i64,) = sqlx::query_as(
424 "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1 AND status = 'RUNNING'",
425 )
426 .bind(namespace)
427 .fetch_one(&self.pool)
428 .await?;
429 let pending: (i64,) = sqlx::query_as(
430 "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1 AND status = 'PENDING'",
431 )
432 .bind(namespace)
433 .fetch_one(&self.pool)
434 .await?;
435 let completed: (i64,) = sqlx::query_as(
436 "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1 AND status = 'COMPLETED'",
437 )
438 .bind(namespace)
439 .fetch_one(&self.pool)
440 .await?;
441 let failed: (i64,) = sqlx::query_as(
442 "SELECT COUNT(*) FROM workflow.workflows WHERE namespace = $1 AND status = 'FAILED'",
443 )
444 .bind(namespace)
445 .fetch_one(&self.pool)
446 .await?;
447 let schedules: (i64,) =
448 sqlx::query_as("SELECT COUNT(*) FROM workflow.schedules WHERE namespace = $1")
449 .bind(namespace)
450 .fetch_one(&self.pool)
451 .await?;
452 let workers: (i64,) =
453 sqlx::query_as("SELECT COUNT(*) FROM workflow.workers WHERE namespace = $1")
454 .bind(namespace)
455 .fetch_one(&self.pool)
456 .await?;
457
458 Ok(crate::store::NamespaceStats {
459 namespace: namespace.to_string(),
460 total_workflows: total.0,
461 running: running.0,
462 pending: pending.0,
463 completed: completed.0,
464 failed: failed.0,
465 schedules: schedules.0,
466 workers: workers.0,
467 })
468 }
469
470 async fn create_workflow(&self, wf: &WorkflowRecord) -> Result<()> {
473 sqlx::query(
474 "INSERT INTO workflow.workflows (id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at)
475 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
476 )
477 .bind(&wf.id)
478 .bind(&wf.namespace)
479 .bind(&wf.run_id)
480 .bind(&wf.workflow_type)
481 .bind(&wf.task_queue)
482 .bind(&wf.status)
483 .bind(&wf.input)
484 .bind(&wf.result)
485 .bind(&wf.error)
486 .bind(&wf.parent_id)
487 .bind(&wf.claimed_by)
488 .bind(&wf.search_attributes)
489 .bind(wf.archived_at)
490 .bind(&wf.archive_uri)
491 .bind(wf.created_at)
492 .bind(wf.updated_at)
493 .bind(wf.completed_at)
494 .execute(&self.pool)
495 .await?;
496 Ok(())
497 }
498
499 async fn get_workflow(&self, id: &str) -> Result<Option<WorkflowRecord>> {
500 let row = sqlx::query_as::<_, PgWorkflowRow>(
501 "SELECT id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at FROM workflow.workflows WHERE id = $1",
502 )
503 .bind(id)
504 .fetch_optional(&self.pool)
505 .await?;
506 Ok(row.map(Into::into))
507 }
508
509 async fn list_workflows(
510 &self,
511 namespace: &str,
512 status: Option<WorkflowStatus>,
513 workflow_type: Option<&str>,
514 search_attrs_filter: Option<&str>,
515 limit: i64,
516 offset: i64,
517 ) -> Result<Vec<WorkflowRecord>> {
518 let status_str = status.map(|s| s.to_string());
519
520 let filter_pairs: Vec<(String, serde_json::Value)> = search_attrs_filter
521 .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
522 .and_then(|v| v.as_object().cloned())
523 .map(|m| m.into_iter().collect())
524 .unwrap_or_default();
525
526 let mut sql = String::from(
527 "SELECT id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at
528 FROM workflow.workflows
529 WHERE namespace = $1
530 AND ($2::TEXT IS NULL OR status = $2)
531 AND ($3::TEXT IS NULL OR workflow_type = $3)",
532 );
533 let mut idx = 4usize;
535 for _ in &filter_pairs {
536 sql.push_str(&format!(
537 " AND (search_attributes::jsonb)->>${} = ${}",
538 idx,
539 idx + 1
540 ));
541 idx += 2;
542 }
543 sql.push_str(&format!(" ORDER BY created_at DESC LIMIT ${} OFFSET ${}", idx, idx + 1));
544
545 let mut q = sqlx::query_as::<_, PgWorkflowRow>(&sql)
546 .bind(namespace)
547 .bind(&status_str)
548 .bind(workflow_type);
549 for (key, value) in &filter_pairs {
550 q = q.bind(key.clone());
551 let as_text = match value {
553 serde_json::Value::String(s) => s.clone(),
554 other => other.to_string(),
555 };
556 q = q.bind(as_text);
557 }
558 let rows = q.bind(limit).bind(offset).fetch_all(&self.pool).await?;
559 Ok(rows.into_iter().map(Into::into).collect())
560 }
561
562 async fn update_workflow_status(
563 &self,
564 id: &str,
565 status: WorkflowStatus,
566 result: Option<&str>,
567 error: Option<&str>,
568 ) -> Result<()> {
569 let now = timestamp_now();
570 let completed_at = if status.is_terminal() { Some(now) } else { None };
571 sqlx::query(
572 "UPDATE workflow.workflows SET status = $1, result = COALESCE($2, result), error = COALESCE($3, error), updated_at = $4, completed_at = COALESCE($5, completed_at) WHERE id = $6",
573 )
574 .bind(status.to_string())
575 .bind(result)
576 .bind(error)
577 .bind(now)
578 .bind(completed_at)
579 .bind(id)
580 .execute(&self.pool)
581 .await?;
582 Ok(())
583 }
584
585 async fn claim_workflow(&self, id: &str, worker_id: &str) -> Result<bool> {
586 let res = sqlx::query(
587 "UPDATE workflow.workflows SET claimed_by = $1, status = 'RUNNING', updated_at = $2 WHERE id = $3 AND claimed_by IS NULL",
588 )
589 .bind(worker_id)
590 .bind(timestamp_now())
591 .bind(id)
592 .execute(&self.pool)
593 .await?;
594 Ok(res.rows_affected() > 0)
595 }
596
597 async fn mark_workflow_dispatchable(&self, workflow_id: &str) -> Result<()> {
598 sqlx::query("UPDATE workflow.workflows SET needs_dispatch = TRUE WHERE id = $1")
599 .bind(workflow_id)
600 .execute(&self.pool)
601 .await?;
602 Ok(())
603 }
604
605 async fn claim_workflow_task(
606 &self,
607 task_queue: &str,
608 worker_id: &str,
609 ) -> Result<Option<WorkflowRecord>> {
610 let now = timestamp_now();
611 let row = sqlx::query_as::<_, PgWorkflowRow>(
614 "UPDATE workflow.workflows
615 SET dispatch_claimed_by = $1, dispatch_last_heartbeat = $2, needs_dispatch = FALSE
616 WHERE id = (
617 SELECT id FROM workflow.workflows
618 WHERE task_queue = $3
619 AND needs_dispatch = TRUE
620 AND dispatch_claimed_by IS NULL
621 AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
622 ORDER BY updated_at ASC
623 FOR UPDATE SKIP LOCKED
624 LIMIT 1
625 )
626 RETURNING id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at",
627 )
628 .bind(worker_id)
629 .bind(now)
630 .bind(task_queue)
631 .fetch_optional(&self.pool)
632 .await?;
633 Ok(row.map(Into::into))
634 }
635
636 async fn release_workflow_task(&self, workflow_id: &str, worker_id: &str) -> Result<()> {
637 sqlx::query(
638 "UPDATE workflow.workflows
639 SET dispatch_claimed_by = NULL, dispatch_last_heartbeat = NULL
640 WHERE id = $1 AND dispatch_claimed_by = $2",
641 )
642 .bind(workflow_id)
643 .bind(worker_id)
644 .execute(&self.pool)
645 .await?;
646 Ok(())
647 }
648
649 async fn release_stale_dispatch_leases(
650 &self,
651 now: f64,
652 timeout_secs: f64,
653 ) -> Result<u64> {
654 let res = sqlx::query(
655 "UPDATE workflow.workflows
656 SET dispatch_claimed_by = NULL,
657 dispatch_last_heartbeat = NULL,
658 needs_dispatch = TRUE
659 WHERE dispatch_claimed_by IS NOT NULL
660 AND ($1 - dispatch_last_heartbeat) > $2
661 AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')",
662 )
663 .bind(now)
664 .bind(timeout_secs)
665 .execute(&self.pool)
666 .await?;
667 Ok(res.rows_affected())
668 }
669
670 async fn append_event(&self, ev: &WorkflowEvent) -> Result<i64> {
673 let row: (i64,) = sqlx::query_as(
674 "INSERT INTO workflow.events (workflow_id, seq, event_type, payload, timestamp) VALUES ($1, $2, $3, $4, $5) RETURNING id",
675 )
676 .bind(&ev.workflow_id)
677 .bind(ev.seq)
678 .bind(&ev.event_type)
679 .bind(&ev.payload)
680 .bind(ev.timestamp)
681 .fetch_one(&self.pool)
682 .await?;
683 Ok(row.0)
684 }
685
686 async fn list_events(&self, workflow_id: &str) -> Result<Vec<WorkflowEvent>> {
687 let rows = sqlx::query_as::<_, PgEventRow>(
688 "SELECT id, workflow_id, seq, event_type, payload, timestamp FROM workflow.events WHERE workflow_id = $1 ORDER BY seq ASC",
689 )
690 .bind(workflow_id)
691 .fetch_all(&self.pool)
692 .await?;
693 Ok(rows.into_iter().map(Into::into).collect())
694 }
695
696 async fn get_event_count(&self, workflow_id: &str) -> Result<i64> {
697 let row: (i64,) =
698 sqlx::query_as("SELECT COUNT(*) FROM workflow.events WHERE workflow_id = $1")
699 .bind(workflow_id)
700 .fetch_one(&self.pool)
701 .await?;
702 Ok(row.0)
703 }
704
705 async fn create_activity(&self, act: &WorkflowActivity) -> Result<i64> {
708 let row: (i64,) = sqlx::query_as(
709 "INSERT INTO workflow.activities (workflow_id, seq, name, task_queue, input, status, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, scheduled_at)
710 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id",
711 )
712 .bind(&act.workflow_id)
713 .bind(act.seq)
714 .bind(&act.name)
715 .bind(&act.task_queue)
716 .bind(&act.input)
717 .bind(&act.status)
718 .bind(act.attempt)
719 .bind(act.max_attempts)
720 .bind(act.initial_interval_secs)
721 .bind(act.backoff_coefficient)
722 .bind(act.start_to_close_secs)
723 .bind(act.heartbeat_timeout_secs)
724 .bind(act.scheduled_at)
725 .fetch_one(&self.pool)
726 .await?;
727 Ok(row.0)
728 }
729
730 async fn get_activity(&self, id: i64) -> Result<Option<WorkflowActivity>> {
731 let row = sqlx::query_as::<_, PgActivityRow>(
732 "SELECT id, workflow_id, seq, name, task_queue, input, status, result, error, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, claimed_by, scheduled_at, started_at, completed_at, last_heartbeat
733 FROM workflow.activities WHERE id = $1",
734 )
735 .bind(id)
736 .fetch_optional(&self.pool)
737 .await?;
738 Ok(row.map(Into::into))
739 }
740
741 async fn get_activity_by_workflow_seq(
742 &self,
743 workflow_id: &str,
744 seq: i32,
745 ) -> Result<Option<WorkflowActivity>> {
746 let row = sqlx::query_as::<_, PgActivityRow>(
747 "SELECT id, workflow_id, seq, name, task_queue, input, status, result, error, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, claimed_by, scheduled_at, started_at, completed_at, last_heartbeat
748 FROM workflow.activities WHERE workflow_id = $1 AND seq = $2",
749 )
750 .bind(workflow_id)
751 .bind(seq)
752 .fetch_optional(&self.pool)
753 .await?;
754 Ok(row.map(Into::into))
755 }
756
757 async fn claim_activity(
758 &self,
759 task_queue: &str,
760 worker_id: &str,
761 ) -> Result<Option<WorkflowActivity>> {
762 let now = timestamp_now();
763 let row = sqlx::query_as::<_, PgActivityRow>(
766 "UPDATE workflow.activities SET status = 'RUNNING', claimed_by = $1, started_at = $2
767 WHERE id = (
768 SELECT id FROM workflow.activities
769 WHERE task_queue = $3 AND status = 'PENDING'
770 ORDER BY scheduled_at ASC
771 FOR UPDATE SKIP LOCKED
772 LIMIT 1
773 )
774 RETURNING id, workflow_id, seq, name, task_queue, input, status, result, error, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, claimed_by, scheduled_at, started_at, completed_at, last_heartbeat",
775 )
776 .bind(worker_id)
777 .bind(now)
778 .bind(task_queue)
779 .fetch_optional(&self.pool)
780 .await?;
781 Ok(row.map(Into::into))
782 }
783
784 async fn requeue_activity_for_retry(
785 &self,
786 id: i64,
787 next_attempt: i32,
788 next_scheduled_at: f64,
789 ) -> Result<()> {
790 sqlx::query(
791 "UPDATE workflow.activities
792 SET status = 'PENDING', attempt = $1, scheduled_at = $2,
793 claimed_by = NULL, started_at = NULL, last_heartbeat = NULL,
794 error = NULL
795 WHERE id = $3",
796 )
797 .bind(next_attempt)
798 .bind(next_scheduled_at)
799 .bind(id)
800 .execute(&self.pool)
801 .await?;
802 Ok(())
803 }
804
805 async fn complete_activity(
806 &self,
807 id: i64,
808 result: Option<&str>,
809 error: Option<&str>,
810 failed: bool,
811 ) -> Result<()> {
812 let status = if failed { "FAILED" } else { "COMPLETED" };
813 sqlx::query(
814 "UPDATE workflow.activities SET status = $1, result = $2, error = $3, completed_at = $4 WHERE id = $5",
815 )
816 .bind(status)
817 .bind(result)
818 .bind(error)
819 .bind(timestamp_now())
820 .bind(id)
821 .execute(&self.pool)
822 .await?;
823 Ok(())
824 }
825
826 async fn heartbeat_activity(&self, id: i64, _details: Option<&str>) -> Result<()> {
827 sqlx::query("UPDATE workflow.activities SET last_heartbeat = $1 WHERE id = $2")
828 .bind(timestamp_now())
829 .bind(id)
830 .execute(&self.pool)
831 .await?;
832 Ok(())
833 }
834
835 async fn get_timed_out_activities(&self, now: f64) -> Result<Vec<WorkflowActivity>> {
836 let rows = sqlx::query_as::<_, PgActivityRow>(
837 "SELECT id, workflow_id, seq, name, task_queue, input, status, result, error, attempt, max_attempts, initial_interval_secs, backoff_coefficient, start_to_close_secs, heartbeat_timeout_secs, claimed_by, scheduled_at, started_at, completed_at, last_heartbeat
838 FROM workflow.activities
839 WHERE status = 'RUNNING'
840 AND heartbeat_timeout_secs IS NOT NULL
841 AND last_heartbeat IS NOT NULL
842 AND ($1 - last_heartbeat) > heartbeat_timeout_secs",
843 )
844 .bind(now)
845 .fetch_all(&self.pool)
846 .await?;
847 Ok(rows.into_iter().map(Into::into).collect())
848 }
849
850 async fn create_timer(&self, timer: &WorkflowTimer) -> Result<i64> {
853 let inserted: Option<(i64,)> = sqlx::query_as(
856 "INSERT INTO workflow.timers (workflow_id, seq, fire_at, fired)
857 VALUES ($1, $2, $3, FALSE)
858 ON CONFLICT (workflow_id, seq) DO NOTHING
859 RETURNING id",
860 )
861 .bind(&timer.workflow_id)
862 .bind(timer.seq)
863 .bind(timer.fire_at)
864 .fetch_optional(&self.pool)
865 .await?;
866
867 if let Some((id,)) = inserted {
868 return Ok(id);
869 }
870
871 let (id,): (i64,) = sqlx::query_as(
873 "SELECT id FROM workflow.timers WHERE workflow_id = $1 AND seq = $2",
874 )
875 .bind(&timer.workflow_id)
876 .bind(timer.seq)
877 .fetch_one(&self.pool)
878 .await?;
879 Ok(id)
880 }
881
882 async fn cancel_pending_activities(&self, workflow_id: &str) -> Result<u64> {
883 let res = sqlx::query(
884 "UPDATE workflow.activities SET status = 'CANCELLED', completed_at = $1
885 WHERE workflow_id = $2 AND status = 'PENDING'",
886 )
887 .bind(timestamp_now())
888 .bind(workflow_id)
889 .execute(&self.pool)
890 .await?;
891 Ok(res.rows_affected())
892 }
893
894 async fn cancel_pending_timers(&self, workflow_id: &str) -> Result<u64> {
895 let res = sqlx::query(
896 "UPDATE workflow.timers SET fired = TRUE
897 WHERE workflow_id = $1 AND fired = FALSE",
898 )
899 .bind(workflow_id)
900 .execute(&self.pool)
901 .await?;
902 Ok(res.rows_affected())
903 }
904
905 async fn get_timer_by_workflow_seq(
906 &self,
907 workflow_id: &str,
908 seq: i32,
909 ) -> Result<Option<WorkflowTimer>> {
910 let row = sqlx::query_as::<_, PgTimerRow>(
911 "SELECT id, workflow_id, seq, fire_at, fired
912 FROM workflow.timers WHERE workflow_id = $1 AND seq = $2",
913 )
914 .bind(workflow_id)
915 .bind(seq)
916 .fetch_optional(&self.pool)
917 .await?;
918 Ok(row.map(Into::into))
919 }
920
921 async fn fire_due_timers(&self, now: f64) -> Result<Vec<WorkflowTimer>> {
922 let rows = sqlx::query_as::<_, PgTimerRow>(
923 "UPDATE workflow.timers SET fired = TRUE
924 WHERE fired = FALSE AND fire_at <= $1
925 RETURNING id, workflow_id, seq, fire_at, fired",
926 )
927 .bind(now)
928 .fetch_all(&self.pool)
929 .await?;
930 Ok(rows.into_iter().map(Into::into).collect())
931 }
932
933 async fn send_signal(&self, sig: &WorkflowSignal) -> Result<i64> {
936 let row: (i64,) = sqlx::query_as(
937 "INSERT INTO workflow.signals (workflow_id, name, payload, consumed, received_at) VALUES ($1, $2, $3, FALSE, $4) RETURNING id",
938 )
939 .bind(&sig.workflow_id)
940 .bind(&sig.name)
941 .bind(&sig.payload)
942 .bind(sig.received_at)
943 .fetch_one(&self.pool)
944 .await?;
945 Ok(row.0)
946 }
947
948 async fn consume_signals(
949 &self,
950 workflow_id: &str,
951 name: &str,
952 ) -> Result<Vec<WorkflowSignal>> {
953 let rows = sqlx::query_as::<_, PgSignalRow>(
954 "UPDATE workflow.signals SET consumed = TRUE
955 WHERE workflow_id = $1 AND name = $2 AND consumed = FALSE
956 RETURNING id, workflow_id, name, payload, consumed, received_at",
957 )
958 .bind(workflow_id)
959 .bind(name)
960 .fetch_all(&self.pool)
961 .await?;
962 Ok(rows.into_iter().map(Into::into).collect())
963 }
964
965 async fn create_schedule(&self, sched: &WorkflowSchedule) -> Result<()> {
968 sqlx::query(
969 "INSERT INTO workflow.schedules (namespace, name, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at)
970 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
971 )
972 .bind(&sched.namespace)
973 .bind(&sched.name)
974 .bind(&sched.workflow_type)
975 .bind(&sched.cron_expr)
976 .bind(&sched.timezone)
977 .bind(&sched.input)
978 .bind(&sched.task_queue)
979 .bind(&sched.overlap_policy)
980 .bind(sched.paused)
981 .bind(sched.last_run_at)
982 .bind(sched.next_run_at)
983 .bind(&sched.last_workflow_id)
984 .bind(sched.created_at)
985 .execute(&self.pool)
986 .await?;
987 Ok(())
988 }
989
990 async fn get_schedule(&self, namespace: &str, name: &str) -> Result<Option<WorkflowSchedule>> {
991 let row = sqlx::query_as::<_, PgScheduleRow>(
992 "SELECT namespace, name, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at FROM workflow.schedules WHERE namespace = $1 AND name = $2",
993 )
994 .bind(namespace)
995 .bind(name)
996 .fetch_optional(&self.pool)
997 .await?;
998 Ok(row.map(Into::into))
999 }
1000
1001 async fn list_schedules(&self, namespace: &str) -> Result<Vec<WorkflowSchedule>> {
1002 let rows = sqlx::query_as::<_, PgScheduleRow>(
1003 "SELECT namespace, name, workflow_type, cron_expr, timezone, input, task_queue, overlap_policy, paused, last_run_at, next_run_at, last_workflow_id, created_at FROM workflow.schedules WHERE namespace = $1 ORDER BY name",
1004 )
1005 .bind(namespace)
1006 .fetch_all(&self.pool)
1007 .await?;
1008 Ok(rows.into_iter().map(Into::into).collect())
1009 }
1010
1011 async fn update_schedule_last_run(
1012 &self,
1013 namespace: &str,
1014 name: &str,
1015 last_run_at: f64,
1016 next_run_at: f64,
1017 workflow_id: &str,
1018 ) -> Result<()> {
1019 sqlx::query(
1020 "UPDATE workflow.schedules SET last_run_at = $1, next_run_at = $2, last_workflow_id = $3 WHERE namespace = $4 AND name = $5",
1021 )
1022 .bind(last_run_at)
1023 .bind(next_run_at)
1024 .bind(workflow_id)
1025 .bind(namespace)
1026 .bind(name)
1027 .execute(&self.pool)
1028 .await?;
1029 Ok(())
1030 }
1031
1032 async fn delete_schedule(&self, namespace: &str, name: &str) -> Result<bool> {
1033 let res = sqlx::query("DELETE FROM workflow.schedules WHERE namespace = $1 AND name = $2")
1034 .bind(namespace)
1035 .bind(name)
1036 .execute(&self.pool)
1037 .await?;
1038 Ok(res.rows_affected() > 0)
1039 }
1040
1041 async fn list_archivable_workflows(
1042 &self,
1043 cutoff: f64,
1044 limit: i64,
1045 ) -> Result<Vec<WorkflowRecord>> {
1046 let rows = sqlx::query_as::<_, PgWorkflowRow>(
1047 "SELECT id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at
1048 FROM workflow.workflows
1049 WHERE status IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
1050 AND completed_at IS NOT NULL
1051 AND completed_at < $1
1052 AND archived_at IS NULL
1053 ORDER BY completed_at ASC
1054 LIMIT $2",
1055 )
1056 .bind(cutoff)
1057 .bind(limit)
1058 .fetch_all(&self.pool)
1059 .await?;
1060 Ok(rows.into_iter().map(Into::into).collect())
1061 }
1062
1063 async fn mark_archived_and_purge(
1064 &self,
1065 workflow_id: &str,
1066 archive_uri: &str,
1067 archived_at: f64,
1068 ) -> Result<()> {
1069 let mut tx = self.pool.begin().await?;
1070 sqlx::query("DELETE FROM workflow.events WHERE workflow_id = $1")
1071 .bind(workflow_id)
1072 .execute(&mut *tx)
1073 .await?;
1074 sqlx::query("DELETE FROM workflow.activities WHERE workflow_id = $1")
1075 .bind(workflow_id)
1076 .execute(&mut *tx)
1077 .await?;
1078 sqlx::query("DELETE FROM workflow.timers WHERE workflow_id = $1")
1079 .bind(workflow_id)
1080 .execute(&mut *tx)
1081 .await?;
1082 sqlx::query("DELETE FROM workflow.signals WHERE workflow_id = $1")
1083 .bind(workflow_id)
1084 .execute(&mut *tx)
1085 .await?;
1086 sqlx::query("DELETE FROM workflow.snapshots WHERE workflow_id = $1")
1087 .bind(workflow_id)
1088 .execute(&mut *tx)
1089 .await?;
1090 sqlx::query(
1091 "UPDATE workflow.workflows SET archived_at = $1, archive_uri = $2 WHERE id = $3",
1092 )
1093 .bind(archived_at)
1094 .bind(archive_uri)
1095 .bind(workflow_id)
1096 .execute(&mut *tx)
1097 .await?;
1098 tx.commit().await?;
1099 Ok(())
1100 }
1101
1102 async fn upsert_search_attributes(
1103 &self,
1104 workflow_id: &str,
1105 patch_json: &str,
1106 ) -> Result<()> {
1107 let current: Option<(Option<String>,)> =
1108 sqlx::query_as("SELECT search_attributes FROM workflow.workflows WHERE id = $1")
1109 .bind(workflow_id)
1110 .fetch_optional(&self.pool)
1111 .await?;
1112 let merged = crate::store::sqlite::merge_search_attrs(
1113 current.and_then(|(s,)| s).as_deref(),
1114 patch_json,
1115 )?;
1116 sqlx::query("UPDATE workflow.workflows SET search_attributes = $1 WHERE id = $2")
1117 .bind(merged)
1118 .bind(workflow_id)
1119 .execute(&self.pool)
1120 .await?;
1121 Ok(())
1122 }
1123
1124 async fn update_schedule(
1125 &self,
1126 namespace: &str,
1127 name: &str,
1128 patch: &SchedulePatch,
1129 ) -> Result<Option<WorkflowSchedule>> {
1130 let mut sets: Vec<String> = Vec::new();
1131 let mut idx = 1usize;
1132 if patch.cron_expr.is_some() {
1133 sets.push(format!("cron_expr = ${idx}"));
1134 idx += 1;
1135 }
1136 if patch.timezone.is_some() {
1137 sets.push(format!("timezone = ${idx}"));
1138 idx += 1;
1139 }
1140 if patch.input.is_some() {
1141 sets.push(format!("input = ${idx}"));
1142 idx += 1;
1143 }
1144 if patch.task_queue.is_some() {
1145 sets.push(format!("task_queue = ${idx}"));
1146 idx += 1;
1147 }
1148 if patch.overlap_policy.is_some() {
1149 sets.push(format!("overlap_policy = ${idx}"));
1150 idx += 1;
1151 }
1152 if sets.is_empty() {
1153 return self.get_schedule(namespace, name).await;
1154 }
1155 let sql = format!(
1156 "UPDATE workflow.schedules SET {} WHERE namespace = ${} AND name = ${}",
1157 sets.join(", "),
1158 idx,
1159 idx + 1
1160 );
1161 let mut q = sqlx::query(&sql);
1162 if let Some(ref v) = patch.cron_expr {
1163 q = q.bind(v);
1164 }
1165 if let Some(ref v) = patch.timezone {
1166 q = q.bind(v);
1167 }
1168 if let Some(ref v) = patch.input {
1169 q = q.bind(v.to_string());
1170 }
1171 if let Some(ref v) = patch.task_queue {
1172 q = q.bind(v);
1173 }
1174 if let Some(ref v) = patch.overlap_policy {
1175 q = q.bind(v);
1176 }
1177 let res = q
1178 .bind(namespace)
1179 .bind(name)
1180 .execute(&self.pool)
1181 .await?;
1182 if res.rows_affected() == 0 {
1183 return Ok(None);
1184 }
1185 self.get_schedule(namespace, name).await
1186 }
1187
1188 async fn set_schedule_paused(
1189 &self,
1190 namespace: &str,
1191 name: &str,
1192 paused: bool,
1193 ) -> Result<Option<WorkflowSchedule>> {
1194 let res = sqlx::query(
1195 "UPDATE workflow.schedules SET paused = $1 WHERE namespace = $2 AND name = $3",
1196 )
1197 .bind(paused)
1198 .bind(namespace)
1199 .bind(name)
1200 .execute(&self.pool)
1201 .await?;
1202 if res.rows_affected() == 0 {
1203 return Ok(None);
1204 }
1205 self.get_schedule(namespace, name).await
1206 }
1207
1208 async fn register_worker(&self, w: &WorkflowWorker) -> Result<()> {
1211 sqlx::query(
1212 "INSERT INTO workflow.workers (id, namespace, identity, task_queue, workflows, activities, max_concurrent_workflows, max_concurrent_activities, active_tasks, last_heartbeat, registered_at)
1213 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
1214 ON CONFLICT (id) DO UPDATE SET last_heartbeat = EXCLUDED.last_heartbeat, identity = EXCLUDED.identity",
1215 )
1216 .bind(&w.id)
1217 .bind(&w.namespace)
1218 .bind(&w.identity)
1219 .bind(&w.task_queue)
1220 .bind(&w.workflows)
1221 .bind(&w.activities)
1222 .bind(w.max_concurrent_workflows)
1223 .bind(w.max_concurrent_activities)
1224 .bind(w.active_tasks)
1225 .bind(w.last_heartbeat)
1226 .bind(w.registered_at)
1227 .execute(&self.pool)
1228 .await?;
1229 Ok(())
1230 }
1231
1232 async fn heartbeat_worker(&self, id: &str, now: f64) -> Result<()> {
1233 sqlx::query("UPDATE workflow.workers SET last_heartbeat = $1 WHERE id = $2")
1234 .bind(now)
1235 .bind(id)
1236 .execute(&self.pool)
1237 .await?;
1238 Ok(())
1239 }
1240
1241 async fn list_workers(&self, namespace: &str) -> Result<Vec<WorkflowWorker>> {
1242 let rows = sqlx::query_as::<_, PgWorkerRow>(
1243 "SELECT id, namespace, identity, task_queue, workflows, activities, max_concurrent_workflows, max_concurrent_activities, active_tasks, last_heartbeat, registered_at FROM workflow.workers WHERE namespace = $1 ORDER BY registered_at",
1244 )
1245 .bind(namespace)
1246 .fetch_all(&self.pool)
1247 .await?;
1248 Ok(rows.into_iter().map(Into::into).collect())
1249 }
1250
1251 async fn remove_dead_workers(&self, cutoff: f64) -> Result<Vec<String>> {
1252 let rows: Vec<(String,)> =
1253 sqlx::query_as("SELECT id FROM workflow.workers WHERE last_heartbeat < $1")
1254 .bind(cutoff)
1255 .fetch_all(&self.pool)
1256 .await?;
1257 let ids: Vec<String> = rows.into_iter().map(|r| r.0).collect();
1258 if !ids.is_empty() {
1259 sqlx::query("DELETE FROM workflow.workers WHERE last_heartbeat < $1")
1260 .bind(cutoff)
1261 .execute(&self.pool)
1262 .await?;
1263 }
1264 Ok(ids)
1265 }
1266
1267 async fn list_child_workflows(&self, parent_id: &str) -> Result<Vec<WorkflowRecord>> {
1270 let rows = sqlx::query_as::<_, PgWorkflowRow>(
1271 "SELECT id, namespace, run_id, workflow_type, task_queue, status, input, result, error, parent_id, claimed_by, search_attributes, archived_at, archive_uri, created_at, updated_at, completed_at
1272 FROM workflow.workflows WHERE parent_id = $1 ORDER BY created_at ASC",
1273 )
1274 .bind(parent_id)
1275 .fetch_all(&self.pool)
1276 .await?;
1277 Ok(rows.into_iter().map(Into::into).collect())
1278 }
1279
1280 async fn create_snapshot(
1283 &self,
1284 workflow_id: &str,
1285 event_seq: i32,
1286 state_json: &str,
1287 ) -> Result<()> {
1288 sqlx::query(
1289 "INSERT INTO workflow.snapshots (workflow_id, event_seq, state_json, created_at)
1290 VALUES ($1, $2, $3, $4)
1291 ON CONFLICT (workflow_id, event_seq) DO UPDATE SET state_json = EXCLUDED.state_json, created_at = EXCLUDED.created_at",
1292 )
1293 .bind(workflow_id)
1294 .bind(event_seq)
1295 .bind(state_json)
1296 .bind(timestamp_now())
1297 .execute(&self.pool)
1298 .await?;
1299 Ok(())
1300 }
1301
1302 async fn get_latest_snapshot(
1303 &self,
1304 workflow_id: &str,
1305 ) -> Result<Option<WorkflowSnapshot>> {
1306 let row = sqlx::query_as::<_, (String, i32, String, f64)>(
1307 "SELECT workflow_id, event_seq, state_json, created_at
1308 FROM workflow.snapshots WHERE workflow_id = $1
1309 ORDER BY event_seq DESC LIMIT 1",
1310 )
1311 .bind(workflow_id)
1312 .fetch_optional(&self.pool)
1313 .await?;
1314
1315 Ok(row.map(|(workflow_id, event_seq, state_json, created_at)| WorkflowSnapshot {
1316 workflow_id,
1317 event_seq,
1318 state_json,
1319 created_at,
1320 }))
1321 }
1322
1323 async fn get_queue_stats(&self, namespace: &str) -> Result<Vec<crate::store::QueueStats>> {
1326 let rows = sqlx::query_as::<_, (String, i64, i64, i64)>(
1327 "SELECT
1328 a.task_queue AS queue,
1329 SUM(CASE WHEN a.status = 'PENDING' THEN 1 ELSE 0 END) AS pending,
1330 SUM(CASE WHEN a.status = 'RUNNING' THEN 1 ELSE 0 END) AS running,
1331 (SELECT COUNT(*) FROM workflow.workers w WHERE w.task_queue = a.task_queue AND w.namespace = $1) AS workers
1332 FROM workflow.activities a
1333 JOIN workflow.workflows wf ON a.workflow_id = wf.id AND wf.namespace = $1
1334 GROUP BY a.task_queue",
1335 )
1336 .bind(namespace)
1337 .fetch_all(&self.pool)
1338 .await?;
1339
1340 Ok(rows
1341 .into_iter()
1342 .map(|(queue, pending, running, workers)| crate::store::QueueStats {
1343 queue,
1344 pending_activities: pending,
1345 running_activities: running,
1346 workers,
1347 })
1348 .collect())
1349 }
1350
1351 async fn try_acquire_scheduler_lock(&self) -> Result<bool> {
1354 let row: (bool,) =
1358 sqlx::query_as("SELECT pg_try_advisory_lock(42)")
1359 .fetch_one(&self.pool)
1360 .await?;
1361 Ok(row.0)
1362 }
1363
1364}
1365
1366fn timestamp_now() -> f64 {
1367 std::time::SystemTime::now()
1368 .duration_since(std::time::UNIX_EPOCH)
1369 .unwrap()
1370 .as_secs_f64()
1371}
1372
1373#[derive(sqlx::FromRow)]
1376struct PgWorkflowRow {
1377 id: String,
1378 namespace: String,
1379 run_id: String,
1380 workflow_type: String,
1381 task_queue: String,
1382 status: String,
1383 input: Option<String>,
1384 result: Option<String>,
1385 error: Option<String>,
1386 parent_id: Option<String>,
1387 claimed_by: Option<String>,
1388 search_attributes: Option<String>,
1389 archived_at: Option<f64>,
1390 archive_uri: Option<String>,
1391 created_at: f64,
1392 updated_at: f64,
1393 completed_at: Option<f64>,
1394}
1395
1396impl From<PgWorkflowRow> for WorkflowRecord {
1397 fn from(r: PgWorkflowRow) -> Self {
1398 Self {
1399 id: r.id,
1400 namespace: r.namespace,
1401 run_id: r.run_id,
1402 workflow_type: r.workflow_type,
1403 task_queue: r.task_queue,
1404 status: r.status,
1405 input: r.input,
1406 result: r.result,
1407 error: r.error,
1408 parent_id: r.parent_id,
1409 claimed_by: r.claimed_by,
1410 search_attributes: r.search_attributes,
1411 archived_at: r.archived_at,
1412 archive_uri: r.archive_uri,
1413 created_at: r.created_at,
1414 updated_at: r.updated_at,
1415 completed_at: r.completed_at,
1416 }
1417 }
1418}
1419
1420#[derive(sqlx::FromRow)]
1421struct PgEventRow {
1422 id: i64,
1423 workflow_id: String,
1424 seq: i32,
1425 event_type: String,
1426 payload: Option<String>,
1427 timestamp: f64,
1428}
1429
1430impl From<PgEventRow> for WorkflowEvent {
1431 fn from(r: PgEventRow) -> Self {
1432 Self {
1433 id: Some(r.id),
1434 workflow_id: r.workflow_id,
1435 seq: r.seq,
1436 event_type: r.event_type,
1437 payload: r.payload,
1438 timestamp: r.timestamp,
1439 }
1440 }
1441}
1442
1443#[derive(sqlx::FromRow)]
1444struct PgActivityRow {
1445 id: i64,
1446 workflow_id: String,
1447 seq: i32,
1448 name: String,
1449 task_queue: String,
1450 input: Option<String>,
1451 status: String,
1452 result: Option<String>,
1453 error: Option<String>,
1454 attempt: i32,
1455 max_attempts: i32,
1456 initial_interval_secs: f64,
1457 backoff_coefficient: f64,
1458 start_to_close_secs: f64,
1459 heartbeat_timeout_secs: Option<f64>,
1460 claimed_by: Option<String>,
1461 scheduled_at: f64,
1462 started_at: Option<f64>,
1463 completed_at: Option<f64>,
1464 last_heartbeat: Option<f64>,
1465}
1466
1467impl From<PgActivityRow> for WorkflowActivity {
1468 fn from(r: PgActivityRow) -> Self {
1469 Self {
1470 id: Some(r.id),
1471 workflow_id: r.workflow_id,
1472 seq: r.seq,
1473 name: r.name,
1474 task_queue: r.task_queue,
1475 input: r.input,
1476 status: r.status,
1477 result: r.result,
1478 error: r.error,
1479 attempt: r.attempt,
1480 max_attempts: r.max_attempts,
1481 initial_interval_secs: r.initial_interval_secs,
1482 backoff_coefficient: r.backoff_coefficient,
1483 start_to_close_secs: r.start_to_close_secs,
1484 heartbeat_timeout_secs: r.heartbeat_timeout_secs,
1485 claimed_by: r.claimed_by,
1486 scheduled_at: r.scheduled_at,
1487 started_at: r.started_at,
1488 completed_at: r.completed_at,
1489 last_heartbeat: r.last_heartbeat,
1490 }
1491 }
1492}
1493
1494#[derive(sqlx::FromRow)]
1495struct PgTimerRow {
1496 id: i64,
1497 workflow_id: String,
1498 seq: i32,
1499 fire_at: f64,
1500 fired: bool,
1501}
1502
1503impl From<PgTimerRow> for WorkflowTimer {
1504 fn from(r: PgTimerRow) -> Self {
1505 Self {
1506 id: Some(r.id),
1507 workflow_id: r.workflow_id,
1508 seq: r.seq,
1509 fire_at: r.fire_at,
1510 fired: r.fired,
1511 }
1512 }
1513}
1514
1515#[derive(sqlx::FromRow)]
1516struct PgSignalRow {
1517 id: i64,
1518 workflow_id: String,
1519 name: String,
1520 payload: Option<String>,
1521 consumed: bool,
1522 received_at: f64,
1523}
1524
1525impl From<PgSignalRow> for WorkflowSignal {
1526 fn from(r: PgSignalRow) -> Self {
1527 Self {
1528 id: Some(r.id),
1529 workflow_id: r.workflow_id,
1530 name: r.name,
1531 payload: r.payload,
1532 consumed: r.consumed,
1533 received_at: r.received_at,
1534 }
1535 }
1536}
1537
1538#[derive(sqlx::FromRow)]
1539struct PgScheduleRow {
1540 namespace: String,
1541 name: String,
1542 workflow_type: String,
1543 cron_expr: String,
1544 timezone: String,
1545 input: Option<String>,
1546 task_queue: String,
1547 overlap_policy: String,
1548 paused: bool,
1549 last_run_at: Option<f64>,
1550 next_run_at: Option<f64>,
1551 last_workflow_id: Option<String>,
1552 created_at: f64,
1553}
1554
1555impl From<PgScheduleRow> for WorkflowSchedule {
1556 fn from(r: PgScheduleRow) -> Self {
1557 Self {
1558 namespace: r.namespace,
1559 name: r.name,
1560 workflow_type: r.workflow_type,
1561 cron_expr: r.cron_expr,
1562 timezone: r.timezone,
1563 input: r.input,
1564 task_queue: r.task_queue,
1565 overlap_policy: r.overlap_policy,
1566 paused: r.paused,
1567 last_run_at: r.last_run_at,
1568 next_run_at: r.next_run_at,
1569 last_workflow_id: r.last_workflow_id,
1570 created_at: r.created_at,
1571 }
1572 }
1573}
1574
1575#[derive(sqlx::FromRow)]
1576struct PgWorkerRow {
1577 id: String,
1578 namespace: String,
1579 identity: String,
1580 task_queue: String,
1581 workflows: Option<String>,
1582 activities: Option<String>,
1583 max_concurrent_workflows: i32,
1584 max_concurrent_activities: i32,
1585 active_tasks: i32,
1586 last_heartbeat: f64,
1587 registered_at: f64,
1588}
1589
1590impl From<PgWorkerRow> for WorkflowWorker {
1591 fn from(r: PgWorkerRow) -> Self {
1592 Self {
1593 id: r.id,
1594 namespace: r.namespace,
1595 identity: r.identity,
1596 task_queue: r.task_queue,
1597 workflows: r.workflows,
1598 activities: r.activities,
1599 max_concurrent_workflows: r.max_concurrent_workflows,
1600 max_concurrent_activities: r.max_concurrent_activities,
1601 active_tasks: r.active_tasks,
1602 last_heartbeat: r.last_heartbeat,
1603 registered_at: r.registered_at,
1604 }
1605 }
1606}
1607
1608#[cfg(test)]
1609mod tests {
1610 use super::*;
1611
1612 #[test]
1613 fn sanitise_schema_keeps_statements_intact() {
1614 let input = "CREATE TABLE foo (x INT);\nCREATE INDEX idx_foo ON foo(x);\n";
1615 let out = sanitise_schema(input);
1616 assert_eq!(out.len(), 2);
1617 assert!(out[0].starts_with("CREATE TABLE foo"));
1618 assert!(out[1].starts_with("CREATE INDEX idx_foo"));
1619 }
1620
1621 #[test]
1622 fn sanitise_schema_drops_pure_comment_lines() {
1623 let input = "-- header comment\nCREATE TABLE foo (x INT);\n-- trailing comment\n";
1624 let out = sanitise_schema(input);
1625 assert_eq!(out.len(), 1);
1626 assert!(out[0].starts_with("CREATE TABLE foo"));
1627 }
1628
1629 #[test]
1630 fn sanitise_schema_ignores_semicolons_inside_comment_prose() {
1631 let input = "\
1635CREATE TABLE foo (x INT);
1636-- Idempotent across startups; fresh installs pick the column up from the
1637-- CREATE TABLE above so the ADD is a no-op.
1638";
1639 let out = sanitise_schema(input);
1640 assert_eq!(
1641 out.len(),
1642 1,
1643 "expected 1 real statement, got {}: {:?}",
1644 out.len(),
1645 out
1646 );
1647 assert!(out[0].starts_with("CREATE TABLE foo"));
1648 }
1649
1650 #[test]
1651 fn sanitise_schema_drops_indented_comment_lines() {
1652 let input = " -- indented comment\n\tCREATE TABLE foo (x INT);\n";
1653 let out = sanitise_schema(input);
1654 assert_eq!(out.len(), 1);
1655 assert!(out[0].contains("CREATE TABLE foo"));
1656 }
1657
1658 #[test]
1659 fn sanitise_schema_real_constant_produces_only_ddl() {
1660 for stmt in sanitise_schema(SCHEMA) {
1664 let first_word = stmt
1665 .split_whitespace()
1666 .next()
1667 .expect("non-empty statement")
1668 .to_uppercase();
1669 assert!(
1670 matches!(
1671 first_word.as_str(),
1672 "CREATE" | "INSERT" | "UPDATE" | "DROP" | "ALTER" | "WITH"
1673 ),
1674 "SCHEMA produced non-DDL statement starting with {first_word:?}: {stmt:?}"
1675 );
1676 }
1677 }
1678}