Skip to main content

ironflow_engine/context/steps/
signal.rs

1//! Signal wait step for [`WorkflowContext`].
2
3use std::time::Duration;
4
5use chrono::{DateTime, TimeDelta, Utc};
6use schemars::schema_for;
7use serde_json::{Value, from_value, json, to_value};
8use tracing::info;
9use uuid::Uuid;
10
11use ironflow_store::models::{
12    NewStep, SignalStepResolution, Step, StepKind, StepStatus, StepUpdate, step_trace_id,
13};
14
15use crate::context::WorkflowContext;
16use crate::context::lifecycle::check_replay_identity;
17use crate::error::EngineError;
18use crate::executor::SignalOutcome;
19use crate::plan::lock_plan;
20use crate::signal::{
21    SIGNAL_SCHEMA_KEY, SIGNAL_TIMED_OUT_KEY, Signal, received_output, timed_out_output,
22};
23
24/// Key of the wait deadline in a signal step's input.
25const DEADLINE_AT_KEY: &str = "deadline_at";
26
27impl WorkflowContext {
28    /// Wait for a typed external signal, suspending the run until it arrives
29    /// or `timeout` elapses.
30    ///
31    /// The step waits for a signal named [`S::NAME`](Signal::NAME) carrying
32    /// `key`. The key identifies one occurrence of the event, not the thing it
33    /// happens to: wait on a commit SHA, not on a merge request, so a signal
34    /// for an older push never resumes a run waiting for the newest one.
35    ///
36    /// A signal received since the run was created resolves the step at once,
37    /// without suspending. Otherwise the step stores the JSON schema of `S`,
38    /// the run goes `Sleeping` until the deadline, and a delivery
39    /// (`POST /api/v1/signals`,
40    /// [`Engine::send_signal`](crate::engine::Engine::send_signal)) whose
41    /// payload matches the schema wakes it. On resume the step is replayed:
42    /// the payload is deserialized into `S` and returned as `Some`. When the
43    /// deadline passes first, the step completes as timed out and `None` is
44    /// returned.
45    ///
46    /// While planning, the step is recorded and never suspends: `S` is
47    /// deserialized from `{}` when it accepts that, `None` otherwise.
48    ///
49    /// # Panics
50    ///
51    /// Panics when `key` is empty or whitespace, when `timeout` is zero, or
52    /// when `S::NAME` is empty.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`EngineError::SignalWaiting`] to suspend the run until a signal
57    /// or the deadline. Returns [`EngineError::StepConfig`] when the stored
58    /// payload does not match `S` or the timeout is out of range. Returns
59    /// [`EngineError::ReplayDivergence`] when the step recorded at this
60    /// position has a different name or kind. Returns other [`EngineError`]
61    /// variants on store failures.
62    ///
63    /// # Examples
64    ///
65    /// ```no_run
66    /// use std::time::Duration;
67    ///
68    /// use ironflow_engine::context::WorkflowContext;
69    /// use ironflow_engine::error::EngineError;
70    /// use ironflow_engine::signal::Signal;
71    /// use schemars::JsonSchema;
72    /// use serde::{Deserialize, Serialize};
73    ///
74    /// #[derive(Serialize, Deserialize, JsonSchema)]
75    /// struct PipelineFinished {
76    ///     status: String,
77    /// }
78    ///
79    /// impl Signal for PipelineFinished {
80    ///     const NAME: &'static str = "ci.pipeline_finished";
81    /// }
82    ///
83    /// # async fn example(ctx: &mut WorkflowContext, sha: &str) -> Result<(), EngineError> {
84    /// let finished = ctx
85    ///     .wait_for_signal::<PipelineFinished>("wait-ci", sha, Duration::from_secs(3600))
86    ///     .await?;
87    /// match finished {
88    ///     Some(pipeline) if pipeline.status == "success" => { /* deploy */ }
89    ///     Some(_) => return Err(EngineError::StepConfig("CI failed".to_string())),
90    ///     None => return Err(EngineError::StepConfig("CI timed out".to_string())),
91    /// }
92    /// # Ok(())
93    /// # }
94    /// ```
95    pub async fn wait_for_signal<S: Signal>(
96        &mut self,
97        name: &str,
98        key: &str,
99        timeout: Duration,
100    ) -> Result<Option<S>, EngineError> {
101        assert!(
102            !S::NAME.is_empty(),
103            "wait_for_signal: Signal::NAME must not be empty"
104        );
105        assert!(
106            !key.trim().is_empty(),
107            "wait_for_signal: key must not be empty"
108        );
109        assert!(
110            !timeout.is_zero(),
111            "wait_for_signal: timeout must be greater than zero"
112        );
113
114        // Plan mode: record the step and continue. Planning must never suspend.
115        if let Some(plan) = self.plan().cloned() {
116            self.position += 1;
117            {
118                let mut recorder = lock_plan(&plan);
119                if recorder.record(name, StepKind::Signal, &self.workflow_name, None) {
120                    recorder.set_last(vec![name.to_string()]);
121                }
122            }
123            return Ok(from_value::<S>(json!({})).ok());
124        }
125
126        let position = self.next_position();
127
128        // Replay: the step exists from a prior execution of this attempt.
129        if let Some(existing) = self.replay_steps.get(&position).cloned() {
130            check_replay_identity(&existing, position, name, &StepKind::Signal)?;
131            self.last_step_ids = vec![existing.id];
132            return self.signal_replay::<S>(name, key, existing).await;
133        }
134
135        let schema = to_value(schema_for!(S))?;
136        let now = Utc::now();
137        let deadline_at = deadline(name, now, timeout)?;
138        let input = json!({
139            "name": S::NAME,
140            "key": key,
141            SIGNAL_SCHEMA_KEY: schema,
142            "waiting_since": now,
143            DEADLINE_AT_KEY: deadline_at,
144        });
145
146        // An interceptor resolves the step inline: the run never suspends.
147        if let Some(interceptor) = self.interceptor.clone()
148            && let Some(outcome) = interceptor.intercept_signal(name, S::NAME, key, &schema)
149        {
150            let step = self.create_signal_step(name, position, input).await?;
151            self.start_step(step.id, now).await?;
152            self.last_step_ids = vec![step.id];
153
154            let output = match outcome {
155                SignalOutcome::Received(payload) => json!({
156                    SIGNAL_TIMED_OUT_KEY: false,
157                    "signal_id": null,
158                    "payload": payload,
159                }),
160                SignalOutcome::TimedOut => timed_out_output(),
161            };
162            self.store
163                .update_step(
164                    step.id,
165                    StepUpdate {
166                        status: Some(StepStatus::Completed),
167                        output: Some(output.clone()),
168                        completed_at: Some(Utc::now()),
169                        ..StepUpdate::default()
170                    },
171                )
172                .await?;
173            info!(
174                run_id = %self.run_id,
175                step = %name,
176                position,
177                "signal step resolved by the step interceptor"
178            );
179            return decode_signal_output(name, &output);
180        }
181
182        // First execution: open the step. Once `Running` it is visible to
183        // deliveries, so a signal sent from here on resolves it.
184        let step = self.create_signal_step(name, position, input).await?;
185        self.start_step(step.id, now).await?;
186        self.last_step_ids = vec![step.id];
187
188        if let Some(output) = self.received_signal::<S>(step.id, key).await? {
189            info!(
190                run_id = %self.run_id,
191                step = %name,
192                position,
193                "signal already received, not suspending"
194            );
195            return decode_signal_output(name, &output);
196        }
197
198        info!(
199            run_id = %self.run_id,
200            step = %name,
201            signal = %S::NAME,
202            key = %key,
203            deadline_at = %deadline_at,
204            "waiting for signal"
205        );
206        Err(EngineError::SignalWaiting {
207            run_id: self.run_id,
208            step_id: step.id,
209            step_name: name.to_string(),
210            name: S::NAME.to_string(),
211            key: key.to_string(),
212            deadline_at,
213        })
214    }
215
216    /// Replay a signal step recorded in this attempt.
217    async fn signal_replay<S: Signal>(
218        &mut self,
219        name: &str,
220        key: &str,
221        existing: Step,
222    ) -> Result<Option<S>, EngineError> {
223        match existing.status.state {
224            StepStatus::Completed => {
225                let output = require_output(name, existing.output)?;
226                info!(
227                    run_id = %self.run_id,
228                    step = %name,
229                    "signal step replayed (resolved)"
230                );
231                decode_signal_output(name, &output)
232            }
233            StepStatus::Running => {
234                let deadline_at = stored_deadline(name, existing.input.as_ref())?;
235
236                if Utc::now() >= deadline_at {
237                    let resolution = self
238                        .store
239                        .resolve_signal_step(existing.id, timed_out_output())
240                        .await?;
241                    let output = match resolution {
242                        SignalStepResolution::Resolved { .. } => timed_out_output(),
243                        // A delivery won the race against the timeout.
244                        SignalStepResolution::NotWaiting { output } => {
245                            require_output(name, output)?
246                        }
247                    };
248                    info!(
249                        run_id = %self.run_id,
250                        step = %name,
251                        "signal step deadline reached"
252                    );
253                    return decode_signal_output(name, &output);
254                }
255
256                // Woken before the deadline without a delivery on the step:
257                // look once more, then keep waiting until the same deadline.
258                if let Some(output) = self.received_signal::<S>(existing.id, key).await? {
259                    return decode_signal_output(name, &output);
260                }
261                info!(
262                    run_id = %self.run_id,
263                    step = %name,
264                    deadline_at = %deadline_at,
265                    "signal still not received, suspending again"
266                );
267                Err(EngineError::SignalWaiting {
268                    run_id: self.run_id,
269                    step_id: existing.id,
270                    step_name: name.to_string(),
271                    name: S::NAME.to_string(),
272                    key: key.to_string(),
273                    deadline_at,
274                })
275            }
276            state => Err(EngineError::StepConfig(format!(
277                "signal step '{name}' is in state {state:?}"
278            ))),
279        }
280    }
281
282    /// Resolve `step_id` with the oldest signal received for `key` since the
283    /// run was created whose payload matches `S`.
284    ///
285    /// Returns the output recorded on the step, or `None` when no such signal
286    /// exists.
287    async fn received_signal<S: Signal>(
288        &self,
289        step_id: Uuid,
290        key: &str,
291    ) -> Result<Option<Value>, EngineError> {
292        let since = self.run_created_at().await?;
293        let signals = self.store.list_signals_for_key(S::NAME, key, since).await?;
294        let Some(signal) = signals
295            .iter()
296            .find(|s| from_value::<S>(s.payload.clone()).is_ok())
297        else {
298            return Ok(None);
299        };
300
301        let output = received_output(signal);
302        let resolution = self
303            .store
304            .resolve_signal_step(step_id, output.clone())
305            .await?;
306        match resolution {
307            SignalStepResolution::Resolved { .. } => Ok(Some(output)),
308            // A concurrent delivery resolved the step first: its output wins.
309            SignalStepResolution::NotWaiting { output } => Ok(output),
310        }
311    }
312
313    /// Create the step record of a signal wait.
314    async fn create_signal_step(
315        &self,
316        name: &str,
317        position: u32,
318        input: Value,
319    ) -> Result<Step, EngineError> {
320        let trace_id = step_trace_id(self.run_id, name, position);
321        Ok(self
322            .store
323            .create_step(NewStep {
324                run_id: self.run_id,
325                trace_id,
326                name: name.to_string(),
327                kind: StepKind::Signal,
328                position,
329                input: Some(input),
330                is_error_handler: false,
331            })
332            .await?)
333    }
334}
335
336/// The wait deadline: `now + timeout`.
337fn deadline(
338    name: &str,
339    now: DateTime<Utc>,
340    timeout: Duration,
341) -> Result<DateTime<Utc>, EngineError> {
342    let delta = TimeDelta::from_std(timeout).ok();
343    match delta.and_then(|delta| now.checked_add_signed(delta)) {
344        Some(deadline_at) => Ok(deadline_at),
345        None => Err(EngineError::StepConfig(format!(
346            "signal step '{name}' timeout {timeout:?} is out of range"
347        ))),
348    }
349}
350
351/// The deadline stored in a signal step's input when it opened.
352fn stored_deadline(name: &str, input: Option<&Value>) -> Result<DateTime<Utc>, EngineError> {
353    let Some(value) = input.and_then(|i| i.get(DEADLINE_AT_KEY)) else {
354        let message = format!("signal step '{name}' has no stored deadline");
355        return Err(EngineError::StepConfig(message));
356    };
357    match from_value(value.clone()) {
358        Ok(deadline_at) => Ok(deadline_at),
359        Err(e) => Err(EngineError::StepConfig(format!(
360            "signal step '{name}' has an invalid deadline: {e}"
361        ))),
362    }
363}
364
365/// The output recorded on a resolved signal step.
366fn require_output(name: &str, output: Option<Value>) -> Result<Value, EngineError> {
367    let Some(output) = output else {
368        let message = format!("signal step '{name}' has no stored output");
369        return Err(EngineError::StepConfig(message));
370    };
371    Ok(output)
372}
373
374/// Turn a signal step output into the handler's result: `None` on timeout,
375/// the payload deserialized into `S` otherwise.
376fn decode_signal_output<S: Signal>(name: &str, output: &Value) -> Result<Option<S>, EngineError> {
377    if output.get(SIGNAL_TIMED_OUT_KEY).and_then(Value::as_bool) == Some(true) {
378        return Ok(None);
379    }
380    let Some(payload) = output.get("payload") else {
381        let message = format!("signal step '{name}' output has no payload");
382        return Err(EngineError::StepConfig(message));
383    };
384    match from_value::<S>(payload.clone()) {
385        Ok(signal) => Ok(Some(signal)),
386        Err(e) => Err(EngineError::StepConfig(format!(
387            "signal step '{name}' payload does not match the expected type: {e}"
388        ))),
389    }
390}