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