Skip to main content

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}