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).await;
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).await;
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).await;
167 }
168 if let Some(status) = status_message {
169 context.set(self.key(&child_id, "status"), status).await;
170 }
171 // Always store the reported next_action for diagnostics
172 context
173 .set(self.key(&child_id, "next_action"), format!("{:?}", next_action))
174 .await;
175 completed += 1;
176 }
177 },
178 }
179 }
180
181 if let Some(err) = first_error {
182 return Err(err);
183 }
184
185 let summary = format!(
186 "FanOutTask '{}' completed {} child task(s)",
187 self.id, completed
188 );
189
190 Ok(TaskResult::new_with_status(
191 Some(summary.clone()),
192 self.next_action.clone(),
193 Some(summary),
194 ))
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use async_trait::async_trait;
202 use tokio::time::{sleep, Duration};
203
204 struct OkTask { name: &'static str }
205 struct FailingTask { name: &'static str }
206
207 #[async_trait]
208 impl Task for OkTask {
209 fn id(&self) -> &str { self.name }
210 async fn run(&self, ctx: Context) -> Result<TaskResult> {
211 ctx.set(format!("out.{}", self.name), true).await;
212 sleep(Duration::from_millis(10)).await;
213 Ok(TaskResult::new(Some(format!("{} ok", self.name)), NextAction::End))
214 }
215 }
216
217 #[async_trait]
218 impl Task for FailingTask {
219 fn id(&self) -> &str { self.name }
220 async fn run(&self, _ctx: Context) -> Result<TaskResult> {
221 Err(GraphError::TaskExecutionFailed(format!("{} failed", self.name)))
222 }
223 }
224
225 #[tokio::test]
226 async fn fanout_all_success_aggregates() {
227 let a: Arc<dyn Task> = Arc::new(OkTask { name: "a" });
228 let b: Arc<dyn Task> = Arc::new(OkTask { name: "b" });
229 let fan = FanOutTask::new("fan", vec![a, b]).with_prefix("agg");
230
231 let ctx = Context::new();
232 let res = fan.run(ctx.clone()).await.unwrap();
233
234 assert_eq!(res.next_action, NextAction::Continue);
235
236 let ar: Option<String> = ctx.get("agg.a.response").await;
237 let br: Option<String> = ctx.get("agg.b.response").await;
238 assert_eq!(ar, Some("a ok".to_string()));
239 assert_eq!(br, Some("b ok".to_string()));
240
241 // also store next_action diagnostic
242 let an: Option<String> = ctx.get("agg.a.next_action").await;
243 assert_eq!(an, Some(format!("{:?}", NextAction::End)));
244 }
245
246 #[tokio::test]
247 async fn fanout_failure_bubbles_up() {
248 let a: Arc<dyn Task> = Arc::new(OkTask { name: "a" });
249 let f: Arc<dyn Task> = Arc::new(FailingTask { name: "bad" });
250 let fan = FanOutTask::new("fan", vec![a, f]);
251
252 let ctx = Context::new();
253 let err = fan.run(ctx.clone()).await.err().unwrap();
254 match err {
255 GraphError::TaskExecutionFailed(msg) => assert!(msg.contains("bad")),
256 other => panic!("Unexpected error variant: {other:?}"),
257 }
258 }
259}