graph_flow/fanout.rs
1//! FanOutTask – a composite task that runs multiple child tasks in parallel
2//!
3//! This task provides simple parallelism within a single graph node. It executes
4//! a fixed set of child tasks concurrently, waits for them to finish, aggregates
5//! their responses into the shared `Context`, and then returns control back to
6//! the graph with `NextAction::Continue` (by default).
7//!
8//! Design goals:
9//! - Keep engine changes minimal (no changes to `Graph` needed)
10//! - Keep semantics simple and predictable
11//! - Make context aggregation explicit and easy to consume by downstream tasks
12//!
13//! Important caveats:
14//! - Child tasks' `NextAction` is ignored by `FanOutTask`. Children are treated as
15//! units-of-work that produce outputs and/or write to context, not as control-flow
16//! steps. The `FanOutTask` itself controls the next step of the graph.
17//! - By default, all children share the same `Context` (concurrent writes must be
18//! coordinated by the user). To avoid key collisions, you can set a prefix so that
19//! each child’s output is stored under `"<prefix>.<child_id>.*"`.
20//! - Error policy is conservative: if any child fails, `FanOutTask` fails with
21//! the *first* error observed. Note that other children still run to
22//! completion, so the context may contain partial results from successful
23//! children even when the fan-out as a whole returns an error.
24//!
25//! Example:
26//! ```rust
27//! use graph_flow::{Context, Task, TaskResult, NextAction};
28//! use graph_flow::fanout::FanOutTask;
29//! use async_trait::async_trait;
30//! use std::sync::Arc;
31//!
32//! struct ChildA;
33//! struct ChildB;
34//!
35//! #[async_trait]
36//! impl Task for ChildA {
37//! fn id(&self) -> &str { "child_a" }
38//! async fn run(&self, ctx: Context) -> graph_flow::Result<TaskResult> {
39//! ctx.set("a", 1_i32)?;
40//! Ok(TaskResult::new(Some("A done".to_string()), NextAction::End))
41//! }
42//! }
43//!
44//! #[async_trait]
45//! impl Task for ChildB {
46//! fn id(&self) -> &str { "child_b" }
47//! async fn run(&self, ctx: Context) -> graph_flow::Result<TaskResult> {
48//! ctx.set("b", 2_i32)?;
49//! Ok(TaskResult::new(Some("B done".to_string()), NextAction::End))
50//! }
51//! }
52//!
53//! # #[tokio::main]
54//! # async fn main() -> graph_flow::Result<()> {
55//! let fan = FanOutTask::new("fan", vec![Arc::new(ChildA), Arc::new(ChildB)])
56//! .with_prefix("fanout");
57//! let ctx = Context::new();
58//! let _ = fan.run(ctx.clone()).await?;
59//! // Aggregated entries under prefix:
60//! // fanout.child_a.response, fanout.child_b.response
61//! # Ok(())
62//! # }
63//! ```
64
65use async_trait::async_trait;
66use std::sync::Arc;
67use tokio::task::JoinSet;
68
69use crate::{Context, Result, Task, TaskResult, NextAction, GraphError};
70
71/// Composite task that executes multiple child tasks concurrently and aggregates results.
72#[derive(Clone)]
73pub struct FanOutTask {
74 id: String,
75 children: Vec<Arc<dyn Task>>, // executed in parallel
76 prefix: Option<String>, // context aggregation prefix
77 next_action: NextAction, // default: Continue
78}
79
80impl FanOutTask {
81 /// Create a new `FanOutTask` with an explicit id and a list of child tasks.
82 pub fn new(id: impl Into<String>, children: Vec<Arc<dyn Task>>) -> Arc<Self> {
83 Arc::new(Self {
84 id: id.into(),
85 children,
86 prefix: None,
87 next_action: NextAction::Continue,
88 })
89 }
90
91 /// Set a context prefix for storing aggregated child results.
92 ///
93 /// Aggregation keys will be written as `<prefix>.<child_id>.<field>`.
94 pub fn with_prefix(mut self: Arc<Self>, prefix: impl Into<String>) -> Arc<Self> {
95 Arc::make_mut(&mut self).prefix = Some(prefix.into());
96 self
97 }
98
99 /// Override the `NextAction` returned by the `FanOutTask` (default: `Continue`).
100 pub fn with_next_action(mut self: Arc<Self>, next: NextAction) -> Arc<Self> {
101 Arc::make_mut(&mut self).next_action = next;
102 self
103 }
104
105 fn key(&self, child_id: &str, field: &str) -> String {
106 if let Some(p) = &self.prefix {
107 format!("{}.{}.{}", p, child_id, field)
108 } else {
109 format!("fanout.{}.{}", child_id, field)
110 }
111 }
112}
113
114#[async_trait]
115impl Task for FanOutTask {
116 fn id(&self) -> &str { &self.id }
117
118 async fn run(&self, context: Context) -> Result<TaskResult> {
119 let mut set = JoinSet::new();
120
121 // Spawn children concurrently
122 for child in &self.children {
123 let child = child.clone();
124 let ctx = context.clone();
125 set.spawn(async move {
126 let cid = child.id().to_string();
127 let res = child.run(ctx.clone()).await;
128 (cid, res)
129 });
130 }
131
132 // Keep the *first* failure so the reported error is deterministic;
133 // remaining children still run to completion and their outputs are
134 // aggregated into the context even when an error is returned.
135 let mut first_error = None;
136 let mut completed = 0usize;
137
138 while let Some(joined) = set.join_next().await {
139 match joined {
140 Err(join_err) => {
141 first_error.get_or_insert_with(|| {
142 GraphError::TaskExecutionFailed(format!(
143 "FanOut child join error: {}",
144 join_err
145 ))
146 });
147 }
148 Ok((child_id, outcome)) => match outcome {
149 Err(e) => {
150 first_error.get_or_insert_with(|| {
151 GraphError::TaskExecutionFailed(format!(
152 "FanOut child '{}' failed: {}",
153 child_id, e
154 ))
155 });
156 }
157 Ok(tr) => {
158 // Store child outputs under prefixed keys
159 let TaskResult {
160 response,
161 status_message,
162 next_action,
163 ..
164 } = tr;
165 if let Some(resp) = response {
166 context.set(self.key(&child_id, "response"), resp)?;
167 }
168 if let Some(status) = status_message {
169 context.set(self.key(&child_id, "status"), status)?;
170 }
171 // Always store the reported next_action for diagnostics
172 context
173 .set(self.key(&child_id, "next_action"), format!("{:?}", next_action))?;
174 completed += 1;
175 }
176 },
177 }
178 }
179
180 if let Some(err) = first_error {
181 return Err(err);
182 }
183
184 let summary = format!(
185 "FanOutTask '{}' completed {} child task(s)",
186 self.id, completed
187 );
188
189 Ok(TaskResult::new_with_status(
190 Some(summary.clone()),
191 self.next_action.clone(),
192 Some(summary),
193 ))
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use async_trait::async_trait;
201 use tokio::time::{sleep, Duration};
202
203 struct OkTask { name: &'static str }
204 struct FailingTask { name: &'static str }
205
206 #[async_trait]
207 impl Task for OkTask {
208 fn id(&self) -> &str { self.name }
209 async fn run(&self, ctx: Context) -> Result<TaskResult> {
210 ctx.set(format!("out.{}", self.name), true)?;
211 sleep(Duration::from_millis(10)).await;
212 Ok(TaskResult::new(Some(format!("{} ok", self.name)), NextAction::End))
213 }
214 }
215
216 #[async_trait]
217 impl Task for FailingTask {
218 fn id(&self) -> &str { self.name }
219 async fn run(&self, _ctx: Context) -> Result<TaskResult> {
220 Err(GraphError::TaskExecutionFailed(format!("{} failed", self.name)))
221 }
222 }
223
224 #[tokio::test]
225 async fn fanout_all_success_aggregates() {
226 let a: Arc<dyn Task> = Arc::new(OkTask { name: "a" });
227 let b: Arc<dyn Task> = Arc::new(OkTask { name: "b" });
228 let fan = FanOutTask::new("fan", vec![a, b]).with_prefix("agg");
229
230 let ctx = Context::new();
231 let res = fan.run(ctx.clone()).await.unwrap();
232
233 assert_eq!(res.next_action, NextAction::Continue);
234
235 let ar: Option<String> = ctx.get("agg.a.response");
236 let br: Option<String> = ctx.get("agg.b.response");
237 assert_eq!(ar, Some("a ok".to_string()));
238 assert_eq!(br, Some("b ok".to_string()));
239
240 // also store next_action diagnostic
241 let an: Option<String> = ctx.get("agg.a.next_action");
242 assert_eq!(an, Some(format!("{:?}", NextAction::End)));
243 }
244
245 #[tokio::test]
246 async fn fanout_failure_bubbles_up() {
247 let a: Arc<dyn Task> = Arc::new(OkTask { name: "a" });
248 let f: Arc<dyn Task> = Arc::new(FailingTask { name: "bad" });
249 let fan = FanOutTask::new("fan", vec![a, f]);
250
251 let ctx = Context::new();
252 let err = fan.run(ctx.clone()).await.err().unwrap();
253 match err {
254 GraphError::TaskExecutionFailed(msg) => assert!(msg.contains("bad")),
255 other => panic!("Unexpected error variant: {other:?}"),
256 }
257 }
258}