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,) =
370 sqlx::query_as("SELECT pg_try_advisory_lock(1)")
371 .fetch_one(&self.pool)
372 .await?;
373 Ok(row.0)
374 }
375}
376
377impl WorkflowStore for PostgresStore {
378 async fn create_namespace(&self, name: &str) -> Result<()> {
381 sqlx::query("INSERT INTO workflow.namespaces (name, created_at) VALUES ($1, EXTRACT(EPOCH FROM NOW()))")
382 .bind(name)
383 .execute(&self.pool)
384 .await?;
385 Ok(())
386 }
387
388 async fn list_namespaces(&self) -> Result<Vec<crate::store::NamespaceRecord>> {
389 let rows = sqlx::query_as::<_, (String, f64)>(
390 "SELECT name, created_at FROM workflow.namespaces ORDER BY name",
391 )
392 .fetch_all(&self.pool)
393 .await?;
394 Ok(rows
395 .into_iter()
396 .map(|(name, created_at)| crate::store::NamespaceRecord { name, created_at })
397 .collect())
398 }
399
400 async fn delete_namespace(&self, name: &str) -> Result<bool> {
401 let res = sqlx::query("DELETE FROM workflow.namespaces WHERE name = $1 AND name != 'main'")
402 .bind(name)
403 .execute(&self.pool)
404 .await?;
405 Ok(res.rows_affected() > 0)
406 }
407
408 async fn get_namespace_stats(&self, namespace: &str) -> Result<crate::store::NamespaceStats> {
409 let total: (i64,) = 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!(" ORDER BY created_at DESC LIMIT ${} OFFSET ${}", idx, idx + 1));
534
535 let mut q = sqlx::query_as::<_, PgWorkflowRow>(&sql)
536 .bind(namespace)
537 .bind(&status_str)
538 .bind(workflow_type);
539 for (key, value) in &filter_pairs {
540 q = q.bind(key.clone());
541 let as_text = match value {
543 serde_json::Value::String(s) => s.clone(),
544 other => other.to_string(),
545 };
546 q = q.bind(as_text);
547 }
548 let rows = q.bind(limit).bind(offset).fetch_all(&self.pool).await?;
549 Ok(rows.into_iter().map(Into::into).collect())
550 }
551
552 async fn update_workflow_status(
553 &self,
554 id: &str,
555 status: WorkflowStatus,
556 result: Option<&str>,
557 error: Option<&str>,
558 ) -> Result<()> {
559 let now = timestamp_now();
560 let completed_at = if status.is_terminal() { Some(now) } else { None };
561 sqlx::query(
562 "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",
563 )
564 .bind(status.to_string())
565 .bind(result)
566 .bind(error)
567 .bind(now)
568 .bind(completed_at)
569 .bind(id)
570 .execute(&self.pool)
571 .await?;
572 Ok(())
573 }
574
575 async fn claim_workflow(&self, id: &str, worker_id: &str) -> Result<bool> {
576 let res = sqlx::query(
577 "UPDATE workflow.workflows SET claimed_by = $1, status = 'RUNNING', updated_at = $2 WHERE id = $3 AND claimed_by IS NULL",
578 )
579 .bind(worker_id)
580 .bind(timestamp_now())
581 .bind(id)
582 .execute(&self.pool)
583 .await?;
584 Ok(res.rows_affected() > 0)
585 }
586
587 async fn mark_workflow_dispatchable(&self, workflow_id: &str) -> Result<()> {
588 sqlx::query("UPDATE workflow.workflows SET needs_dispatch = TRUE WHERE id = $1")
589 .bind(workflow_id)
590 .execute(&self.pool)
591 .await?;
592 Ok(())
593 }
594
595 async fn claim_workflow_task(
596 &self,
597 task_queue: &str,
598 worker_id: &str,
599 ) -> Result<Option<WorkflowRecord>> {
600 let now = timestamp_now();
601 let row = sqlx::query_as::<_, PgWorkflowRow>(
604 "UPDATE workflow.workflows
605 SET dispatch_claimed_by = $1, dispatch_last_heartbeat = $2, needs_dispatch = FALSE
606 WHERE id = (
607 SELECT id FROM workflow.workflows
608 WHERE task_queue = $3
609 AND needs_dispatch = TRUE
610 AND dispatch_claimed_by IS NULL
611 AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
612 ORDER BY updated_at ASC
613 FOR UPDATE SKIP LOCKED
614 LIMIT 1
615 )
616 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",
617 )
618 .bind(worker_id)
619 .bind(now)
620 .bind(task_queue)
621 .fetch_optional(&self.pool)
622 .await?;
623 Ok(row.map(Into::into))
624 }
625
626 async fn release_workflow_task(&self, workflow_id: &str, worker_id: &str) -> Result<()> {
627 sqlx::query(
628 "UPDATE workflow.workflows
629 SET dispatch_claimed_by = NULL, dispatch_last_heartbeat = NULL
630 WHERE id = $1 AND dispatch_claimed_by = $2",
631 )
632 .bind(workflow_id)
633 .bind(worker_id)
634 .execute(&self.pool)
635 .await?;
636 Ok(())
637 }
638
639 async fn release_stale_dispatch_leases(
640 &self,
641 now: f64,
642 timeout_secs: f64,
643 ) -> Result<u64> {
644 let res = sqlx::query(
645 "UPDATE workflow.workflows
646 SET dispatch_claimed_by = NULL,
647 dispatch_last_heartbeat = NULL,
648 needs_dispatch = TRUE
649 WHERE dispatch_claimed_by IS NOT NULL
650 AND ($1 - dispatch_last_heartbeat) > $2
651 AND status NOT IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')",
652 )
653 .bind(now)
654 .bind(timeout_secs)
655 .execute(&self.pool)
656 .await?;
657 Ok(res.rows_affected())
658 }
659
660 async fn append_event(&self, ev: &WorkflowEvent) -> Result<i64> {
663 let row: (i64,) = sqlx::query_as(
664 "INSERT INTO workflow.events (workflow_id, seq, event_type, payload, timestamp) VALUES ($1, $2, $3, $4, $5) RETURNING id",
665 )
666 .bind(&ev.workflow_id)
667 .bind(ev.seq)
668 .bind(&ev.event_type)
669 .bind(&ev.payload)
670 .bind(ev.timestamp)
671 .fetch_one(&self.pool)
672 .await?;
673 Ok(row.0)
674 }
675
676 async fn list_events(&self, workflow_id: &str) -> Result<Vec<WorkflowEvent>> {
677 let rows = sqlx::query_as::<_, PgEventRow>(
678 "SELECT id, workflow_id, seq, event_type, payload, timestamp FROM workflow.events WHERE workflow_id = $1 ORDER BY seq ASC",
679 )
680 .bind(workflow_id)
681 .fetch_all(&self.pool)
682 .await?;
683 Ok(rows.into_iter().map(Into::into).collect())
684 }
685
686 async fn get_event_count(&self, workflow_id: &str) -> Result<i64> {
687 let row: (i64,) =
688 sqlx::query_as("SELECT COUNT(*) FROM workflow.events WHERE workflow_id = $1")
689 .bind(workflow_id)
690 .fetch_one(&self.pool)
691 .await?;
692 Ok(row.0)
693 }
694
695 async fn create_activity(&self, act: &WorkflowActivity) -> Result<i64> {
698 let row: (i64,) = sqlx::query_as(
699 "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)
700 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id",
701 )
702 .bind(&act.workflow_id)
703 .bind(act.seq)
704 .bind(&act.name)
705 .bind(&act.task_queue)
706 .bind(&act.input)
707 .bind(&act.status)
708 .bind(act.attempt)
709 .bind(act.max_attempts)
710 .bind(act.initial_interval_secs)
711 .bind(act.backoff_coefficient)
712 .bind(act.start_to_close_secs)
713 .bind(act.heartbeat_timeout_secs)
714 .bind(act.scheduled_at)
715 .fetch_one(&self.pool)
716 .await?;
717 Ok(row.0)
718 }
719
720 async fn get_activity(&self, id: i64) -> Result<Option<WorkflowActivity>> {
721 let row = sqlx::query_as::<_, PgActivityRow>(
722 "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
723 FROM workflow.activities WHERE id = $1",
724 )
725 .bind(id)
726 .fetch_optional(&self.pool)
727 .await?;
728 Ok(row.map(Into::into))
729 }
730
731 async fn get_activity_by_workflow_seq(
732 &self,
733 workflow_id: &str,
734 seq: i32,
735 ) -> Result<Option<WorkflowActivity>> {
736 let row = sqlx::query_as::<_, PgActivityRow>(
737 "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
738 FROM workflow.activities WHERE workflow_id = $1 AND seq = $2",
739 )
740 .bind(workflow_id)
741 .bind(seq)
742 .fetch_optional(&self.pool)
743 .await?;
744 Ok(row.map(Into::into))
745 }
746
747 async fn claim_activity(
748 &self,
749 task_queue: &str,
750 worker_id: &str,
751 ) -> Result<Option<WorkflowActivity>> {
752 let now = timestamp_now();
753 let row = sqlx::query_as::<_, PgActivityRow>(
756 "UPDATE workflow.activities SET status = 'RUNNING', claimed_by = $1, started_at = $2
757 WHERE id = (
758 SELECT id FROM workflow.activities
759 WHERE task_queue = $3 AND status = 'PENDING'
760 ORDER BY scheduled_at ASC
761 FOR UPDATE SKIP LOCKED
762 LIMIT 1
763 )
764 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",
765 )
766 .bind(worker_id)
767 .bind(now)
768 .bind(task_queue)
769 .fetch_optional(&self.pool)
770 .await?;
771 Ok(row.map(Into::into))
772 }
773
774 async fn requeue_activity_for_retry(
775 &self,
776 id: i64,
777 next_attempt: i32,
778 next_scheduled_at: f64,
779 ) -> Result<()> {
780 sqlx::query(
781 "UPDATE workflow.activities
782 SET status = 'PENDING', attempt = $1, scheduled_at = $2,
783 claimed_by = NULL, started_at = NULL, last_heartbeat = NULL,
784 error = NULL
785 WHERE id = $3",
786 )
787 .bind(next_attempt)
788 .bind(next_scheduled_at)
789 .bind(id)
790 .execute(&self.pool)
791 .await?;
792 Ok(())
793 }
794
795 async fn complete_activity(
796 &self,
797 id: i64,
798 result: Option<&str>,
799 error: Option<&str>,
800 failed: bool,
801 ) -> Result<()> {
802 let status = if failed { "FAILED" } else { "COMPLETED" };
803 sqlx::query(
804 "UPDATE workflow.activities SET status = $1, result = $2, error = $3, completed_at = $4 WHERE id = $5",
805 )
806 .bind(status)
807 .bind(result)
808 .bind(error)
809 .bind(timestamp_now())
810 .bind(id)
811 .execute(&self.pool)
812 .await?;
813 Ok(())
814 }
815
816 async fn heartbeat_activity(&self, id: i64, _details: Option<&str>) -> Result<()> {
817 sqlx::query("UPDATE workflow.activities SET last_heartbeat = $1 WHERE id = $2")
818 .bind(timestamp_now())
819 .bind(id)
820 .execute(&self.pool)
821 .await?;
822 Ok(())
823 }
824
825 async fn get_timed_out_activities(&self, now: f64) -> Result<Vec<WorkflowActivity>> {
826 let rows = sqlx::query_as::<_, PgActivityRow>(
827 "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
828 FROM workflow.activities
829 WHERE status = 'RUNNING'
830 AND heartbeat_timeout_secs IS NOT NULL
831 AND last_heartbeat IS NOT NULL
832 AND ($1 - last_heartbeat) > heartbeat_timeout_secs",
833 )
834 .bind(now)
835 .fetch_all(&self.pool)
836 .await?;
837 Ok(rows.into_iter().map(Into::into).collect())
838 }
839
840 async fn create_timer(&self, timer: &WorkflowTimer) -> Result<i64> {
843 let inserted: Option<(i64,)> = sqlx::query_as(
846 "INSERT INTO workflow.timers (workflow_id, seq, fire_at, fired)
847 VALUES ($1, $2, $3, FALSE)
848 ON CONFLICT (workflow_id, seq) DO NOTHING
849 RETURNING id",
850 )
851 .bind(&timer.workflow_id)
852 .bind(timer.seq)
853 .bind(timer.fire_at)
854 .fetch_optional(&self.pool)
855 .await?;
856
857 if let Some((id,)) = inserted {
858 return Ok(id);
859 }
860
861 let (id,): (i64,) = sqlx::query_as(
863 "SELECT id FROM workflow.timers WHERE workflow_id = $1 AND seq = $2",
864 )
865 .bind(&timer.workflow_id)
866 .bind(timer.seq)
867 .fetch_one(&self.pool)
868 .await?;
869 Ok(id)
870 }
871
872 async fn cancel_pending_activities(&self, workflow_id: &str) -> Result<u64> {
873 let res = sqlx::query(
874 "UPDATE workflow.activities SET status = 'CANCELLED', completed_at = $1
875 WHERE workflow_id = $2 AND status = 'PENDING'",
876 )
877 .bind(timestamp_now())
878 .bind(workflow_id)
879 .execute(&self.pool)
880 .await?;
881 Ok(res.rows_affected())
882 }
883
884 async fn cancel_pending_timers(&self, workflow_id: &str) -> Result<u64> {
885 let res = sqlx::query(
886 "UPDATE workflow.timers SET fired = TRUE
887 WHERE workflow_id = $1 AND fired = FALSE",
888 )
889 .bind(workflow_id)
890 .execute(&self.pool)
891 .await?;
892 Ok(res.rows_affected())
893 }
894
895 async fn get_timer_by_workflow_seq(
896 &self,
897 workflow_id: &str,
898 seq: i32,
899 ) -> Result<Option<WorkflowTimer>> {
900 let row = sqlx::query_as::<_, PgTimerRow>(
901 "SELECT id, workflow_id, seq, fire_at, fired
902 FROM workflow.timers WHERE workflow_id = $1 AND seq = $2",
903 )
904 .bind(workflow_id)
905 .bind(seq)
906 .fetch_optional(&self.pool)
907 .await?;
908 Ok(row.map(Into::into))
909 }
910
911 async fn fire_due_timers(&self, now: f64) -> Result<Vec<WorkflowTimer>> {
912 let rows = sqlx::query_as::<_, PgTimerRow>(
913 "UPDATE workflow.timers SET fired = TRUE
914 WHERE fired = FALSE AND fire_at <= $1
915 RETURNING id, workflow_id, seq, fire_at, fired",
916 )
917 .bind(now)
918 .fetch_all(&self.pool)
919 .await?;
920 Ok(rows.into_iter().map(Into::into).collect())
921 }
922
923 async fn send_signal(&self, sig: &WorkflowSignal) -> Result<i64> {
926 let row: (i64,) = sqlx::query_as(
927 "INSERT INTO workflow.signals (workflow_id, name, payload, consumed, received_at) VALUES ($1, $2, $3, FALSE, $4) RETURNING id",
928 )
929 .bind(&sig.workflow_id)
930 .bind(&sig.name)
931 .bind(&sig.payload)
932 .bind(sig.received_at)
933 .fetch_one(&self.pool)
934 .await?;
935 Ok(row.0)
936 }
937
938 async fn consume_signals(
939 &self,
940 workflow_id: &str,
941 name: &str,
942 ) -> Result<Vec<WorkflowSignal>> {
943 let rows = sqlx::query_as::<_, PgSignalRow>(
944 "UPDATE workflow.signals SET consumed = TRUE
945 WHERE workflow_id = $1 AND name = $2 AND consumed = FALSE
946 RETURNING id, workflow_id, name, payload, consumed, received_at",
947 )
948 .bind(workflow_id)
949 .bind(name)
950 .fetch_all(&self.pool)
951 .await?;
952 Ok(rows.into_iter().map(Into::into).collect())
953 }
954
955 async fn create_schedule(&self, sched: &WorkflowSchedule) -> Result<()> {
958 sqlx::query(
959 "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)
960 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
961 )
962 .bind(&sched.namespace)
963 .bind(&sched.name)
964 .bind(&sched.workflow_type)
965 .bind(&sched.cron_expr)
966 .bind(&sched.timezone)
967 .bind(&sched.input)
968 .bind(&sched.task_queue)
969 .bind(&sched.overlap_policy)
970 .bind(sched.paused)
971 .bind(sched.last_run_at)
972 .bind(sched.next_run_at)
973 .bind(&sched.last_workflow_id)
974 .bind(sched.created_at)
975 .execute(&self.pool)
976 .await?;
977 Ok(())
978 }
979
980 async fn get_schedule(&self, namespace: &str, name: &str) -> Result<Option<WorkflowSchedule>> {
981 let row = sqlx::query_as::<_, PgScheduleRow>(
982 "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",
983 )
984 .bind(namespace)
985 .bind(name)
986 .fetch_optional(&self.pool)
987 .await?;
988 Ok(row.map(Into::into))
989 }
990
991 async fn list_schedules(&self, namespace: &str) -> Result<Vec<WorkflowSchedule>> {
992 let rows = sqlx::query_as::<_, PgScheduleRow>(
993 "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",
994 )
995 .bind(namespace)
996 .fetch_all(&self.pool)
997 .await?;
998 Ok(rows.into_iter().map(Into::into).collect())
999 }
1000
1001 async fn update_schedule_last_run(
1002 &self,
1003 namespace: &str,
1004 name: &str,
1005 last_run_at: f64,
1006 next_run_at: f64,
1007 workflow_id: &str,
1008 ) -> Result<()> {
1009 sqlx::query(
1010 "UPDATE workflow.schedules SET last_run_at = $1, next_run_at = $2, last_workflow_id = $3 WHERE namespace = $4 AND name = $5",
1011 )
1012 .bind(last_run_at)
1013 .bind(next_run_at)
1014 .bind(workflow_id)
1015 .bind(namespace)
1016 .bind(name)
1017 .execute(&self.pool)
1018 .await?;
1019 Ok(())
1020 }
1021
1022 async fn delete_schedule(&self, namespace: &str, name: &str) -> Result<bool> {
1023 let res = sqlx::query("DELETE FROM workflow.schedules WHERE namespace = $1 AND name = $2")
1024 .bind(namespace)
1025 .bind(name)
1026 .execute(&self.pool)
1027 .await?;
1028 Ok(res.rows_affected() > 0)
1029 }
1030
1031 async fn list_archivable_workflows(
1032 &self,
1033 cutoff: f64,
1034 limit: i64,
1035 ) -> Result<Vec<WorkflowRecord>> {
1036 let rows = sqlx::query_as::<_, PgWorkflowRow>(
1037 "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
1038 FROM workflow.workflows
1039 WHERE status IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMED_OUT')
1040 AND completed_at IS NOT NULL
1041 AND completed_at < $1
1042 AND archived_at IS NULL
1043 ORDER BY completed_at ASC
1044 LIMIT $2",
1045 )
1046 .bind(cutoff)
1047 .bind(limit)
1048 .fetch_all(&self.pool)
1049 .await?;
1050 Ok(rows.into_iter().map(Into::into).collect())
1051 }
1052
1053 async fn mark_archived_and_purge(
1054 &self,
1055 workflow_id: &str,
1056 archive_uri: &str,
1057 archived_at: f64,
1058 ) -> Result<()> {
1059 let mut tx = self.pool.begin().await?;
1060 sqlx::query("DELETE FROM workflow.events WHERE workflow_id = $1")
1061 .bind(workflow_id)
1062 .execute(&mut *tx)
1063 .await?;
1064 sqlx::query("DELETE FROM workflow.activities WHERE workflow_id = $1")
1065 .bind(workflow_id)
1066 .execute(&mut *tx)
1067 .await?;
1068 sqlx::query("DELETE FROM workflow.timers WHERE workflow_id = $1")
1069 .bind(workflow_id)
1070 .execute(&mut *tx)
1071 .await?;
1072 sqlx::query("DELETE FROM workflow.signals WHERE workflow_id = $1")
1073 .bind(workflow_id)
1074 .execute(&mut *tx)
1075 .await?;
1076 sqlx::query("DELETE FROM workflow.snapshots WHERE workflow_id = $1")
1077 .bind(workflow_id)
1078 .execute(&mut *tx)
1079 .await?;
1080 sqlx::query(
1081 "UPDATE workflow.workflows SET archived_at = $1, archive_uri = $2 WHERE id = $3",
1082 )
1083 .bind(archived_at)
1084 .bind(archive_uri)
1085 .bind(workflow_id)
1086 .execute(&mut *tx)
1087 .await?;
1088 tx.commit().await?;
1089 Ok(())
1090 }
1091
1092 async fn upsert_search_attributes(
1093 &self,
1094 workflow_id: &str,
1095 patch_json: &str,
1096 ) -> Result<()> {
1097 let current: Option<(Option<String>,)> =
1098 sqlx::query_as("SELECT search_attributes FROM workflow.workflows WHERE id = $1")
1099 .bind(workflow_id)
1100 .fetch_optional(&self.pool)
1101 .await?;
1102 let merged = crate::store::sqlite::merge_search_attrs(
1103 current.and_then(|(s,)| s).as_deref(),
1104 patch_json,
1105 )?;
1106 sqlx::query("UPDATE workflow.workflows SET search_attributes = $1 WHERE id = $2")
1107 .bind(merged)
1108 .bind(workflow_id)
1109 .execute(&self.pool)
1110 .await?;
1111 Ok(())
1112 }
1113
1114 async fn update_schedule(
1115 &self,
1116 namespace: &str,
1117 name: &str,
1118 patch: &SchedulePatch,
1119 ) -> Result<Option<WorkflowSchedule>> {
1120 let mut sets: Vec<String> = Vec::new();
1121 let mut idx = 1usize;
1122 if patch.cron_expr.is_some() {
1123 sets.push(format!("cron_expr = ${idx}"));
1124 idx += 1;
1125 }
1126 if patch.timezone.is_some() {
1127 sets.push(format!("timezone = ${idx}"));
1128 idx += 1;
1129 }
1130 if patch.input.is_some() {
1131 sets.push(format!("input = ${idx}"));
1132 idx += 1;
1133 }
1134 if patch.task_queue.is_some() {
1135 sets.push(format!("task_queue = ${idx}"));
1136 idx += 1;
1137 }
1138 if patch.overlap_policy.is_some() {
1139 sets.push(format!("overlap_policy = ${idx}"));
1140 idx += 1;
1141 }
1142 if sets.is_empty() {
1143 return self.get_schedule(namespace, name).await;
1144 }
1145 let sql = format!(
1146 "UPDATE workflow.schedules SET {} WHERE namespace = ${} AND name = ${}",
1147 sets.join(", "),
1148 idx,
1149 idx + 1
1150 );
1151 let mut q = sqlx::query(&sql);
1152 if let Some(ref v) = patch.cron_expr {
1153 q = q.bind(v);
1154 }
1155 if let Some(ref v) = patch.timezone {
1156 q = q.bind(v);
1157 }
1158 if let Some(ref v) = patch.input {
1159 q = q.bind(v.to_string());
1160 }
1161 if let Some(ref v) = patch.task_queue {
1162 q = q.bind(v);
1163 }
1164 if let Some(ref v) = patch.overlap_policy {
1165 q = q.bind(v);
1166 }
1167 let res = q
1168 .bind(namespace)
1169 .bind(name)
1170 .execute(&self.pool)
1171 .await?;
1172 if res.rows_affected() == 0 {
1173 return Ok(None);
1174 }
1175 self.get_schedule(namespace, name).await
1176 }
1177
1178 async fn set_schedule_paused(
1179 &self,
1180 namespace: &str,
1181 name: &str,
1182 paused: bool,
1183 ) -> Result<Option<WorkflowSchedule>> {
1184 let res = sqlx::query(
1185 "UPDATE workflow.schedules SET paused = $1 WHERE namespace = $2 AND name = $3",
1186 )
1187 .bind(paused)
1188 .bind(namespace)
1189 .bind(name)
1190 .execute(&self.pool)
1191 .await?;
1192 if res.rows_affected() == 0 {
1193 return Ok(None);
1194 }
1195 self.get_schedule(namespace, name).await
1196 }
1197
1198 async fn register_worker(&self, w: &WorkflowWorker) -> Result<()> {
1201 sqlx::query(
1202 "INSERT INTO workflow.workers (id, namespace, identity, task_queue, workflows, activities, max_concurrent_workflows, max_concurrent_activities, active_tasks, last_heartbeat, registered_at)
1203 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
1204 ON CONFLICT (id) DO UPDATE SET last_heartbeat = EXCLUDED.last_heartbeat, identity = EXCLUDED.identity",
1205 )
1206 .bind(&w.id)
1207 .bind(&w.namespace)
1208 .bind(&w.identity)
1209 .bind(&w.task_queue)
1210 .bind(&w.workflows)
1211 .bind(&w.activities)
1212 .bind(w.max_concurrent_workflows)
1213 .bind(w.max_concurrent_activities)
1214 .bind(w.active_tasks)
1215 .bind(w.last_heartbeat)
1216 .bind(w.registered_at)
1217 .execute(&self.pool)
1218 .await?;
1219 Ok(())
1220 }
1221
1222 async fn heartbeat_worker(&self, id: &str, now: f64) -> Result<()> {
1223 sqlx::query("UPDATE workflow.workers SET last_heartbeat = $1 WHERE id = $2")
1224 .bind(now)
1225 .bind(id)
1226 .execute(&self.pool)
1227 .await?;
1228 Ok(())
1229 }
1230
1231 async fn list_workers(&self, namespace: &str) -> Result<Vec<WorkflowWorker>> {
1232 let rows = sqlx::query_as::<_, PgWorkerRow>(
1233 "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",
1234 )
1235 .bind(namespace)
1236 .fetch_all(&self.pool)
1237 .await?;
1238 Ok(rows.into_iter().map(Into::into).collect())
1239 }
1240
1241 async fn remove_dead_workers(&self, cutoff: f64) -> Result<Vec<String>> {
1242 let rows: Vec<(String,)> =
1243 sqlx::query_as("SELECT id FROM workflow.workers WHERE last_heartbeat < $1")
1244 .bind(cutoff)
1245 .fetch_all(&self.pool)
1246 .await?;
1247 let ids: Vec<String> = rows.into_iter().map(|r| r.0).collect();
1248 if !ids.is_empty() {
1249 sqlx::query("DELETE FROM workflow.workers WHERE last_heartbeat < $1")
1250 .bind(cutoff)
1251 .execute(&self.pool)
1252 .await?;
1253 }
1254 Ok(ids)
1255 }
1256
1257 async fn list_child_workflows(&self, parent_id: &str) -> Result<Vec<WorkflowRecord>> {
1260 let rows = sqlx::query_as::<_, PgWorkflowRow>(
1261 "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
1262 FROM workflow.workflows WHERE parent_id = $1 ORDER BY created_at ASC",
1263 )
1264 .bind(parent_id)
1265 .fetch_all(&self.pool)
1266 .await?;
1267 Ok(rows.into_iter().map(Into::into).collect())
1268 }
1269
1270 async fn create_snapshot(
1273 &self,
1274 workflow_id: &str,
1275 event_seq: i32,
1276 state_json: &str,
1277 ) -> Result<()> {
1278 sqlx::query(
1279 "INSERT INTO workflow.snapshots (workflow_id, event_seq, state_json, created_at)
1280 VALUES ($1, $2, $3, $4)
1281 ON CONFLICT (workflow_id, event_seq) DO UPDATE SET state_json = EXCLUDED.state_json, created_at = EXCLUDED.created_at",
1282 )
1283 .bind(workflow_id)
1284 .bind(event_seq)
1285 .bind(state_json)
1286 .bind(timestamp_now())
1287 .execute(&self.pool)
1288 .await?;
1289 Ok(())
1290 }
1291
1292 async fn get_latest_snapshot(
1293 &self,
1294 workflow_id: &str,
1295 ) -> Result<Option<WorkflowSnapshot>> {
1296 let row = sqlx::query_as::<_, (String, i32, String, f64)>(
1297 "SELECT workflow_id, event_seq, state_json, created_at
1298 FROM workflow.snapshots WHERE workflow_id = $1
1299 ORDER BY event_seq DESC LIMIT 1",
1300 )
1301 .bind(workflow_id)
1302 .fetch_optional(&self.pool)
1303 .await?;
1304
1305 Ok(row.map(|(workflow_id, event_seq, state_json, created_at)| WorkflowSnapshot {
1306 workflow_id,
1307 event_seq,
1308 state_json,
1309 created_at,
1310 }))
1311 }
1312
1313 async fn get_queue_stats(&self, namespace: &str) -> Result<Vec<crate::store::QueueStats>> {
1316 let rows = sqlx::query_as::<_, (String, i64, i64, i64)>(
1317 "SELECT
1318 a.task_queue AS queue,
1319 SUM(CASE WHEN a.status = 'PENDING' THEN 1 ELSE 0 END) AS pending,
1320 SUM(CASE WHEN a.status = 'RUNNING' THEN 1 ELSE 0 END) AS running,
1321 (SELECT COUNT(*) FROM workflow.workers w WHERE w.task_queue = a.task_queue AND w.namespace = $1) AS workers
1322 FROM workflow.activities a
1323 JOIN workflow.workflows wf ON a.workflow_id = wf.id AND wf.namespace = $1
1324 GROUP BY a.task_queue",
1325 )
1326 .bind(namespace)
1327 .fetch_all(&self.pool)
1328 .await?;
1329
1330 Ok(rows
1331 .into_iter()
1332 .map(|(queue, pending, running, workers)| crate::store::QueueStats {
1333 queue,
1334 pending_activities: pending,
1335 running_activities: running,
1336 workers,
1337 })
1338 .collect())
1339 }
1340
1341 async fn try_acquire_scheduler_lock(&self) -> Result<bool> {
1344 let row: (bool,) =
1348 sqlx::query_as("SELECT pg_try_advisory_lock(42)")
1349 .fetch_one(&self.pool)
1350 .await?;
1351 Ok(row.0)
1352 }
1353
1354}
1355
1356fn timestamp_now() -> f64 {
1357 std::time::SystemTime::now()
1358 .duration_since(std::time::UNIX_EPOCH)
1359 .unwrap()
1360 .as_secs_f64()
1361}
1362
1363#[derive(sqlx::FromRow)]
1366struct PgWorkflowRow {
1367 id: String,
1368 namespace: String,
1369 run_id: String,
1370 workflow_type: String,
1371 task_queue: String,
1372 status: String,
1373 input: Option<String>,
1374 result: Option<String>,
1375 error: Option<String>,
1376 parent_id: Option<String>,
1377 claimed_by: Option<String>,
1378 search_attributes: Option<String>,
1379 archived_at: Option<f64>,
1380 archive_uri: Option<String>,
1381 created_at: f64,
1382 updated_at: f64,
1383 completed_at: Option<f64>,
1384}
1385
1386impl From<PgWorkflowRow> for WorkflowRecord {
1387 fn from(r: PgWorkflowRow) -> Self {
1388 Self {
1389 id: r.id,
1390 namespace: r.namespace,
1391 run_id: r.run_id,
1392 workflow_type: r.workflow_type,
1393 task_queue: r.task_queue,
1394 status: r.status,
1395 input: r.input,
1396 result: r.result,
1397 error: r.error,
1398 parent_id: r.parent_id,
1399 claimed_by: r.claimed_by,
1400 search_attributes: r.search_attributes,
1401 archived_at: r.archived_at,
1402 archive_uri: r.archive_uri,
1403 created_at: r.created_at,
1404 updated_at: r.updated_at,
1405 completed_at: r.completed_at,
1406 }
1407 }
1408}
1409
1410#[derive(sqlx::FromRow)]
1411struct PgEventRow {
1412 id: i64,
1413 workflow_id: String,
1414 seq: i32,
1415 event_type: String,
1416 payload: Option<String>,
1417 timestamp: f64,
1418}
1419
1420impl From<PgEventRow> for WorkflowEvent {
1421 fn from(r: PgEventRow) -> Self {
1422 Self {
1423 id: Some(r.id),
1424 workflow_id: r.workflow_id,
1425 seq: r.seq,
1426 event_type: r.event_type,
1427 payload: r.payload,
1428 timestamp: r.timestamp,
1429 }
1430 }
1431}
1432
1433#[derive(sqlx::FromRow)]
1434struct PgActivityRow {
1435 id: i64,
1436 workflow_id: String,
1437 seq: i32,
1438 name: String,
1439 task_queue: String,
1440 input: Option<String>,
1441 status: String,
1442 result: Option<String>,
1443 error: Option<String>,
1444 attempt: i32,
1445 max_attempts: i32,
1446 initial_interval_secs: f64,
1447 backoff_coefficient: f64,
1448 start_to_close_secs: f64,
1449 heartbeat_timeout_secs: Option<f64>,
1450 claimed_by: Option<String>,
1451 scheduled_at: f64,
1452 started_at: Option<f64>,
1453 completed_at: Option<f64>,
1454 last_heartbeat: Option<f64>,
1455}
1456
1457impl From<PgActivityRow> for WorkflowActivity {
1458 fn from(r: PgActivityRow) -> Self {
1459 Self {
1460 id: Some(r.id),
1461 workflow_id: r.workflow_id,
1462 seq: r.seq,
1463 name: r.name,
1464 task_queue: r.task_queue,
1465 input: r.input,
1466 status: r.status,
1467 result: r.result,
1468 error: r.error,
1469 attempt: r.attempt,
1470 max_attempts: r.max_attempts,
1471 initial_interval_secs: r.initial_interval_secs,
1472 backoff_coefficient: r.backoff_coefficient,
1473 start_to_close_secs: r.start_to_close_secs,
1474 heartbeat_timeout_secs: r.heartbeat_timeout_secs,
1475 claimed_by: r.claimed_by,
1476 scheduled_at: r.scheduled_at,
1477 started_at: r.started_at,
1478 completed_at: r.completed_at,
1479 last_heartbeat: r.last_heartbeat,
1480 }
1481 }
1482}
1483
1484#[derive(sqlx::FromRow)]
1485struct PgTimerRow {
1486 id: i64,
1487 workflow_id: String,
1488 seq: i32,
1489 fire_at: f64,
1490 fired: bool,
1491}
1492
1493impl From<PgTimerRow> for WorkflowTimer {
1494 fn from(r: PgTimerRow) -> Self {
1495 Self {
1496 id: Some(r.id),
1497 workflow_id: r.workflow_id,
1498 seq: r.seq,
1499 fire_at: r.fire_at,
1500 fired: r.fired,
1501 }
1502 }
1503}
1504
1505#[derive(sqlx::FromRow)]
1506struct PgSignalRow {
1507 id: i64,
1508 workflow_id: String,
1509 name: String,
1510 payload: Option<String>,
1511 consumed: bool,
1512 received_at: f64,
1513}
1514
1515impl From<PgSignalRow> for WorkflowSignal {
1516 fn from(r: PgSignalRow) -> Self {
1517 Self {
1518 id: Some(r.id),
1519 workflow_id: r.workflow_id,
1520 name: r.name,
1521 payload: r.payload,
1522 consumed: r.consumed,
1523 received_at: r.received_at,
1524 }
1525 }
1526}
1527
1528#[derive(sqlx::FromRow)]
1529struct PgScheduleRow {
1530 namespace: String,
1531 name: String,
1532 workflow_type: String,
1533 cron_expr: String,
1534 timezone: String,
1535 input: Option<String>,
1536 task_queue: String,
1537 overlap_policy: String,
1538 paused: bool,
1539 last_run_at: Option<f64>,
1540 next_run_at: Option<f64>,
1541 last_workflow_id: Option<String>,
1542 created_at: f64,
1543}
1544
1545impl From<PgScheduleRow> for WorkflowSchedule {
1546 fn from(r: PgScheduleRow) -> Self {
1547 Self {
1548 namespace: r.namespace,
1549 name: r.name,
1550 workflow_type: r.workflow_type,
1551 cron_expr: r.cron_expr,
1552 timezone: r.timezone,
1553 input: r.input,
1554 task_queue: r.task_queue,
1555 overlap_policy: r.overlap_policy,
1556 paused: r.paused,
1557 last_run_at: r.last_run_at,
1558 next_run_at: r.next_run_at,
1559 last_workflow_id: r.last_workflow_id,
1560 created_at: r.created_at,
1561 }
1562 }
1563}
1564
1565#[derive(sqlx::FromRow)]
1566struct PgWorkerRow {
1567 id: String,
1568 namespace: String,
1569 identity: String,
1570 task_queue: String,
1571 workflows: Option<String>,
1572 activities: Option<String>,
1573 max_concurrent_workflows: i32,
1574 max_concurrent_activities: i32,
1575 active_tasks: i32,
1576 last_heartbeat: f64,
1577 registered_at: f64,
1578}
1579
1580impl From<PgWorkerRow> for WorkflowWorker {
1581 fn from(r: PgWorkerRow) -> Self {
1582 Self {
1583 id: r.id,
1584 namespace: r.namespace,
1585 identity: r.identity,
1586 task_queue: r.task_queue,
1587 workflows: r.workflows,
1588 activities: r.activities,
1589 max_concurrent_workflows: r.max_concurrent_workflows,
1590 max_concurrent_activities: r.max_concurrent_activities,
1591 active_tasks: r.active_tasks,
1592 last_heartbeat: r.last_heartbeat,
1593 registered_at: r.registered_at,
1594 }
1595 }
1596}
1597
1598#[cfg(test)]
1599mod tests {
1600 use super::*;
1601
1602 #[test]
1603 fn sanitise_schema_keeps_statements_intact() {
1604 let input = "CREATE TABLE foo (x INT);\nCREATE INDEX idx_foo ON foo(x);\n";
1605 let out = sanitise_schema(input);
1606 assert_eq!(out.len(), 2);
1607 assert!(out[0].starts_with("CREATE TABLE foo"));
1608 assert!(out[1].starts_with("CREATE INDEX idx_foo"));
1609 }
1610
1611 #[test]
1612 fn sanitise_schema_drops_pure_comment_lines() {
1613 let input = "-- header comment\nCREATE TABLE foo (x INT);\n-- trailing comment\n";
1614 let out = sanitise_schema(input);
1615 assert_eq!(out.len(), 1);
1616 assert!(out[0].starts_with("CREATE TABLE foo"));
1617 }
1618
1619 #[test]
1620 fn sanitise_schema_ignores_semicolons_inside_comment_prose() {
1621 let input = "\
1625CREATE TABLE foo (x INT);
1626-- Idempotent across startups; fresh installs pick the column up from the
1627-- CREATE TABLE above so the ADD is a no-op.
1628";
1629 let out = sanitise_schema(input);
1630 assert_eq!(
1631 out.len(),
1632 1,
1633 "expected 1 real statement, got {}: {:?}",
1634 out.len(),
1635 out
1636 );
1637 assert!(out[0].starts_with("CREATE TABLE foo"));
1638 }
1639
1640 #[test]
1641 fn sanitise_schema_drops_indented_comment_lines() {
1642 let input = " -- indented comment\n\tCREATE TABLE foo (x INT);\n";
1643 let out = sanitise_schema(input);
1644 assert_eq!(out.len(), 1);
1645 assert!(out[0].contains("CREATE TABLE foo"));
1646 }
1647
1648 #[test]
1649 fn sanitise_schema_real_constant_produces_only_ddl() {
1650 for stmt in sanitise_schema(SCHEMA) {
1654 let first_word = stmt
1655 .split_whitespace()
1656 .next()
1657 .expect("non-empty statement")
1658 .to_uppercase();
1659 assert!(
1660 matches!(
1661 first_word.as_str(),
1662 "CREATE" | "INSERT" | "UPDATE" | "DROP" | "ALTER" | "WITH"
1663 ),
1664 "SCHEMA produced non-DDL statement starting with {first_word:?}: {stmt:?}"
1665 );
1666 }
1667 }
1668}