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