Skip to main content

a3s_flow/engine/
signals.rs

1use crate::error::{FlowError, Result};
2use crate::model::{
3    validate_signal_wait, FlowEvent, SignalWaitStatus, WorkflowRunSnapshot, WorkflowRunStatus,
4    WorkflowSignal, WorkflowSignalSnapshot,
5};
6
7use super::{
8    validation::{ensure_signal_wait_command_matches, is_event_conflict},
9    FlowEngine,
10};
11
12pub(super) enum SignalWaitCommandOutcome {
13    Replay,
14    Waiting,
15}
16
17impl FlowEngine {
18    /// Durably deliver a named asynchronous signal to an active execution.
19    ///
20    /// The target follows persisted continue-as-new links. Retrying with the
21    /// same target run ID and `signal_id` is idempotent across that descendant
22    /// chain; changing the name or payload is an explicit conflict. New and
23    /// matching deliveries repair and drive the active leaf, including a
24    /// successor missing after its predecessor link committed.
25    pub async fn send_signal(
26        &self,
27        run_id: &str,
28        signal: WorkflowSignal,
29    ) -> Result<WorkflowRunSnapshot> {
30        let (snapshot, _) = self.send_signal_with_commit(run_id, signal).await?;
31        Ok(snapshot)
32    }
33
34    /// Deliver a signal and return the stream where this call committed it.
35    pub(crate) async fn send_signal_with_commit(
36        &self,
37        run_id: &str,
38        signal: WorkflowSignal,
39    ) -> Result<(WorkflowRunSnapshot, Option<String>)> {
40        signal.validate()?;
41        let mut committed_run_id = None;
42
43        for _ in 0..self.max_replay_iterations {
44            // Repair an interrupted continuation before scanning its complete
45            // descendant chain for an earlier delivery attempt.
46            let candidate = self.ensure_continuation_leaf(run_id).await?;
47            if candidate.status == WorkflowRunStatus::Pending {
48                self.ensure_run_started_with_admission(
49                    &candidate.run_id,
50                    &candidate.spec,
51                    &candidate.input,
52                    false,
53                )
54                .await?;
55                continue;
56            }
57            let chain = self.continuation_chain(run_id).await?;
58            let leaf = chain
59                .last()
60                .ok_or_else(|| FlowError::RunNotFound(run_id.to_string()))?;
61
62            let existing = chain.iter().find_map(|snapshot| {
63                snapshot
64                    .signal(&signal.signal_id)
65                    .map(|existing| (snapshot.run_id.as_str(), existing))
66            });
67            if let Some((delivery_run_id, existing)) = existing {
68                ensure_signal_matches(delivery_run_id, existing, &signal)?;
69                match self.recover_and_drive_continuation_leaf(run_id).await {
70                    Ok(snapshot) => return Ok((snapshot, committed_run_id)),
71                    Err(error) if is_event_conflict(&error) => continue,
72                    Err(error) => return Err(error),
73                }
74            }
75
76            if leaf.status.is_terminal() {
77                return Err(FlowError::RunTerminal(leaf.run_id.clone()));
78            }
79            if !leaf.spec.accepts_signal(&signal.name) {
80                return Err(FlowError::InvalidTransition(format!(
81                    "workflow run {} does not declare signal {}",
82                    leaf.run_id, signal.name
83                )));
84            }
85            self.ensure_runtime_build_available(&leaf.run_id, &leaf.spec)?;
86            match self
87                .record_event_at(
88                    &leaf.run_id,
89                    leaf.last_sequence,
90                    FlowEvent::SignalReceived {
91                        signal: signal.clone(),
92                    },
93                )
94                .await
95            {
96                Ok(_) => {
97                    committed_run_id = Some(leaf.run_id.clone());
98                    match self.drive(run_id).await {
99                        Ok(snapshot) => return Ok((snapshot, committed_run_id)),
100                        Err(error) if is_event_conflict(&error) => continue,
101                        Err(error) => return Err(error),
102                    }
103                }
104                Err(error) if is_event_conflict(&error) => continue,
105                Err(error) => return Err(error),
106            }
107        }
108
109        Err(FlowError::ReplayLimitExceeded(self.max_replay_iterations))
110    }
111
112    /// Pair one waiting signal command with the oldest matching unconsumed
113    /// delivery. One event is appended per replay iteration to preserve the
114    /// store's optimistic concurrency boundary.
115    pub(super) async fn reconcile_signal_waits(
116        &self,
117        snapshot: &WorkflowRunSnapshot,
118    ) -> Result<bool> {
119        let mut waits = snapshot
120            .signal_waits
121            .values()
122            .filter(|wait| wait.status == SignalWaitStatus::Waiting)
123            .collect::<Vec<_>>();
124        waits.sort_by_key(|wait| wait.created_sequence);
125
126        for wait in waits {
127            let signal = snapshot
128                .signals
129                .iter()
130                .filter(|signal| signal.consumed_by.is_none() && signal.name == wait.signal_name)
131                .min_by_key(|signal| signal.received_sequence);
132            let Some(signal) = signal else {
133                continue;
134            };
135
136            self.record_event_at(
137                &snapshot.run_id,
138                snapshot.last_sequence,
139                FlowEvent::SignalWaitCompleted {
140                    wait_id: wait.wait_id.clone(),
141                    signal_id: signal.signal_id.clone(),
142                },
143            )
144            .await?;
145            return Ok(true);
146        }
147
148        Ok(false)
149    }
150
151    pub(super) async fn schedule_signal_wait(
152        &self,
153        snapshot: &WorkflowRunSnapshot,
154        wait_id: String,
155        signal_name: String,
156    ) -> Result<SignalWaitCommandOutcome> {
157        validate_signal_wait(&wait_id, &signal_name)?;
158        if !snapshot.spec.accepts_signal(&signal_name) {
159            return Err(FlowError::InvalidTransition(format!(
160                "workflow run {} does not declare signal {signal_name}",
161                snapshot.run_id
162            )));
163        }
164
165        match snapshot.signal_waits.get(&wait_id) {
166            Some(wait) => {
167                ensure_signal_wait_command_matches(&snapshot.run_id, wait, &signal_name)?;
168                match wait.status {
169                    SignalWaitStatus::Completed => Ok(SignalWaitCommandOutcome::Replay),
170                    SignalWaitStatus::Waiting => Ok(SignalWaitCommandOutcome::Waiting),
171                    SignalWaitStatus::Cancelled => Err(FlowError::InvalidTransition(format!(
172                        "workflow rescheduled cancelled signal wait {wait_id}; cancellation cleanup must use a distinct stable identity"
173                    ))),
174                }
175            }
176            None => {
177                self.record_event_at(
178                    &snapshot.run_id,
179                    snapshot.last_sequence,
180                    FlowEvent::SignalWaitCreated {
181                        wait_id,
182                        signal_name,
183                    },
184                )
185                .await?;
186                Ok(SignalWaitCommandOutcome::Replay)
187            }
188        }
189    }
190}
191
192fn ensure_signal_matches(
193    run_id: &str,
194    existing: &WorkflowSignalSnapshot,
195    signal: &WorkflowSignal,
196) -> Result<()> {
197    if existing.name != signal.name || existing.payload != signal.payload {
198        return Err(FlowError::SignalConflict {
199            run_id: run_id.to_string(),
200            signal_id: signal.signal_id.clone(),
201            reason: "name or payload differs from the durable delivery".to_string(),
202        });
203    }
204    Ok(())
205}