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