Skip to main content

lc_core/runnables/
parallel.rs

1// lc-core/src/runnables/parallel.rs
2//! RunnableParallel - fan-out/fan-in composition.
3//!
4//! `RunnableParallel` runs multiple `Runnable` steps concurrently on
5//! the same input, collecting results into a `HashMap<String, Value>`.
6//! This is the LCEL equivalent of Python's `RunnableParallel` / `RunnableMap`.
7
8use super::assign::RunnableAssign;
9use super::config::RunnableConfig;
10use super::error::LcelError;
11use super::runnable_trait::Runnable;
12use async_trait::async_trait;
13use futures_util::future::join_all;
14use futures_util::Stream;
15use serde_json::Value;
16use std::collections::HashMap;
17use std::pin::Pin;
18use std::sync::Arc;
19use tokio::sync::Semaphore;
20
21/// A `Runnable` that runs multiple steps in parallel on the same input.
22///
23/// Each step is identified by a string key. The output is a
24/// `HashMap<String, Value>` where each key maps to the corresponding
25/// step's output (serialized as `serde_json::Value`).
26///
27/// # Example
28///
29/// ```rust,ignore
30/// let parallel = RunnableParallel::<String>::new()
31///     .with("length", RunnableLambda::new_sync(|s: String| s.len() as i64))
32///     .with("upper", RunnableLambda::new_sync(|s: String| s.to_uppercase()));
33///
34/// let result = parallel.invoke("hello".to_string(), None).await?;
35/// // result = {"length": 5, "upper": "HELLO"}
36/// ```
37pub struct RunnableParallel<I: Send + Sync + 'static> {
38    steps: Vec<(String, Arc<dyn ParallelStep<I>>)>,
39}
40
41impl<I: Send + Sync + 'static> std::fmt::Debug for RunnableParallel<I> {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        let keys: Vec<&str> = self.steps.iter().map(|(k, _)| k.as_str()).collect();
44        f.debug_struct("RunnableParallel")
45            .field("steps", &keys)
46            .field("input", &std::any::type_name::<I>())
47            .finish()
48    }
49}
50
51impl<I: Clone + Send + Sync + 'static> Default for RunnableParallel<I> {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl<I: Clone + Send + Sync + 'static> RunnableParallel<I> {
58    /// Create an empty parallel runnable.
59    pub fn new() -> Self {
60        Self { steps: Vec::new() }
61    }
62
63    /// Add a step with the given key.
64    ///
65    /// The step's output will be serialized to `serde_json::Value`
66    /// and stored under the key in the output HashMap.
67    pub fn with<O, R>(mut self, key: &str, runnable: R) -> Self
68    where
69        O: serde::Serialize + Send + Sync + 'static,
70        R: Runnable<I, O> + Send + Sync + 'static,
71        R::Error: Into<LcelError>,
72    {
73        self.steps.push((
74            key.to_string(),
75            Arc::new(ParallelStepImpl {
76                inner: runnable,
77                serialize: |output: &O| serde_json::to_value(output),
78                _marker: std::marker::PhantomData,
79            }),
80        ));
81        self
82    }
83
84    /// Number of parallel steps.
85    pub fn len(&self) -> usize {
86        self.steps.len()
87    }
88
89    /// Whether there are no steps.
90    pub fn is_empty(&self) -> bool {
91        self.steps.is_empty()
92    }
93
94    /// Add an assign step that injects a new key into the output HashMap.
95    ///
96    /// This is the LCEL equivalent of Python's `RunnableParallel.assign()`.
97    /// It pipes the parallel output (a `HashMap<String, Value>`) through
98    /// a `RunnableAssign` that runs the given runnable on the HashMap
99    /// and merges the result under the specified key.
100    ///
101    /// # Example
102    ///
103    /// ```rust,ignore
104    /// let chain = RunnableParallel::<String>::new()
105    ///     .with("context", retriever.pipe(format_docs))
106    ///     .assign("question", RunnableLambda::new_sync(|m: HashMap<String, Value>| {
107    ///         m.get("context").map(|c| c.to_string()).unwrap_or_default()
108    ///     }))
109    ///     .pipe(prompt_template)
110    ///     .pipe(llm);
111    /// ```
112    ///
113    /// # How it works
114    ///
115    /// `assign()` returns `self.pipe(RunnableAssign)`. The RunnableAssign
116    /// receives the HashMap output from the parallel step, runs the
117    /// provided runnable on it, and merges the result back.
118    pub fn assign<O, R>(self, key: &str, runnable: R) -> RunnableSequence<I, HashMap<String, Value>>
119    where
120        I: 'static,
121        O: serde::Serialize + Send + Sync + 'static,
122        R: Runnable<HashMap<String, Value>, O> + Send + Sync + 'static,
123        R::Error: Into<LcelError>,
124    {
125        use super::ext::RunnableExt;
126
127        let assign = RunnableAssign::new().with(key, runnable);
128        self.pipe(assign)
129    }
130}
131
132use super::sequence::RunnableSequence;
133
134/// Trait for a single parallel step that produces a `serde_json::Value`.
135#[async_trait]
136trait ParallelStep<I: Send + Sync + 'static>: Send + Sync {
137    async fn invoke(&self, input: I, config: Option<RunnableConfig>) -> Result<Value, LcelError>;
138}
139
140/// Concrete implementation of `ParallelStep` for any `Runnable<I, O>`.
141struct ParallelStepImpl<I, O, R>
142where
143    I: Send + Sync + 'static,
144    O: serde::Serialize + Send + Sync + 'static,
145    R: Runnable<I, O>,
146{
147    inner: R,
148    serialize: fn(&O) -> Result<Value, serde_json::Error>,
149    _marker: std::marker::PhantomData<I>,
150}
151
152#[async_trait]
153impl<I, O, R> ParallelStep<I> for ParallelStepImpl<I, O, R>
154where
155    I: Clone + Send + Sync + 'static,
156    O: serde::Serialize + Send + Sync + 'static,
157    R: Runnable<I, O>,
158    R::Error: Into<LcelError>,
159{
160    async fn invoke(&self, input: I, config: Option<RunnableConfig>) -> Result<Value, LcelError> {
161        let result = self.inner.invoke(input, config).await.map_err(Into::into)?;
162        (self.serialize)(&result)
163            .map_err(|e| LcelError::Other(format!("parallel serialization: {}", e)))
164    }
165}
166
167#[async_trait]
168impl<I: Clone + Send + Sync + 'static> Runnable<I, HashMap<String, Value>> for RunnableParallel<I> {
169    type Error = LcelError;
170
171    /// Execute all steps in parallel using tokio tasks.
172    ///
173    /// Concurrency is bounded by `config.max_concurrency` (Semaphore), and all
174    /// tasks are awaited via `join_all` so an early error cannot orphan the
175    /// remaining in-flight tasks (a dropped `JoinHandle` only detaches — it
176    /// does not cancel). (A2)
177    async fn invoke(
178        &self,
179        input: I,
180        config: Option<RunnableConfig>,
181    ) -> Result<HashMap<String, Value>, LcelError> {
182        let limit = config
183            .as_ref()
184            .and_then(|c| c.max_concurrency)
185            .unwrap_or(self.steps.len())
186            .max(1);
187        let semaphore = Arc::new(Semaphore::new(limit));
188
189        let mut handles = Vec::with_capacity(self.steps.len());
190
191        for (key, step) in &self.steps {
192            let key = key.clone();
193            let step = step.clone();
194            let input = input.clone();
195            let config = config.clone();
196            let sem = semaphore.clone();
197
198            let handle = tokio::spawn(async move {
199                // Tasks beyond the limit park on the permit, so max_concurrency
200                // is a true cap on in-flight step execution.
201                let _permit = sem
202                    .acquire()
203                    .await
204                    .map_err(|e| LcelError::Other(format!("parallel semaphore: {e}")))?;
205                let value = step.invoke(input, config).await?;
206                Ok::<(String, Value), LcelError>((key, value))
207            });
208
209            handles.push(handle);
210        }
211
212        let joined = join_all(handles).await;
213        let mut results = HashMap::new();
214        for res in joined {
215            // Outer = JoinError (task panicked/cancelled), inner = LcelError.
216            let inner =
217                res.map_err(|e| LcelError::Other(format!("parallel task join error: {e}")))?;
218            let (k, v) = inner?;
219            results.insert(k, v);
220        }
221
222        Ok(results)
223    }
224
225    /// Batch: each step processes all inputs independently.
226    async fn batch(
227        &self,
228        inputs: Vec<I>,
229        config: Option<RunnableConfig>,
230    ) -> Result<Vec<HashMap<String, Value>>, LcelError> {
231        let mut results = Vec::with_capacity(inputs.len());
232        for input in inputs {
233            results.push(self.invoke(input, config.clone()).await?);
234        }
235        Ok(results)
236    }
237
238    /// Stream: invoke and return single-element stream.
239    async fn stream(
240        &self,
241        input: I,
242        config: Option<RunnableConfig>,
243    ) -> Result<
244        Pin<Box<dyn Stream<Item = Result<HashMap<String, Value>, LcelError>> + Send>>,
245        LcelError,
246    > {
247        let result = self.invoke(input, config).await?;
248        Ok(Box::pin(futures_util::stream::once(
249            async move { Ok(result) },
250        )))
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::RunnableLambda;
258
259    #[tokio::test]
260    async fn parallel_invoke() {
261        let parallel = RunnableParallel::<String>::new()
262            .with("len", RunnableLambda::new_sync(|s: String| s.len() as i64))
263            .with(
264                "upper",
265                RunnableLambda::new_sync(|s: String| s.to_uppercase()),
266            );
267
268        let result = parallel.invoke("hello".to_string(), None).await.unwrap();
269        assert_eq!(
270            result.get("len").unwrap(),
271            &Value::Number(serde_json::Number::from(5))
272        );
273        assert_eq!(
274            result.get("upper").unwrap(),
275            &Value::String("HELLO".to_string())
276        );
277    }
278
279    #[tokio::test]
280    async fn parallel_empty() {
281        let parallel = RunnableParallel::<i32>::new();
282        let result = parallel.invoke(42, None).await.unwrap();
283        assert!(result.is_empty());
284    }
285
286    #[tokio::test]
287    async fn parallel_batch() {
288        let parallel = RunnableParallel::<String>::new()
289            .with("len", RunnableLambda::new_sync(|s: String| s.len() as i64));
290
291        let results = parallel
292            .batch(vec!["hi".to_string(), "hello".to_string()], None)
293            .await
294            .unwrap();
295        assert_eq!(results.len(), 2);
296        assert_eq!(
297            results[0].get("len").unwrap(),
298            &Value::Number(serde_json::Number::from(2))
299        );
300        assert_eq!(
301            results[1].get("len").unwrap(),
302            &Value::Number(serde_json::Number::from(5))
303        );
304    }
305
306    #[tokio::test]
307    async fn parallel_assign_adds_field() {
308        let chain = RunnableParallel::<String>::new()
309            .with("len", RunnableLambda::new_sync(|s: String| s.len() as i64))
310            .assign(
311                "upper",
312                RunnableLambda::new_sync(|m: HashMap<String, Value>| {
313                    // Use the "len" field from the parallel output
314                    m.get("len")
315                        .and_then(|v| v.as_i64())
316                        .map(|n| format!("length={}", n))
317                        .unwrap_or_default()
318                }),
319            );
320
321        let result = chain.invoke("hello".to_string(), None).await.unwrap();
322        // Original parallel step result
323        assert_eq!(
324            result.get("len").unwrap(),
325            &Value::Number(serde_json::Number::from(5))
326        );
327        // Assign step result — can reference previous parallel output
328        assert_eq!(
329            result.get("upper").unwrap(),
330            &Value::String("length=5".to_string())
331        );
332    }
333
334    #[tokio::test]
335    async fn parallel_respects_max_concurrency() {
336        use std::sync::atomic::{AtomicUsize, Ordering};
337        use std::time::Duration;
338
339        let in_flight = Arc::new(AtomicUsize::new(0));
340        let peak = Arc::new(AtomicUsize::new(0));
341
342        let mk = |in_flight: Arc<AtomicUsize>, peak: Arc<AtomicUsize>| {
343            RunnableLambda::new_async(move |_: String| {
344                let a = in_flight.clone();
345                let b = peak.clone();
346                async move {
347                    let cur = a.fetch_add(1, Ordering::SeqCst) + 1;
348                    b.fetch_max(cur, Ordering::SeqCst);
349                    tokio::time::sleep(Duration::from_millis(20)).await;
350                    a.fetch_sub(1, Ordering::SeqCst);
351                    Ok::<i32, LcelError>(1)
352                }
353            })
354        };
355
356        let parallel = RunnableParallel::<String>::new()
357            .with("a", mk(in_flight.clone(), peak.clone()))
358            .with("b", mk(in_flight.clone(), peak.clone()))
359            .with("c", mk(in_flight.clone(), peak.clone()))
360            .with("d", mk(in_flight.clone(), peak.clone()));
361
362        let config = RunnableConfig::new().with_max_concurrency(2);
363        let result = parallel
364            .invoke("x".to_string(), Some(config))
365            .await
366            .unwrap();
367        assert_eq!(result.len(), 4);
368
369        // With a cap of 2, we should never see more than 2 steps in flight.
370        assert!(
371            peak.load(Ordering::SeqCst) <= 2,
372            "peak concurrency {} exceeded cap 2",
373            peak.load(Ordering::SeqCst)
374        );
375    }
376
377    /// A2: when one step fails, `invoke` must still await the other spawned
378    /// tasks before returning the error. A dropped `JoinHandle` only detaches a
379    /// task (it does not cancel it), so the previous early-return-on-first-error
380    /// implementation orphaned in-flight work: the call returned before the
381    /// surviving steps' side effects happened.
382    #[tokio::test]
383    async fn parallel_failure_waits_for_other_steps_instead_of_orphaning() {
384        use std::sync::atomic::{AtomicUsize, Ordering};
385        use std::time::{Duration, Instant};
386
387        let completed = Arc::new(AtomicUsize::new(0));
388
389        let slow = |completed: Arc<AtomicUsize>| {
390            RunnableLambda::new_async(move |_: String| {
391                let done = completed.clone();
392                async move {
393                    tokio::time::sleep(Duration::from_millis(60)).await;
394                    done.fetch_add(1, Ordering::SeqCst);
395                    Ok::<i32, LcelError>(1)
396                }
397            })
398        };
399
400        let failing = RunnableLambda::new_async(|_: String| async move {
401            // Fails immediately — much faster than the three sleeping steps.
402            Err::<i32, LcelError>(LcelError::Other("deliberate step failure".to_string()))
403        });
404
405        let parallel = RunnableParallel::<String>::new()
406            .with("a", slow(completed.clone()))
407            .with("boom", failing)
408            .with("c", slow(completed.clone()))
409            .with("d", slow(completed.clone()));
410
411        let start = Instant::now();
412        let err = parallel.invoke("x".to_string(), None).await.unwrap_err();
413        let elapsed = start.elapsed();
414
415        assert!(
416            err.to_string().contains("deliberate step failure"),
417            "expected the step error, got: {err}"
418        );
419        // When the error surfaces, all three surviving steps have run to
420        // completion — join_all folded the full task set.
421        assert_eq!(
422            completed.load(Ordering::SeqCst),
423            3,
424            "surviving steps must finish before invoke returns the error"
425        );
426        // The old detach-on-first-error implementation returned in ~0ms while
427        // the surviving tasks were still sleeping.
428        assert!(
429            elapsed >= Duration::from_millis(45),
430            "invoke returned after {elapsed:?} — orphaned steps were not awaited"
431        );
432    }
433}