Skip to main content

ironflow_engine/context/steps/
parallel.rs

1//! Parallel step wave for [`WorkflowContext`].
2
3use std::collections::{HashMap, HashSet};
4
5use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde_json::to_value;
8use tokio::task::{Id, JoinSet};
9use tokio::time::timeout;
10use tracing::{error, info};
11use uuid::Uuid;
12
13use ironflow_core::error::{AgentError, OperationError};
14use ironflow_store::models::{NewStep, StepStatus, StepUpdate, step_trace_id};
15
16use crate::budget::step_budget_usd;
17use crate::config::StepConfig;
18use crate::context::WorkflowContext;
19use crate::context::failure::{
20    allowed_failure_output, extract_debug_messages_from_error, extract_partial_usage_from_error,
21    extract_raw_response_from_error, replayed_failure_output,
22};
23use crate::context::lifecycle::check_replay_identity;
24use crate::error::EngineError;
25use crate::executor::{
26    ParallelStepResult, StepArtifacts, StepOutput, StepResult, execute_step_config_intercepted,
27};
28use crate::guard::WorkflowRejection;
29use crate::log_sender::StepLogSender;
30use crate::notify::{WorkflowAgentStepTokensUsedEvent, WorkflowEvent};
31use crate::plan::{lock_plan, planned_output};
32
33impl WorkflowContext {
34    /// Execute multiple steps concurrently (wait-all model).
35    ///
36    /// All steps in the batch execute in parallel via `tokio::JoinSet`.
37    /// Each step is recorded with the same `position` (execution wave).
38    /// Dependencies on previous steps are recorded automatically.
39    ///
40    /// When `fail_fast` is true, remaining steps are aborted on the first
41    /// failure. When false, all steps run to completion and the first
42    /// error is returned.
43    ///
44    /// Every step of a wave must have its own name: the name identifies the
45    /// step in the run timeline, in its artifact handles, on resume and in the
46    /// `ironflow.io/step` pod label. Two steps sharing that label would let
47    /// the K8s ephemeral provider delete one step's pod when starting the other.
48    ///
49    /// On resume, each step of the wave that already completed in a prior
50    /// execution of the current attempt is replayed from the store; only the
51    /// other steps of the wave are launched again.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`EngineError::StepConfig`] if two steps of the wave share a
56    /// name, before anything runs. Returns [`EngineError::ReplayDivergence`]
57    /// when the step recorded at a wave position has a different kind.
58    /// Returns [`EngineError`] if any step fails.
59    ///
60    /// # Examples
61    ///
62    /// ```no_run
63    /// use ironflow_engine::context::WorkflowContext;
64    /// use ironflow_engine::config::{StepConfig, ShellConfig};
65    /// use ironflow_engine::error::EngineError;
66    ///
67    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
68    /// let results = ctx.parallel(
69    ///     vec![
70    ///         ("test-unit", StepConfig::Shell(ShellConfig::new("cargo test --lib"))),
71    ///         ("lint", StepConfig::Shell(ShellConfig::new("cargo clippy"))),
72    ///     ],
73    ///     true,
74    /// ).await?;
75    ///
76    /// for r in &results {
77    ///     println!("{}: {:?}", r.name, r.output.output);
78    /// }
79    /// # Ok(())
80    /// # }
81    /// ```
82    pub async fn parallel(
83        &mut self,
84        steps: Vec<(&str, StepConfig)>,
85        fail_fast: bool,
86    ) -> Result<Vec<ParallelStepResult>, EngineError> {
87        if steps.is_empty() {
88            return Ok(Vec::new());
89        }
90        reject_duplicate_names(&steps)?;
91
92        // Plan mode: record the whole wave under one parallel group and return
93        // synthetic outputs. No step record is created and nothing runs.
94        if let Some(plan) = self.plan().cloned() {
95            self.position += 1;
96            let mut results = Vec::with_capacity(steps.len());
97            let mut names = Vec::with_capacity(steps.len());
98            let capped = {
99                let mut recorder = lock_plan(&plan);
100                let group = recorder.next_group();
101                let mut capped = None;
102                for (name, config) in &steps {
103                    let wave = Some(group.clone());
104                    if let Err(err) =
105                        recorder.record_or_stop(name, config.kind(), &self.workflow_name, wave)
106                    {
107                        capped = Some(err);
108                        break;
109                    }
110                    names.push((*name).to_string());
111                    let estimate = recorder.estimate_for(name);
112                    let mut output = planned_output(config, estimate);
113                    output.artifacts = StepArtifacts::new(name, None, config.declared_outputs());
114                    results.push(ParallelStepResult {
115                        name: (*name).to_string(),
116                        output,
117                        step_id: Uuid::now_v7(),
118                    });
119                }
120                recorder.set_last(names);
121                capped
122            };
123            // Over the cap the handler stops: a partial wave is never returned.
124            return match capped {
125                Some(err) => Err(err),
126                None => Ok(results),
127            };
128        }
129
130        // Guard timeout: checked before launching the wave.
131        self.check_guard_timeout()?;
132
133        let wave_position = self.position;
134        self.position += 1;
135
136        // Replay: a step of this wave that already completed in a prior
137        // execution of the current attempt returns its cached output; only the
138        // other steps are launched. When the whole wave completed, nothing is
139        // created or launched. Mirrors the replay-before-budget-check ordering
140        // of `execute_step`.
141        let mut slots = self.replay_wave(wave_position, &steps)?;
142        if slots.iter().all(Option::is_some) {
143            let results: Vec<ParallelStepResult> = slots.into_iter().flatten().collect();
144            self.last_step_ids = results.iter().map(|r| r.step_id).collect();
145            return Ok(results);
146        }
147
148        // Cost cap: the steps left to run are charged at once. Refused before
149        // any step record is created, so nothing in the wave starts.
150        let wave_budget: Decimal = steps
151            .iter()
152            .zip(&slots)
153            .filter(|(_, slot)| slot.is_none())
154            .filter_map(|((_, config), _)| match config {
155                StepConfig::Agent(agent_config) => Some(agent_config.max_budget_usd),
156                _ => None,
157            })
158            .map(step_budget_usd)
159            .sum();
160        // Held until every step of the wave has been charged.
161        let _reservation = self.check_run_budget(wave_budget)?;
162
163        let now = Utc::now();
164        let mut step_records: Vec<(Uuid, Uuid, String, StepConfig)> =
165            Vec::with_capacity(steps.len());
166        // Index in `steps` of each entry of `step_records`.
167        let mut record_slots: Vec<usize> = Vec::with_capacity(steps.len());
168
169        for (slot, (name, config)) in steps.iter().enumerate() {
170            if slots[slot].is_some() {
171                continue;
172            }
173            let kind = config.kind();
174            let trace_id = step_trace_id(self.run_id, name, wave_position);
175            let step = self
176                .store
177                .create_step(NewStep {
178                    run_id: self.run_id,
179                    trace_id,
180                    name: name.to_string(),
181                    kind,
182                    position: wave_position,
183                    input: Some(to_value(config)?),
184                    is_error_handler: false,
185                })
186                .await?;
187
188            self.start_step(step.id, now).await?;
189
190            // Inputs are materialized before any step in the wave starts, so a
191            // missing one fails the wave rather than a half-run command.
192            if let Err(err) = self.prepare_step_inputs(config, wave_position).await {
193                self.fail_step(step.id, &err).await;
194                if !config.allow_failure() {
195                    return Err(err);
196                }
197                self.has_allowed_failure = true;
198                info!(
199                    run_id = %self.run_id,
200                    step = %name,
201                    error = %err,
202                    "parallel step input preparation failed but allow_failure is set, skipping"
203                );
204                continue;
205            }
206
207            let mut config_with_trace = config.clone();
208            self.scope_step_config(&mut config_with_trace, name);
209            self.carry_capacity_wait_since(&mut config_with_trace, wave_position, name)
210                .await?;
211            self.assign_agent_session(&mut config_with_trace, step.id, wave_position, name)
212                .await?;
213            step_records.push((step.id, trace_id, name.to_string(), config_with_trace));
214            record_slots.push(slot);
215        }
216
217        let mut join_set = JoinSet::new();
218        let mut task_index: HashMap<Id, usize> = HashMap::new();
219        let parallel_timeout = self.guard_remaining_timeout();
220        for (idx, (step_id, _trace_id, step_name, config)) in step_records.iter().enumerate() {
221            let provider = self.provider.clone();
222            // Each task owns its own handle: `intercept` is synchronous, so no
223            // borrow of the context is held across an await point.
224            let interceptor = self.interceptor.clone();
225            let config = config.clone();
226            let step_log_sender = self
227                .log_sender
228                .as_ref()
229                .map(|s| StepLogSender::new(s.clone(), self.run_id, *step_id, step_name.clone()));
230            let handle = join_set.spawn(async move {
231                let result = match parallel_timeout {
232                    Some(dur) => {
233                        match timeout(
234                            dur,
235                            execute_step_config_intercepted(
236                                &config,
237                                &provider,
238                                step_log_sender,
239                                interceptor.as_ref(),
240                            ),
241                        )
242                        .await
243                        {
244                            Ok(r) => r,
245                            Err(_elapsed) => {
246                                Err(EngineError::from(WorkflowRejection::WorkflowTimeout {
247                                    elapsed_secs: 0,
248                                    max: 0,
249                                }))
250                            }
251                        }
252                    }
253                    None => {
254                        execute_step_config_intercepted(
255                            &config,
256                            &provider,
257                            step_log_sender,
258                            interceptor.as_ref(),
259                        )
260                        .await
261                    }
262                };
263                (idx, result)
264            });
265            task_index.insert(handle.id(), idx);
266        }
267
268        // JoinSet returns in completion order; indexed_results restores input order.
269        let mut indexed_results: Vec<Option<Result<StepOutput, String>>> =
270            vec![None; step_records.len()];
271        let mut first_error: Option<EngineError> = None;
272        // Earliest capacity wait of the wave: the run sleeps once every other
273        // step settled, and only the parked steps run again when it wakes.
274        let mut capacity_wait: Option<(Uuid, String, DateTime<Utc>)> = None;
275
276        while let Some(join_result) = join_set.join_next().await {
277            let (idx, step_result) = match join_result {
278                Ok(r) => r,
279                Err(e) => {
280                    let error_msg = format!("join error: {e}");
281                    if let Some(&idx) = task_index.get(&e.id()) {
282                        let (step_id, _, step_name, _) = &step_records[idx];
283                        let completed_at = Utc::now();
284                        error!(
285                            run_id = %self.run_id,
286                            step = %step_name,
287                            error = %error_msg,
288                            "parallel step panicked or was cancelled"
289                        );
290                        if let Err(store_err) = self
291                            .store
292                            .update_step(
293                                *step_id,
294                                StepUpdate {
295                                    status: Some(StepStatus::Failed),
296                                    error: Some(error_msg.clone()),
297                                    completed_at: Some(completed_at),
298                                    ..StepUpdate::default()
299                                },
300                            )
301                            .await
302                        {
303                            error!(
304                                run_id = %self.run_id,
305                                step_id = %step_id,
306                                error = %store_err,
307                                "failed to persist JoinError for step"
308                            );
309                        }
310                        indexed_results[idx] = Some(Err(error_msg.clone()));
311                    }
312                    if first_error.is_none() {
313                        first_error = Some(EngineError::StepConfig(error_msg));
314                    }
315                    if fail_fast {
316                        join_set.abort_all();
317                    }
318                    continue;
319                }
320            };
321
322            let (step_id, step_trace, step_name, step_config) = &step_records[idx];
323            let completed_at = Utc::now();
324
325            if let Err(err) = self
326                .store_step_outputs(step_config, *step_id, step_name, step_result.is_ok())
327                .await
328            {
329                self.fail_step(*step_id, &err).await;
330                indexed_results[idx] = Some(Err(err.to_string()));
331                if first_error.is_none() {
332                    first_error = Some(err);
333                }
334                if fail_fast {
335                    join_set.abort_all();
336                }
337                continue;
338            }
339
340            match step_result {
341                Ok(output) => {
342                    self.charge_cost(output.cost_usd);
343                    self.total_duration_ms += output.duration_ms;
344
345                    // Record token usage in the guard for agent steps.
346                    if matches!(step_config, StepConfig::Agent(_)) {
347                        let tokens = output.total_tokens();
348                        if tokens > 0
349                            && let Err(guard_err) = self.guard_record_tokens(tokens)
350                        {
351                            if first_error.is_none() {
352                                first_error = Some(guard_err);
353                            }
354                            if fail_fast {
355                                join_set.abort_all();
356                            }
357                        }
358                    }
359
360                    let debug_messages_json = output.debug_messages_json();
361
362                    self.store
363                        .update_step(
364                            *step_id,
365                            StepUpdate {
366                                status: Some(StepStatus::Completed),
367                                output: Some(output.output.clone()),
368                                duration_ms: Some(output.duration_ms),
369                                cost_usd: Some(output.cost_usd),
370                                input_tokens: output.input_tokens,
371                                cache_read_input_tokens: output.cache_read_input_tokens,
372                                cache_creation_input_tokens: output.cache_creation_input_tokens,
373                                output_tokens: output.output_tokens,
374                                completed_at: Some(completed_at),
375                                debug_messages: debug_messages_json,
376                                account_id: output.account_id,
377                                environment_id: output.environment_id.clone(),
378                                ..StepUpdate::default()
379                            },
380                        )
381                        .await?;
382
383                    self.step_results.push(StepResult::from_success(
384                        *step_trace,
385                        step_name,
386                        &output,
387                    ));
388
389                    if let Some(ref bus) = self.event_bus
390                        && matches!(step_config, StepConfig::Agent(_))
391                    {
392                        let tokens = output.total_tokens();
393                        bus.publish(
394                            self.run_id,
395                            WorkflowEvent::AgentStepTokensUsed(WorkflowAgentStepTokensUsedEvent {
396                                step_name: step_name.clone(),
397                                tokens,
398                                cost_usd: output.cost_usd,
399                            }),
400                        );
401                    }
402
403                    info!(
404                        run_id = %self.run_id,
405                        step = %step_name,
406                        trace_id = %step_trace,
407                        duration_ms = output.duration_ms,
408                        "parallel step completed"
409                    );
410
411                    indexed_results[idx] = Some(Ok(output));
412                }
413                Err(EngineError::Operation(OperationError::Agent(AgentError::CapacityWait {
414                    kind,
415                    wake_at,
416                }))) => {
417                    // Not a failure, even with `allow_failure`: see `run_step`.
418                    self.park_capacity_step(*step_id).await?;
419                    info!(
420                        run_id = %self.run_id,
421                        step = %step_name,
422                        kind = %kind,
423                        wake_at = %wake_at,
424                        "no provider capacity, parallel step parked until the run wakes"
425                    );
426                    if capacity_wait
427                        .as_ref()
428                        .is_none_or(|(_, _, earliest)| wake_at < *earliest)
429                    {
430                        capacity_wait = Some((*step_id, kind, wake_at));
431                    }
432                }
433                Err(err) => {
434                    let err_msg = err.to_string();
435                    let debug_messages_json = extract_debug_messages_from_error(&err);
436                    let partial = extract_partial_usage_from_error(&err);
437                    let raw_response_output = extract_raw_response_from_error(&err);
438
439                    if let Some(ref usage) = partial {
440                        if let Some(cost) = usage.cost_usd {
441                            self.charge_cost(cost);
442                        }
443                        if let Some(dur) = usage.duration_ms {
444                            self.total_duration_ms += dur;
445                        }
446                    }
447
448                    if let Err(store_err) = self
449                        .store
450                        .update_step(
451                            *step_id,
452                            StepUpdate {
453                                status: Some(StepStatus::Failed),
454                                error: Some(err_msg.clone()),
455                                output: raw_response_output.clone(),
456                                completed_at: Some(completed_at),
457                                debug_messages: debug_messages_json,
458                                duration_ms: partial.as_ref().and_then(|p| p.duration_ms),
459                                cost_usd: partial.as_ref().and_then(|p| p.cost_usd),
460                                input_tokens: partial.as_ref().and_then(|p| p.input_tokens),
461                                cache_read_input_tokens: partial
462                                    .as_ref()
463                                    .and_then(|p| p.cache_read_input_tokens),
464                                cache_creation_input_tokens: partial
465                                    .as_ref()
466                                    .and_then(|p| p.cache_creation_input_tokens),
467                                output_tokens: partial.as_ref().and_then(|p| p.output_tokens),
468                                ..StepUpdate::default()
469                            },
470                        )
471                        .await
472                    {
473                        error!(
474                            step_id = %step_id,
475                            error = %store_err,
476                            "failed to persist parallel step failure"
477                        );
478                    }
479
480                    let err_duration = partial.as_ref().and_then(|p| p.duration_ms).unwrap_or(0);
481                    let err_cost = partial
482                        .as_ref()
483                        .and_then(|p| p.cost_usd)
484                        .unwrap_or(Decimal::ZERO);
485                    self.step_results.push(StepResult::from_failure(
486                        *step_trace,
487                        step_name,
488                        &err_msg,
489                        err_duration,
490                        err_cost,
491                    ));
492
493                    if step_config.allow_failure() {
494                        self.has_allowed_failure = true;
495                        info!(
496                            run_id = %self.run_id,
497                            step = %step_name,
498                            error = %err_msg,
499                            "parallel step failed but allow_failure is set, continuing"
500                        );
501                        indexed_results[idx] = Some(Ok(allowed_failure_output(
502                            &err_msg,
503                            raw_response_output,
504                            partial.as_ref(),
505                        )));
506                    } else {
507                        indexed_results[idx] = Some(Err(err_msg.clone()));
508
509                        if first_error.is_none() {
510                            first_error = Some(err);
511                        }
512
513                        if fail_fast {
514                            join_set.abort_all();
515                        }
516                    }
517                }
518            }
519        }
520
521        if let Some(err) = first_error {
522            return Err(err);
523        }
524
525        if let Some((step_id, kind, wake_at)) = capacity_wait {
526            return Err(EngineError::CapacitySleeping {
527                run_id: self.run_id,
528                step_id,
529                kind,
530                wake_at,
531            });
532        }
533
534        self.persist_progress().await;
535
536        // Build results in original order, replayed and launched steps alike.
537        for (idx, (step_id, _trace_id, name, config)) in step_records.iter().enumerate() {
538            let mut output = match indexed_results[idx].take() {
539                Some(Ok(o)) => o,
540                _ => unreachable!("all steps succeeded if no error returned"),
541            };
542            output.artifacts = StepArtifacts::new(name, Some(*step_id), config.declared_outputs());
543            slots[record_slots[idx]] = Some(ParallelStepResult {
544                name: name.clone(),
545                output,
546                step_id: *step_id,
547            });
548        }
549        let results: Vec<ParallelStepResult> = slots.into_iter().flatten().collect();
550        self.last_step_ids = results.iter().map(|r| r.step_id).collect();
551
552        Ok(results)
553    }
554
555    /// Replay the steps of a parallel wave that completed in a previous
556    /// execution of the current attempt.
557    ///
558    /// Returns one slot per entry of `steps`, in order: `Some` holds the
559    /// replayed result of a step that completed, or failed under
560    /// `allow_failure`, at `position`, `None` marks a
561    /// step that must run. `last_step_ids` is left untouched, so the steps
562    /// launched next still depend on the steps before the wave.
563    fn replay_wave(
564        &mut self,
565        position: u32,
566        steps: &[(&str, StepConfig)],
567    ) -> Result<Vec<Option<ParallelStepResult>>, EngineError> {
568        let mut slots = Vec::with_capacity(steps.len());
569        for (name, config) in steps {
570            let Some(step) = self.replay_wave_steps.get(&(position, (*name).to_string())) else {
571                slots.push(None);
572                continue;
573            };
574            check_replay_identity(step, position, name, &config.kind())?;
575            let mut output = match step.status.state {
576                StepStatus::Completed => StepOutput::from(step),
577                StepStatus::Failed if config.allow_failure() => {
578                    let Some(output) = replayed_failure_output(step) else {
579                        slots.push(None);
580                        continue;
581                    };
582                    self.has_allowed_failure = true;
583                    output
584                }
585                _ => {
586                    slots.push(None);
587                    continue;
588                }
589            };
590            let step_id = step.id;
591            // Cost is not added: `carry_over_run_totals` seeded
592            // `total_cost_usd` from the run totals persisted before the
593            // suspension, which already include this step.
594            self.total_duration_ms += output.duration_ms;
595            output.artifacts = StepArtifacts::new(name, Some(step_id), config.declared_outputs());
596
597            info!(
598                run_id = %self.run_id,
599                step = %name,
600                position,
601                "step replayed from previous execution"
602            );
603
604            slots.push(Some(ParallelStepResult {
605                name: (*name).to_string(),
606                output,
607                step_id,
608            }));
609        }
610        Ok(slots)
611    }
612}
613
614/// Reject a wave in which two steps share a name.
615///
616/// The name identifies a step of the wave in the run timeline, in its
617/// artifact handles and on resume, where two steps with one name would replay
618/// the same stored result.
619fn reject_duplicate_names(steps: &[(&str, StepConfig)]) -> Result<(), EngineError> {
620    let mut seen = HashSet::with_capacity(steps.len());
621    for (name, _) in steps {
622        if !seen.insert(*name) {
623            return Err(EngineError::StepConfig(format!(
624                "parallel wave has two steps named {name:?}; each step of a wave needs its own name"
625            )));
626        }
627    }
628    Ok(())
629}