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,
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        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_timeout = self.guard_remaining_timeout();
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 handle = join_set.spawn(async move {
230                let result = match parallel_timeout {
231                    Some(dur) => {
232                        match timeout(
233                            dur,
234                            execute_step_config_intercepted(
235                                &config,
236                                &provider,
237                                step_log_sender,
238                                interceptor.as_ref(),
239                            ),
240                        )
241                        .await
242                        {
243                            Ok(r) => r,
244                            Err(_elapsed) => {
245                                Err(EngineError::from(WorkflowRejection::WorkflowTimeout {
246                                    elapsed_secs: 0,
247                                    max: 0,
248                                }))
249                            }
250                        }
251                    }
252                    None => {
253                        execute_step_config_intercepted(
254                            &config,
255                            &provider,
256                            step_log_sender,
257                            interceptor.as_ref(),
258                        )
259                        .await
260                    }
261                };
262                (idx, result)
263            });
264            task_index.insert(handle.id(), idx);
265        }
266
267        // JoinSet returns in completion order; indexed_results restores input order.
268        let mut indexed_results: Vec<Option<Result<StepOutput, String>>> =
269            vec![None; step_records.len()];
270        let mut first_error: Option<EngineError> = None;
271        // Earliest capacity wait of the wave: the run sleeps once every other
272        // step settled, and only the parked steps run again when it wakes.
273        let mut capacity_wait: Option<(Uuid, String, DateTime<Utc>)> = None;
274
275        while let Some(join_result) = join_set.join_next().await {
276            let (idx, step_result) = match join_result {
277                Ok(r) => r,
278                Err(e) => {
279                    let error_msg = format!("join error: {e}");
280                    if let Some(&idx) = task_index.get(&e.id()) {
281                        let (step_id, _, step_name, _) = &step_records[idx];
282                        let completed_at = Utc::now();
283                        error!(
284                            run_id = %self.run_id,
285                            step = %step_name,
286                            error = %error_msg,
287                            "parallel step panicked or was cancelled"
288                        );
289                        if let Err(store_err) = self
290                            .store
291                            .update_step(
292                                *step_id,
293                                StepUpdate {
294                                    status: Some(StepStatus::Failed),
295                                    error: Some(error_msg.clone()),
296                                    completed_at: Some(completed_at),
297                                    ..StepUpdate::default()
298                                },
299                            )
300                            .await
301                        {
302                            error!(
303                                run_id = %self.run_id,
304                                step_id = %step_id,
305                                error = %store_err,
306                                "failed to persist JoinError for step"
307                            );
308                        }
309                        indexed_results[idx] = Some(Err(error_msg.clone()));
310                    }
311                    if first_error.is_none() {
312                        first_error = Some(EngineError::StepConfig(error_msg));
313                    }
314                    if fail_fast {
315                        join_set.abort_all();
316                    }
317                    continue;
318                }
319            };
320
321            let (step_id, step_trace, step_name, step_config) = &step_records[idx];
322            let completed_at = Utc::now();
323
324            if let Err(err) = self
325                .store_step_outputs(step_config, *step_id, step_name, step_result.is_ok())
326                .await
327            {
328                self.fail_step(*step_id, &err).await;
329                indexed_results[idx] = Some(Err(err.to_string()));
330                if first_error.is_none() {
331                    first_error = Some(err);
332                }
333                if fail_fast {
334                    join_set.abort_all();
335                }
336                continue;
337            }
338
339            match step_result {
340                Ok(output) => {
341                    self.total_cost_usd += output.cost_usd;
342                    self.total_duration_ms += output.duration_ms;
343
344                    // Record token usage in the guard for agent steps.
345                    if matches!(step_config, StepConfig::Agent(_)) {
346                        let tokens = output.total_tokens();
347                        if tokens > 0
348                            && let Err(guard_err) = self.guard_record_tokens(tokens)
349                        {
350                            if first_error.is_none() {
351                                first_error = Some(guard_err);
352                            }
353                            if fail_fast {
354                                join_set.abort_all();
355                            }
356                        }
357                    }
358
359                    let debug_messages_json = output.debug_messages_json();
360
361                    self.store
362                        .update_step(
363                            *step_id,
364                            StepUpdate {
365                                status: Some(StepStatus::Completed),
366                                output: Some(output.output.clone()),
367                                duration_ms: Some(output.duration_ms),
368                                cost_usd: Some(output.cost_usd),
369                                input_tokens: output.input_tokens,
370                                cache_read_input_tokens: output.cache_read_input_tokens,
371                                cache_creation_input_tokens: output.cache_creation_input_tokens,
372                                output_tokens: output.output_tokens,
373                                completed_at: Some(completed_at),
374                                debug_messages: debug_messages_json,
375                                account_id: output.account_id,
376                                environment_id: output.environment_id.clone(),
377                                ..StepUpdate::default()
378                            },
379                        )
380                        .await?;
381
382                    self.step_results.push(StepResult::from_success(
383                        *step_trace,
384                        step_name,
385                        &output,
386                    ));
387
388                    if let Some(ref bus) = self.event_bus
389                        && matches!(step_config, StepConfig::Agent(_))
390                    {
391                        let tokens = output.total_tokens();
392                        bus.publish(
393                            self.run_id,
394                            WorkflowEvent::AgentStepTokensUsed(WorkflowAgentStepTokensUsedEvent {
395                                step_name: step_name.clone(),
396                                tokens,
397                                cost_usd: output.cost_usd,
398                            }),
399                        );
400                    }
401
402                    info!(
403                        run_id = %self.run_id,
404                        step = %step_name,
405                        trace_id = %step_trace,
406                        duration_ms = output.duration_ms,
407                        "parallel step completed"
408                    );
409
410                    indexed_results[idx] = Some(Ok(output));
411                }
412                Err(EngineError::Operation(OperationError::Agent(AgentError::CapacityWait {
413                    kind,
414                    wake_at,
415                }))) => {
416                    // Not a failure, even with `allow_failure`: see `run_step`.
417                    self.park_capacity_step(*step_id).await?;
418                    info!(
419                        run_id = %self.run_id,
420                        step = %step_name,
421                        kind = %kind,
422                        wake_at = %wake_at,
423                        "no provider capacity, parallel step parked until the run wakes"
424                    );
425                    if capacity_wait
426                        .as_ref()
427                        .is_none_or(|(_, _, earliest)| wake_at < *earliest)
428                    {
429                        capacity_wait = Some((*step_id, kind, wake_at));
430                    }
431                }
432                Err(err) => {
433                    let err_msg = err.to_string();
434                    let debug_messages_json = extract_debug_messages_from_error(&err);
435                    let partial = extract_partial_usage_from_error(&err);
436                    let raw_response_output = extract_raw_response_from_error(&err);
437
438                    if let Some(ref usage) = partial {
439                        if let Some(cost) = usage.cost_usd {
440                            self.total_cost_usd += cost;
441                        }
442                        if let Some(dur) = usage.duration_ms {
443                            self.total_duration_ms += dur;
444                        }
445                    }
446
447                    if let Err(store_err) = self
448                        .store
449                        .update_step(
450                            *step_id,
451                            StepUpdate {
452                                status: Some(StepStatus::Failed),
453                                error: Some(err_msg.clone()),
454                                output: raw_response_output.clone(),
455                                completed_at: Some(completed_at),
456                                debug_messages: debug_messages_json,
457                                duration_ms: partial.as_ref().and_then(|p| p.duration_ms),
458                                cost_usd: partial.as_ref().and_then(|p| p.cost_usd),
459                                input_tokens: partial.as_ref().and_then(|p| p.input_tokens),
460                                cache_read_input_tokens: partial
461                                    .as_ref()
462                                    .and_then(|p| p.cache_read_input_tokens),
463                                cache_creation_input_tokens: partial
464                                    .as_ref()
465                                    .and_then(|p| p.cache_creation_input_tokens),
466                                output_tokens: partial.as_ref().and_then(|p| p.output_tokens),
467                                ..StepUpdate::default()
468                            },
469                        )
470                        .await
471                    {
472                        error!(
473                            step_id = %step_id,
474                            error = %store_err,
475                            "failed to persist parallel step failure"
476                        );
477                    }
478
479                    let err_duration = partial.as_ref().and_then(|p| p.duration_ms).unwrap_or(0);
480                    let err_cost = partial
481                        .as_ref()
482                        .and_then(|p| p.cost_usd)
483                        .unwrap_or(Decimal::ZERO);
484                    self.step_results.push(StepResult::from_failure(
485                        *step_trace,
486                        step_name,
487                        &err_msg,
488                        err_duration,
489                        err_cost,
490                    ));
491
492                    if step_config.allow_failure() {
493                        self.has_allowed_failure = true;
494                        info!(
495                            run_id = %self.run_id,
496                            step = %step_name,
497                            error = %err_msg,
498                            "parallel step failed but allow_failure is set, continuing"
499                        );
500                        indexed_results[idx] = Some(Ok(allowed_failure_output(
501                            &err_msg,
502                            raw_response_output,
503                            partial.as_ref(),
504                        )));
505                    } else {
506                        indexed_results[idx] = Some(Err(err_msg.clone()));
507
508                        if first_error.is_none() {
509                            first_error = Some(err);
510                        }
511
512                        if fail_fast {
513                            join_set.abort_all();
514                        }
515                    }
516                }
517            }
518        }
519
520        if let Some(err) = first_error {
521            return Err(err);
522        }
523
524        if let Some((step_id, kind, wake_at)) = capacity_wait {
525            return Err(EngineError::CapacitySleeping {
526                run_id: self.run_id,
527                step_id,
528                kind,
529                wake_at,
530            });
531        }
532
533        self.persist_progress().await;
534
535        // Build results in original order, replayed and launched steps alike.
536        for (idx, (step_id, _trace_id, name, config)) in step_records.iter().enumerate() {
537            let mut output = match indexed_results[idx].take() {
538                Some(Ok(o)) => o,
539                _ => unreachable!("all steps succeeded if no error returned"),
540            };
541            output.artifacts = StepArtifacts::new(name, Some(*step_id), config.declared_outputs());
542            slots[record_slots[idx]] = Some(ParallelStepResult {
543                name: name.clone(),
544                output,
545                step_id: *step_id,
546            });
547        }
548        let results: Vec<ParallelStepResult> = slots.into_iter().flatten().collect();
549        self.last_step_ids = results.iter().map(|r| r.step_id).collect();
550
551        Ok(results)
552    }
553
554    /// Replay the steps of a parallel wave that completed in a previous
555    /// execution of the current attempt.
556    ///
557    /// Returns one slot per entry of `steps`, in order: `Some` holds the
558    /// replayed result of a step that completed at `position`, `None` marks a
559    /// step that must run. `last_step_ids` is left untouched, so the steps
560    /// launched next still depend on the steps before the wave.
561    fn replay_wave(
562        &mut self,
563        position: u32,
564        steps: &[(&str, StepConfig)],
565    ) -> Result<Vec<Option<ParallelStepResult>>, EngineError> {
566        let mut slots = Vec::with_capacity(steps.len());
567        for (name, config) in steps {
568            let Some(step) = self.replay_wave_steps.get(&(position, (*name).to_string())) else {
569                slots.push(None);
570                continue;
571            };
572            check_replay_identity(step, position, name, &config.kind())?;
573            if step.status.state != StepStatus::Completed {
574                slots.push(None);
575                continue;
576            }
577
578            let mut output = StepOutput::from(step);
579            let step_id = step.id;
580            // Cost is not added: `carry_over_run_totals` seeded
581            // `total_cost_usd` from the run totals persisted before the
582            // suspension, which already include this step.
583            self.total_duration_ms += output.duration_ms;
584            output.artifacts = StepArtifacts::new(name, Some(step_id), config.declared_outputs());
585
586            info!(
587                run_id = %self.run_id,
588                step = %name,
589                position,
590                "step replayed from previous execution"
591            );
592
593            slots.push(Some(ParallelStepResult {
594                name: (*name).to_string(),
595                output,
596                step_id,
597            }));
598        }
599        Ok(slots)
600    }
601}
602
603/// Reject a wave in which two steps share a name.
604///
605/// The name identifies a step of the wave in the run timeline, in its
606/// artifact handles and on resume, where two steps with one name would replay
607/// the same stored result.
608fn reject_duplicate_names(steps: &[(&str, StepConfig)]) -> Result<(), EngineError> {
609    let mut seen = HashSet::with_capacity(steps.len());
610    for (name, _) in steps {
611        if !seen.insert(*name) {
612            return Err(EngineError::StepConfig(format!(
613                "parallel wave has two steps named {name:?}; each step of a wave needs its own name"
614            )));
615        }
616    }
617    Ok(())
618}