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