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                recorder.record_or_stop(name, StepKind::Signal, &self.workflow_name, None)?;
120                recorder.set_last(vec![name.to_string()]);
121            }
122            return Ok(from_value::<S>(json!({})).ok());
123        }
124
125        let position = self.next_position();
126
127        // Replay: the step exists from a prior execution of this attempt.
128        if let Some(existing) = self.replay_steps.get(&position).cloned() {
129            check_replay_identity(&existing, position, name, &StepKind::Signal)?;
130            self.last_step_ids = vec![existing.id];
131            return self.signal_replay::<S>(name, key, existing).await;
132        }
133
134        let schema = to_value(schema_for!(S))?;
135        let now = Utc::now();
136        let deadline_at = deadline(name, now, timeout)?;
137        let input = json!({
138            "name": S::NAME,
139            "key": key,
140            SIGNAL_SCHEMA_KEY: schema,
141            "waiting_since": now,
142            DEADLINE_AT_KEY: deadline_at,
143        });
144
145        // An interceptor resolves the step inline: the run never suspends.
146        if let Some(interceptor) = self.interceptor.clone()
147            && let Some(outcome) = interceptor.intercept_signal(name, S::NAME, key, &schema)
148        {
149            let step = self.create_signal_step(name, position, input).await?;
150            self.start_step(step.id, now).await?;
151            self.last_step_ids = vec![step.id];
152
153            let output = match outcome {
154                SignalOutcome::Received(payload) => json!({
155                    SIGNAL_TIMED_OUT_KEY: false,
156                    "signal_id": null,
157                    "payload": payload,
158                }),
159                SignalOutcome::TimedOut => timed_out_output(),
160            };
161            self.store
162                .update_step(
163                    step.id,
164                    StepUpdate {
165                        status: Some(StepStatus::Completed),
166                        output: Some(output.clone()),
167                        completed_at: Some(Utc::now()),
168                        ..StepUpdate::default()
169                    },
170                )
171                .await?;
172            info!(
173                run_id = %self.run_id,
174                step = %name,
175                position,
176                "signal step resolved by the step interceptor"
177            );
178            return decode_signal_output(name, &output);
179        }
180
181        // First execution: open the step. Once `Running` it is visible to
182        // deliveries, so a signal sent from here on resolves it.
183        let step = self.create_signal_step(name, position, input).await?;
184        self.start_step(step.id, now).await?;
185        self.last_step_ids = vec![step.id];
186
187        if let Some(output) = self.received_signal::<S>(step.id, key).await? {
188            info!(
189                run_id = %self.run_id,
190                step = %name,
191                position,
192                "signal already received, not suspending"
193            );
194            return decode_signal_output(name, &output);
195        }
196
197        info!(
198            run_id = %self.run_id,
199            step = %name,
200            signal = %S::NAME,
201            key = %key,
202            deadline_at = %deadline_at,
203            "waiting for signal"
204        );
205        Err(EngineError::SignalWaiting {
206            run_id: self.run_id,
207            step_id: step.id,
208            step_name: name.to_string(),
209            name: S::NAME.to_string(),
210            key: key.to_string(),
211            deadline_at,
212        })
213    }
214
215    /// Replay a signal step recorded in this attempt.
216    async fn signal_replay<S: Signal>(
217        &mut self,
218        name: &str,
219        key: &str,
220        existing: Step,
221    ) -> Result<Option<S>, EngineError> {
222        match existing.status.state {
223            StepStatus::Completed => {
224                let output = require_output(name, existing.output)?;
225                info!(
226                    run_id = %self.run_id,
227                    step = %name,
228                    "signal step replayed (resolved)"
229                );
230                decode_signal_output(name, &output)
231            }
232            StepStatus::Running => {
233                let deadline_at = stored_deadline(name, existing.input.as_ref())?;
234
235                if Utc::now() >= deadline_at {
236                    let resolution = self
237                        .store
238                        .resolve_signal_step(existing.id, timed_out_output())
239                        .await?;
240                    let output = match resolution {
241                        SignalStepResolution::Resolved { .. } => timed_out_output(),
242                        // A delivery won the race against the timeout.
243                        SignalStepResolution::NotWaiting { output } => {
244                            require_output(name, output)?
245                        }
246                    };
247                    info!(
248                        run_id = %self.run_id,
249                        step = %name,
250                        "signal step deadline reached"
251                    );
252                    return decode_signal_output(name, &output);
253                }
254
255                // Woken before the deadline without a delivery on the step:
256                // look once more, then keep waiting until the same deadline.
257                if let Some(output) = self.received_signal::<S>(existing.id, key).await? {
258                    return decode_signal_output(name, &output);
259                }
260                info!(
261                    run_id = %self.run_id,
262                    step = %name,
263                    deadline_at = %deadline_at,
264                    "signal still not received, suspending again"
265                );
266                Err(EngineError::SignalWaiting {
267                    run_id: self.run_id,
268                    step_id: existing.id,
269                    step_name: name.to_string(),
270                    name: S::NAME.to_string(),
271                    key: key.to_string(),
272                    deadline_at,
273                })
274            }
275            state => Err(EngineError::StepConfig(format!(
276                "signal step '{name}' is in state {state:?}"
277            ))),
278        }
279    }
280
281    /// Resolve `step_id` with the oldest signal received for `key` since the
282    /// run was created whose payload matches `S`.
283    ///
284    /// Returns the output recorded on the step, or `None` when no such signal
285    /// exists.
286    async fn received_signal<S: Signal>(
287        &self,
288        step_id: Uuid,
289        key: &str,
290    ) -> Result<Option<Value>, EngineError> {
291        let since = self.run_created_at().await?;
292        let signals = self.store.list_signals_for_key(S::NAME, key, since).await?;
293        let Some(signal) = signals
294            .iter()
295            .find(|s| from_value::<S>(s.payload.clone()).is_ok())
296        else {
297            return Ok(None);
298        };
299
300        let output = received_output(signal);
301        let resolution = self
302            .store
303            .resolve_signal_step(step_id, output.clone())
304            .await?;
305        match resolution {
306            SignalStepResolution::Resolved { .. } => Ok(Some(output)),
307            // A concurrent delivery resolved the step first: its output wins.
308            SignalStepResolution::NotWaiting { output } => Ok(output),
309        }
310    }
311
312    /// Create the step record of a signal wait.
313    async fn create_signal_step(
314        &self,
315        name: &str,
316        position: u32,
317        input: Value,
318    ) -> Result<Step, EngineError> {
319        let trace_id = step_trace_id(self.run_id, name, position);
320        Ok(self
321            .store
322            .create_step(NewStep {
323                run_id: self.run_id,
324                trace_id,
325                name: name.to_string(),
326                kind: StepKind::Signal,
327                position,
328                input: Some(input),
329                is_error_handler: false,
330            })
331            .await?)
332    }
333}
334
335/// The wait deadline: `now + timeout`.
336fn deadline(
337    name: &str,
338    now: DateTime<Utc>,
339    timeout: Duration,
340) -> Result<DateTime<Utc>, EngineError> {
341    let delta = TimeDelta::from_std(timeout).ok();
342    match delta.and_then(|delta| now.checked_add_signed(delta)) {
343        Some(deadline_at) => Ok(deadline_at),
344        None => Err(EngineError::StepConfig(format!(
345            "signal step '{name}' timeout {timeout:?} is out of range"
346        ))),
347    }
348}
349
350/// The deadline stored in a signal step's input when it opened.
351fn stored_deadline(name: &str, input: Option<&Value>) -> Result<DateTime<Utc>, EngineError> {
352    let Some(value) = input.and_then(|i| i.get(DEADLINE_AT_KEY)) else {
353        let message = format!("signal step '{name}' has no stored deadline");
354        return Err(EngineError::StepConfig(message));
355    };
356    match from_value(value.clone()) {
357        Ok(deadline_at) => Ok(deadline_at),
358        Err(e) => Err(EngineError::StepConfig(format!(
359            "signal step '{name}' has an invalid deadline: {e}"
360        ))),
361    }
362}
363
364/// The output recorded on a resolved signal step.
365fn require_output(name: &str, output: Option<Value>) -> Result<Value, EngineError> {
366    let Some(output) = output else {
367        let message = format!("signal step '{name}' has no stored output");
368        return Err(EngineError::StepConfig(message));
369    };
370    Ok(output)
371}
372
373/// Turn a signal step output into the handler's result: `None` on timeout,
374/// the payload deserialized into `S` otherwise.
375fn decode_signal_output<S: Signal>(name: &str, output: &Value) -> Result<Option<S>, EngineError> {
376    if output.get(SIGNAL_TIMED_OUT_KEY).and_then(Value::as_bool) == Some(true) {
377        return Ok(None);
378    }
379    let Some(payload) = output.get("payload") else {
380        let message = format!("signal step '{name}' output has no payload");
381        return Err(EngineError::StepConfig(message));
382    };
383    match from_value::<S>(payload.clone()) {
384        Ok(signal) => Ok(Some(signal)),
385        Err(e) => Err(EngineError::StepConfig(format!(
386            "signal step '{name}' payload does not match the expected type: {e}"
387        ))),
388    }
389}