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