Skip to main content

sayiir_postgres/
signal_store.rs

1//! [`SignalStore`] implementation for Postgres.
2//!
3//! Overrides the 3 default composite methods with single-transaction
4//! implementations for true ACID atomicity.
5
6use 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        // Lock the snapshot row for the duration of validate-then-insert so a
35        // concurrent `save_snapshot` can't transition the workflow to a state
36        // that would have failed `validate_signal_allowed` between our read
37        // and our write. `save_snapshot` upserts into the same row and will
38        // block on the lock until this transaction commits.
39        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        // Atomic auto-resume path: if the workflow is parked at
134        // `AtSignal` waiting for `signal_name`, mark the signal task
135        // completed with `payload`, advance position to `AtTask` of the
136        // next_task_id stored on the AtSignal variant, and save —
137        // skipping the buffered-event detour entirely. PooledWorker
138        // dispatch has no AwaitSignal-advance logic, so without this
139        // shortcut a parked workflow would never resume (the analogous
140        // gap to the pre-fix fork-join dispatch). Falls back to the
141        // legacy buffered-event insert when the workflow isn't waiting
142        // (e.g. signal arrives before the workflow reaches the
143        // wait node — buffered events are consumed at the in-process
144        // runner's AwaitSignal handling).
145        //
146        // Cheap probe first: a workflow that is missing or in a
147        // terminal state (Completed/Failed/Cancelled) cannot transition
148        // to AtSignal, so the event can be buffered without taking the
149        // lock — saves a tx + FOR UPDATE + outputs hydration for
150        // ad-hoc signals to absent/terminal instances.
151        //
152        // CRITICAL: any InProgress status falls through to the lock
153        // path. The probe is TOCTOU against the worker advancing
154        // position into AtSignal — e.g. driver sends `kick` right after
155        // `record_pickup` fires but BEFORE save_snapshot has committed
156        // the AtTask→AtSignal transition. Without the lock, the probe
157        // sees position_kind='AtTask', buffers the event, and the
158        // PooledWorker dispatch path (which has no AwaitSignal-advance
159        // logic) never drains it — the workflow stalls forever. The
160        // signal-driven bench exercises exactly this race.
161        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            // Capture the payload up front. We can't rely on
200            // `get_task_result_bytes(&signal_id)` after the transition:
201            // on the terminal branch `mark_completed` replaces the
202            // InProgress variant (and with it `completed_tasks`) with
203            // Completed, so the lookup returns None and the
204            // task_output CTE below would persist an empty payload —
205            // a downstream loader hydrating the signal task's output
206            // from `workflow_tasks` would then see empty bytes.
207            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                // Signal was the terminal node — complete the workflow
215                // with the signal payload as the final output.
216                snapshot.mark_completed(signal_payload.clone());
217            }
218            // Encode the snapshot AFTER mark_task_completed so the
219            // signal task's bytes are picked up by `encode_blob`'s
220            // strip step and then re-persisted in sayiir_workflow_tasks
221            // via the `task_output` CTE — without that UPSERT the
222            // outputs-stripped blob loses the signal payload entirely
223            // and the next dispatch hands the join an empty input.
224            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            // When the signal IS the terminal node, `mark_completed` has
236            // flipped state to Completed and the snapshot row must carry
237            // a `completed_at` timestamp — otherwise retention sweeps,
238            // dashboards, and `WHERE completed_at IS NOT NULL` filters
239            // silently miss signal-driven completions.
240            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        // Not parked on this signal — buffer for later consumption by
301        // the in-process AwaitSignal handling (or a future poll-based
302        // recovery on the PooledWorker side).
303        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        // Atomically delete-and-return the oldest event for this (instance, signal).
331        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    // --- Overridden composites: single ACID transactions ---
355
356    #[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        // Check for cancel signal (lock the row)
371        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        // Lock the snapshot row and load the latest blob from history.
389        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        // History row's `current_task_id` records the task the workflow
413        // was interrupted at. `snapshot.current_task_id()` returns None
414        // here because `mark_cancelled` already transitioned to the
415        // Cancelled variant (current_task_id only matches InProgress
416        // AtTask), so binding it would lose the interrupted-task
417        // pointer in the indexed history column. Use the caller's
418        // `interrupted_at_task` directly.
419        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        // Pipeline the snapshot UPDATE and pg_notify into one statement.
424        // pg_notify lands in the outer SELECT (not a sibling CTE) so it
425        // can't be pruned by the planner — `FROM upd` forces upd to
426        // execute, and the WHERE gates the notify when there's no
427        // payload (cancel typically lands on a terminal state with no
428        // current_task_id, so payload is NULL).
429        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        // Mark any still-active tasks as cancelled
466        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        // Clear the signal
476        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        // Check for pause signal (lock the row)
499        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        // Lock the snapshot row and load the latest blob from history.
517        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        // Pipeline UPDATE + pg_notify in one statement. Paused snapshots
546        // typically don't carry a wakeup payload, but mirroring the
547        // pattern keeps the signal-store write paths uniform with
548        // save_snapshot.
549        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        // Clear the signal
587        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            // Explicit rollback releases the FOR UPDATE row lock
617            // immediately rather than waiting for sqlx's async
618            // Transaction Drop to fire — matches the pattern in
619            // check_and_cancel/check_and_pause and matters under
620            // bursty admin scripts that hammer unpause across many
621            // already-running workflows.
622            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        // Unpause restores the snapshot to InProgress AtTask, so this is
641        // the one signal-store path that actually emits a NOTIFY.
642        // Folding it into the UPDATE eliminates the separate pg_notify
643        // round-trip.
644        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
687/// If `snapshot` is parked at `AtSignal` waiting for `signal_name`,
688/// return the (signal_id, next_task_id) pair so `send_event` can
689/// advance the workflow inline. Returns `None` for any other position
690/// or signal-name mismatch.
691fn 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}