1use sayiir_core::codec;
7use sayiir_core::snapshot::{PauseRequest, SignalKind, SignalRequest, WorkflowSnapshot};
8use sayiir_persistence::validation::validate_signal_allowed;
9use sayiir_persistence::{BackendError, SignalStore};
10use sqlx::Row;
11
12use crate::backend::PostgresBackend;
13use crate::error::PgError;
14use crate::history::append_history;
15use crate::wakeup::{TASK_READY_CHANNEL, build_task_ready_payload};
16
17impl<C> SignalStore for PostgresBackend<C>
18where
19 C: codec::SnapshotCodec,
20{
21 #[tracing::instrument(
22 name = "db.store_signal",
23 skip(self, request),
24 fields(db.system = "postgresql", kind = %kind.as_ref()),
25 err(level = tracing::Level::ERROR),
26 )]
27 async fn store_signal(
28 &self,
29 instance_id: &str,
30 kind: SignalKind,
31 request: SignalRequest,
32 ) -> Result<(), BackendError> {
33 tracing::debug!("storing signal");
34 let mut tx = self.pool.begin().await.map_err(PgError)?;
40
41 let row = sqlx::query(
42 "SELECT status FROM sayiir_workflow_snapshots
43 WHERE instance_id = $1
44 FOR UPDATE",
45 )
46 .bind(instance_id)
47 .fetch_optional(&mut *tx)
48 .await
49 .map_err(PgError)?
50 .ok_or_else(|| BackendError::NotFound(instance_id.to_string()))?;
51
52 let status: String = row.get("status");
53 validate_signal_allowed(&status, kind)?;
54
55 sqlx::query(
56 "INSERT INTO sayiir_workflow_signals (instance_id, kind, reason, requested_by)
57 VALUES ($1, $2, $3, $4)
58 ON CONFLICT (instance_id, kind) DO UPDATE SET
59 reason = $3, requested_by = $4, created_at = now()",
60 )
61 .bind(instance_id)
62 .bind(kind.as_ref())
63 .bind(&request.reason)
64 .bind(&request.requested_by)
65 .execute(&mut *tx)
66 .await
67 .map_err(PgError)?;
68
69 tx.commit().await.map_err(PgError)?;
70 Ok(())
71 }
72
73 #[tracing::instrument(
74 name = "db.get_signal",
75 skip(self),
76 fields(db.system = "postgresql", kind = %kind.as_ref()),
77 err(level = tracing::Level::ERROR),
78 )]
79 async fn get_signal(
80 &self,
81 instance_id: &str,
82 kind: SignalKind,
83 ) -> Result<Option<SignalRequest>, BackendError> {
84 tracing::debug!("getting signal");
85 let row = sqlx::query(
86 "SELECT reason, requested_by, created_at
87 FROM sayiir_workflow_signals
88 WHERE instance_id = $1 AND kind = $2",
89 )
90 .bind(instance_id)
91 .bind(kind.as_ref())
92 .fetch_optional(&self.pool)
93 .await
94 .map_err(PgError)?;
95
96 Ok(row.map(|r| SignalRequest {
97 reason: r.get("reason"),
98 requested_by: r.get("requested_by"),
99 requested_at: r.get("created_at"),
100 }))
101 }
102
103 #[tracing::instrument(
104 name = "db.clear_signal",
105 skip(self),
106 fields(db.system = "postgresql", kind = %kind.as_ref()),
107 err(level = tracing::Level::ERROR),
108 )]
109 async fn clear_signal(&self, instance_id: &str, kind: SignalKind) -> Result<(), BackendError> {
110 tracing::debug!("clearing signal");
111 sqlx::query("DELETE FROM sayiir_workflow_signals WHERE instance_id = $1 AND kind = $2")
112 .bind(instance_id)
113 .bind(kind.as_ref())
114 .execute(&self.pool)
115 .await
116 .map_err(PgError)?;
117 Ok(())
118 }
119
120 #[tracing::instrument(
121 name = "db.send_event",
122 skip(self, payload),
123 fields(db.system = "postgresql"),
124 err(level = tracing::Level::ERROR),
125 )]
126 #[allow(clippy::too_many_lines)]
127 async fn send_event(
128 &self,
129 instance_id: &str,
130 signal_name: &str,
131 payload: bytes::Bytes,
132 ) -> Result<(), BackendError> {
133 let probe = sqlx::query(
162 "SELECT status FROM sayiir_workflow_snapshots
163 WHERE instance_id = $1",
164 )
165 .bind(instance_id)
166 .fetch_optional(&self.pool)
167 .await
168 .map_err(PgError)?;
169
170 let in_progress = probe.as_ref().is_some_and(|r| {
171 let status: Option<String> = r.get("status");
172 status.as_deref() == Some("InProgress")
173 });
174
175 if !in_progress {
176 tracing::debug!(%instance_id, %signal_name, "buffering external event (probe: not in progress)");
177 sqlx::query(
178 "INSERT INTO sayiir_workflow_events (instance_id, signal_name, payload)
179 VALUES ($1, $2, $3)",
180 )
181 .bind(instance_id)
182 .bind(signal_name)
183 .bind(payload.as_ref())
184 .execute(&self.pool)
185 .await
186 .map_err(PgError)?;
187 return Ok(());
188 }
189
190 let mut tx = self.pool.begin().await.map_err(PgError)?;
191 let locked = self
192 .lock_snapshot_for_mutation(&mut tx, instance_id)
193 .await?;
194
195 if let Some((mut snapshot, prev_history_version)) = locked
196 && let Some((signal_id, next_task_id)) = signal_resume_target(&snapshot, signal_name)
197 {
198 tracing::debug!(%instance_id, %signal_name, "auto-resuming workflow at signal");
199 let signal_payload = payload.clone();
208 snapshot.mark_task_completed(signal_id, payload);
209 if let Some(next_id) = next_task_id {
210 snapshot.update_position(sayiir_core::snapshot::ExecutionPosition::AtTask {
211 task_id: next_id,
212 });
213 } else {
214 snapshot.mark_completed(signal_payload.clone());
217 }
218 let (data, data_hash) = self.encode_blob(&mut snapshot)?;
225 let status = snapshot.state.as_ref();
226 let task_id_bytes: Option<[u8; 32]> = snapshot.current_task_id().map(|t| *t.as_bytes());
227 let task_id: Option<&[u8]> = task_id_bytes.as_ref().map(<[u8; 32]>::as_slice);
228 let task_count = snapshot.completed_task_count();
229 let pos_kind = snapshot.position_kind();
230 let wake_at = snapshot.delay_wake_at();
231 let terminal = snapshot.state.is_terminal();
232 let next_history_version = prev_history_version + 1;
233 let notify_payload = build_task_ready_payload(&snapshot);
234
235 sqlx::query(
241 "WITH upd AS (
242 UPDATE sayiir_workflow_snapshots
243 SET status = $1, current_task_id = $2,
244 completed_task_count = $3, position_kind = $4,
245 delay_wake_at = $5, history_version = $6,
246 data_hash = $7,
247 completed_at = CASE
248 WHEN $13 THEN now()
249 ELSE completed_at
250 END,
251 updated_at = now()
252 WHERE instance_id = $8
253 RETURNING 1
254 ),
255 task_output AS (
256 INSERT INTO sayiir_workflow_tasks
257 (instance_id, task_id, status, completed_at, output)
258 VALUES ($8, $11, 'completed', now(), $12)
259 ON CONFLICT (instance_id, task_id) DO UPDATE SET
260 status = 'completed',
261 completed_at = now(),
262 error = NULL,
263 output = EXCLUDED.output
264 RETURNING 1
265 )
266 SELECT pg_notify($9, $10) FROM upd WHERE $10 IS NOT NULL",
267 )
268 .bind(status)
269 .bind(task_id)
270 .bind(task_count)
271 .bind(pos_kind)
272 .bind(wake_at)
273 .bind(next_history_version)
274 .bind(data_hash.as_slice())
275 .bind(instance_id)
276 .bind(TASK_READY_CHANNEL)
277 .bind(notify_payload.as_deref())
278 .bind(signal_id.as_bytes().as_slice())
279 .bind(signal_payload.as_ref())
280 .bind(terminal)
281 .execute(&mut *tx)
282 .await
283 .map_err(PgError)?;
284
285 append_history(
286 &mut tx,
287 instance_id,
288 next_history_version,
289 status,
290 task_id,
291 &data,
292 &data_hash,
293 )
294 .await?;
295
296 tx.commit().await.map_err(PgError)?;
297 return Ok(());
298 }
299
300 tracing::debug!(%instance_id, %signal_name, "buffering external event");
304 sqlx::query(
305 "INSERT INTO sayiir_workflow_events (instance_id, signal_name, payload)
306 VALUES ($1, $2, $3)",
307 )
308 .bind(instance_id)
309 .bind(signal_name)
310 .bind(payload.as_ref())
311 .execute(&mut *tx)
312 .await
313 .map_err(PgError)?;
314 tx.commit().await.map_err(PgError)?;
315 Ok(())
316 }
317
318 #[tracing::instrument(
319 name = "db.consume_event",
320 skip(self),
321 fields(db.system = "postgresql"),
322 err(level = tracing::Level::ERROR),
323 )]
324 async fn consume_event(
325 &self,
326 instance_id: &str,
327 signal_name: &str,
328 ) -> Result<Option<bytes::Bytes>, BackendError> {
329 tracing::debug!("consuming oldest buffered event");
330 let row = sqlx::query(
332 "DELETE FROM sayiir_workflow_events
333 WHERE id = (
334 SELECT id FROM sayiir_workflow_events
335 WHERE instance_id = $1 AND signal_name = $2
336 ORDER BY id ASC
337 LIMIT 1
338 FOR UPDATE SKIP LOCKED
339 )
340 RETURNING payload",
341 )
342 .bind(instance_id)
343 .bind(signal_name)
344 .fetch_optional(&self.pool)
345 .await
346 .map_err(PgError)?;
347
348 Ok(row.map(|r| {
349 let raw: Vec<u8> = r.get("payload");
350 bytes::Bytes::from(raw)
351 }))
352 }
353
354 #[tracing::instrument(
357 name = "db.check_and_cancel",
358 skip(self),
359 fields(db.system = "postgresql"),
360 err(level = tracing::Level::ERROR),
361 )]
362 async fn check_and_cancel(
363 &self,
364 instance_id: &str,
365 interrupted_at_task: Option<sayiir_core::TaskId>,
366 ) -> Result<bool, BackendError> {
367 tracing::debug!("checking for cancel signal");
368 let mut tx = self.pool.begin().await.map_err(PgError)?;
369
370 let signal_row = sqlx::query(
372 "SELECT reason, requested_by
373 FROM sayiir_workflow_signals
374 WHERE instance_id = $1 AND kind = $2
375 FOR UPDATE",
376 )
377 .bind(instance_id)
378 .bind(SignalKind::Cancel.as_ref())
379 .fetch_optional(&mut *tx)
380 .await
381 .map_err(PgError)?;
382
383 let Some(signal_row) = signal_row else {
384 tx.rollback().await.map_err(PgError)?;
385 return Ok(false);
386 };
387
388 let Some((mut snapshot, prev_history_version)) = self
390 .lock_snapshot_for_mutation(&mut tx, instance_id)
391 .await?
392 else {
393 tx.rollback().await.map_err(PgError)?;
394 return Ok(false);
395 };
396
397 if !snapshot.state.is_in_progress() {
398 tx.rollback().await.map_err(PgError)?;
399 return Ok(false);
400 }
401
402 let reason: Option<String> = signal_row.get("reason");
403 let requested_by: Option<String> = signal_row.get("requested_by");
404 snapshot.mark_cancelled(reason, requested_by, interrupted_at_task);
405
406 let (data, data_hash) = self.encode_blob(&mut snapshot)?;
407 let status = snapshot.state.as_ref();
408 let error = snapshot.error_message().map(ToString::to_string);
409 let pos_kind = snapshot.position_kind();
410 let wake_at = snapshot.delay_wake_at();
411 let next_history_version = prev_history_version + 1;
412 let task_id_bytes: Option<[u8; 32]> = interrupted_at_task.map(|t| *t.as_bytes());
420 let task_id: Option<&[u8]> = task_id_bytes.as_ref().map(<[u8; 32]>::as_slice);
421 let notify_payload = build_task_ready_payload(&snapshot);
422
423 sqlx::query(
430 "WITH upd AS (
431 UPDATE sayiir_workflow_snapshots
432 SET status = $1, error = $2,
433 position_kind = $3, delay_wake_at = $4,
434 history_version = $5, data_hash = $6,
435 completed_at = now(), updated_at = now()
436 WHERE instance_id = $7
437 RETURNING 1
438 )
439 SELECT pg_notify($8, $9) FROM upd WHERE $9 IS NOT NULL",
440 )
441 .bind(status)
442 .bind(&error)
443 .bind(pos_kind)
444 .bind(wake_at)
445 .bind(next_history_version)
446 .bind(data_hash.as_slice())
447 .bind(instance_id)
448 .bind(TASK_READY_CHANNEL)
449 .bind(notify_payload.as_deref())
450 .execute(&mut *tx)
451 .await
452 .map_err(PgError)?;
453
454 append_history(
455 &mut tx,
456 instance_id,
457 next_history_version,
458 status,
459 task_id,
460 &data,
461 &data_hash,
462 )
463 .await?;
464
465 sqlx::query(
467 "UPDATE sayiir_workflow_tasks SET status = 'cancelled', completed_at = now()
468 WHERE instance_id = $1 AND status = 'active'",
469 )
470 .bind(instance_id)
471 .execute(&mut *tx)
472 .await
473 .map_err(PgError)?;
474
475 sqlx::query("DELETE FROM sayiir_workflow_signals WHERE instance_id = $1 AND kind = $2")
477 .bind(instance_id)
478 .bind(SignalKind::Cancel.as_ref())
479 .execute(&mut *tx)
480 .await
481 .map_err(PgError)?;
482
483 tx.commit().await.map_err(PgError)?;
484 tracing::info!(instance_id, "workflow cancelled");
485 Ok(true)
486 }
487
488 #[tracing::instrument(
489 name = "db.check_and_pause",
490 skip(self),
491 fields(db.system = "postgresql"),
492 err(level = tracing::Level::ERROR),
493 )]
494 async fn check_and_pause(&self, instance_id: &str) -> Result<bool, BackendError> {
495 tracing::debug!("checking for pause signal");
496 let mut tx = self.pool.begin().await.map_err(PgError)?;
497
498 let signal_row = sqlx::query(
500 "SELECT reason, requested_by
501 FROM sayiir_workflow_signals
502 WHERE instance_id = $1 AND kind = $2
503 FOR UPDATE",
504 )
505 .bind(instance_id)
506 .bind(SignalKind::Pause.as_ref())
507 .fetch_optional(&mut *tx)
508 .await
509 .map_err(PgError)?;
510
511 let Some(signal_row) = signal_row else {
512 tx.rollback().await.map_err(PgError)?;
513 return Ok(false);
514 };
515
516 let Some((mut snapshot, prev_history_version)) = self
518 .lock_snapshot_for_mutation(&mut tx, instance_id)
519 .await?
520 else {
521 tx.rollback().await.map_err(PgError)?;
522 return Ok(false);
523 };
524
525 if !snapshot.state.is_in_progress() {
526 tx.rollback().await.map_err(PgError)?;
527 return Ok(false);
528 }
529
530 let reason: Option<String> = signal_row.get("reason");
531 let requested_by: Option<String> = signal_row.get("requested_by");
532 let pause_request = PauseRequest::new(reason, requested_by);
533 snapshot.mark_paused(&pause_request);
534
535 let (data, data_hash) = self.encode_blob(&mut snapshot)?;
536 let status = snapshot.state.as_ref();
537 let task_id_bytes: Option<[u8; 32]> = snapshot.current_task_id().map(|t| *t.as_bytes());
538 let task_id: Option<&[u8]> = task_id_bytes.as_ref().map(<[u8; 32]>::as_slice);
539 let task_count = snapshot.completed_task_count();
540 let pos_kind = snapshot.position_kind();
541 let wake_at = snapshot.delay_wake_at();
542 let next_history_version = prev_history_version + 1;
543 let notify_payload = build_task_ready_payload(&snapshot);
544
545 sqlx::query(
550 "WITH upd AS (
551 UPDATE sayiir_workflow_snapshots
552 SET status = $1, current_task_id = $2,
553 completed_task_count = $3, position_kind = $4,
554 delay_wake_at = $5, history_version = $6,
555 data_hash = $7, updated_at = now()
556 WHERE instance_id = $8
557 RETURNING 1
558 )
559 SELECT pg_notify($9, $10) FROM upd WHERE $10 IS NOT NULL",
560 )
561 .bind(status)
562 .bind(task_id)
563 .bind(task_count)
564 .bind(pos_kind)
565 .bind(wake_at)
566 .bind(next_history_version)
567 .bind(data_hash.as_slice())
568 .bind(instance_id)
569 .bind(TASK_READY_CHANNEL)
570 .bind(notify_payload.as_deref())
571 .execute(&mut *tx)
572 .await
573 .map_err(PgError)?;
574
575 append_history(
576 &mut tx,
577 instance_id,
578 next_history_version,
579 status,
580 task_id,
581 &data,
582 &data_hash,
583 )
584 .await?;
585
586 sqlx::query("DELETE FROM sayiir_workflow_signals WHERE instance_id = $1 AND kind = $2")
588 .bind(instance_id)
589 .bind(SignalKind::Pause.as_ref())
590 .execute(&mut *tx)
591 .await
592 .map_err(PgError)?;
593
594 tx.commit().await.map_err(PgError)?;
595 tracing::info!(instance_id, "workflow paused");
596 Ok(true)
597 }
598
599 #[tracing::instrument(
600 name = "db.unpause",
601 skip(self),
602 fields(db.system = "postgresql"),
603 err(level = tracing::Level::ERROR),
604 )]
605 async fn unpause(&self, instance_id: &str) -> Result<WorkflowSnapshot, BackendError> {
606 tracing::debug!("unpausing workflow");
607 let mut tx = self.pool.begin().await.map_err(PgError)?;
608
609 let (mut snapshot, prev_history_version) = self
610 .lock_snapshot_for_mutation(&mut tx, instance_id)
611 .await?
612 .ok_or_else(|| BackendError::NotFound(instance_id.to_string()))?;
613
614 if !snapshot.state.is_paused() {
615 let state_name = snapshot.state.as_ref();
616 tx.rollback().await.map_err(PgError)?;
623 return Err(BackendError::CannotPause(format!(
624 "Workflow is not paused (current state: {state_name:?})"
625 )));
626 }
627
628 snapshot.mark_unpaused();
629
630 let (data, data_hash) = self.encode_blob(&mut snapshot)?;
631 let status = snapshot.state.as_ref();
632 let task_id_bytes: Option<[u8; 32]> = snapshot.current_task_id().map(|t| *t.as_bytes());
633 let task_id: Option<&[u8]> = task_id_bytes.as_ref().map(<[u8; 32]>::as_slice);
634 let task_count = snapshot.completed_task_count();
635 let pos_kind = snapshot.position_kind();
636 let wake_at = snapshot.delay_wake_at();
637 let next_history_version = prev_history_version + 1;
638 let notify_payload = build_task_ready_payload(&snapshot);
639
640 sqlx::query(
645 "WITH upd AS (
646 UPDATE sayiir_workflow_snapshots
647 SET status = $1, current_task_id = $2,
648 completed_task_count = $3, position_kind = $4,
649 delay_wake_at = $5, history_version = $6,
650 data_hash = $7, updated_at = now()
651 WHERE instance_id = $8
652 RETURNING 1
653 )
654 SELECT pg_notify($9, $10) FROM upd WHERE $10 IS NOT NULL",
655 )
656 .bind(status)
657 .bind(task_id)
658 .bind(task_count)
659 .bind(pos_kind)
660 .bind(wake_at)
661 .bind(next_history_version)
662 .bind(data_hash.as_slice())
663 .bind(instance_id)
664 .bind(TASK_READY_CHANNEL)
665 .bind(notify_payload.as_deref())
666 .execute(&mut *tx)
667 .await
668 .map_err(PgError)?;
669
670 append_history(
671 &mut tx,
672 instance_id,
673 next_history_version,
674 status,
675 task_id,
676 &data,
677 &data_hash,
678 )
679 .await?;
680
681 tx.commit().await.map_err(PgError)?;
682 tracing::info!(instance_id, "workflow unpaused");
683 Ok(snapshot)
684 }
685}
686
687fn signal_resume_target(
692 snapshot: &WorkflowSnapshot,
693 signal_name: &str,
694) -> Option<(sayiir_core::TaskId, Option<sayiir_core::TaskId>)> {
695 use sayiir_core::snapshot::{ExecutionPosition, WorkflowSnapshotState};
696 match &snapshot.state {
697 WorkflowSnapshotState::InProgress {
698 position:
699 ExecutionPosition::AtSignal {
700 signal_id,
701 signal_name: parked_name,
702 next_task_id,
703 ..
704 },
705 ..
706 } if parked_name == signal_name => Some((*signal_id, *next_task_id)),
707 _ => None,
708 }
709}