Skip to main content

swiftide_tasks/
transition.rs

1//! Transition values describe how task execution should continue after a node finishes.
2//!
3//! This module contains the public building blocks for linear transitions, fan-out, join
4//! configuration, and join payload inspection.
5//!
6//! # Examples
7//!
8//! ```no_run
9//! use swiftide_tasks::{JoinInput, NodeError, Task, Transition};
10//!
11//! let mut task = Task::<i32, i32>::new();
12//! let start = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input) });
13//! let left = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input + 1) });
14//! let right = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input + 2) });
15//! let join = task.register_node_fn(|input: &JoinInput<i32>| -> Result<i32, NodeError> {
16//!     Ok(input.iter().copied().sum())
17//! });
18//!
19//! task.starts_with(start);
20//! task.register_transition(start, move |value| {
21//!     // `join_with` defines the branch group and the join node that waits for it.
22//!     Transition::fan_out(&left, value)
23//!         .and(&right, value)
24//!         .join_with(join.join())
25//! })?;
26//! // Branches still decide where their own output goes before the join can run. Fan-out branches
27//! // cannot finish independently; use a join node as the single place that finishes the task.
28//! task.register_transition(left, join.join())?;
29//! task.register_transition(right, join.join())?;
30//! # Ok::<(), Box<dyn std::error::Error>>(())
31//! ```
32use std::{any::Any, marker::PhantomData, sync::Arc};
33
34use super::{
35    errors::TaskError,
36    node::NodeId,
37    traits::{NodeArg, TaskNode},
38};
39
40/// Identifies a concrete runtime branch within a task run.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub(crate) struct BranchId(pub(crate) usize);
43
44/// Controls whether newly scheduled work should run one branch at a time or concurrently.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
46pub enum ConcurrencyModel {
47    /// Run one branch to completion before starting the next runnable branch.
48    #[default]
49    Sequential,
50    /// Allow multiple runnable branches to execute at the same time.
51    Parallel,
52}
53
54#[derive(Debug, Clone)]
55pub(crate) struct NextNode {
56    pub(crate) node_id: usize,
57    pub(crate) context: Arc<dyn Any + Send + Sync>,
58}
59
60impl NextNode {
61    pub(crate) fn new<T: TaskNode + ?Sized>(node_id: NodeId<T>, context: T::Input) -> Self
62    where
63        <T as TaskNode>::Input: 'static,
64    {
65        Self {
66            node_id: node_id.id(),
67            context: Arc::new(context) as Arc<dyn Any + Send + Sync>,
68        }
69    }
70}
71
72type ErasedPayload = Arc<dyn Any + Send + Sync>;
73
74#[derive(Clone, Copy)]
75enum JoinInputFactory {
76    Fallible(fn(Vec<ErasedPayload>) -> Result<ErasedPayload, TaskError>),
77    Infallible(fn(Vec<ErasedPayload>) -> ErasedPayload),
78}
79
80impl JoinInputFactory {
81    fn build(self, payloads: Vec<ErasedPayload>) -> Result<ErasedPayload, TaskError> {
82        match self {
83            JoinInputFactory::Fallible(factory) => factory(payloads),
84            JoinInputFactory::Infallible(factory) => Ok(factory(payloads)),
85        }
86    }
87}
88
89#[doc(hidden)]
90#[derive(Clone, Copy)]
91pub struct JoinDefinition {
92    pub(crate) join_node_id: usize,
93    input_factory: JoinInputFactory,
94}
95
96impl std::fmt::Debug for JoinDefinition {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.debug_struct("JoinDefinition")
99            .field("join_node_id", &self.join_node_id)
100            .finish_non_exhaustive()
101    }
102}
103
104impl JoinDefinition {
105    fn typed<Payload: NodeArg>(join_node_id: usize) -> Self {
106        Self {
107            join_node_id,
108            input_factory: JoinInputFactory::Fallible(typed_join_input::<Payload>),
109        }
110    }
111
112    fn any(join_node_id: usize) -> Self {
113        Self {
114            join_node_id,
115            input_factory: JoinInputFactory::Infallible(any_join_input),
116        }
117    }
118
119    pub(crate) fn into_input(
120        self,
121        payloads: Vec<ErasedPayload>,
122    ) -> Result<ErasedPayload, TaskError> {
123        self.input_factory.build(payloads)
124    }
125}
126
127fn typed_join_input<Payload: NodeArg>(
128    payloads: Vec<ErasedPayload>,
129) -> Result<ErasedPayload, TaskError> {
130    let branches = payloads
131        .into_iter()
132        .map(|payload| {
133            payload.downcast::<Payload>().map_err(|_| {
134                TaskError::invalid_state(format!(
135                    "Join payload expected type {}",
136                    std::any::type_name::<Payload>()
137                ))
138            })
139        })
140        .collect::<Result<Vec<_>, _>>()?;
141
142    Ok(Arc::new(JoinInput::<Payload>::new(branches)) as ErasedPayload)
143}
144
145fn any_join_input(payloads: Vec<ErasedPayload>) -> ErasedPayload {
146    Arc::new(AnyJoinInput::new(payloads)) as ErasedPayload
147}
148
149/// A configured join destination for branches that should converge into a join node.
150///
151/// Build a `JoinTarget` from a join node with [`NodeId::join`](crate::NodeId::join),
152/// and then register it through
153/// [`Task::register_transition`](crate::Task::register_transition).
154/// Users normally do not construct this type directly.
155#[must_use]
156pub struct JoinTarget<T: TaskNode<Input = JoinInput<Payload>> + ?Sized, Payload: NodeArg> {
157    pub(crate) definition: JoinDefinition,
158    _marker: PhantomData<(*const T, Payload)>,
159}
160
161/// A join target with a synchronous payload mapping step.
162///
163/// This is returned by [`JoinTarget::map`] and registered with
164/// [`Task::register_transition`](crate::Task::register_transition).
165#[must_use]
166pub struct MappedJoinTarget<T: TaskNode<Input = JoinInput<Payload>> + ?Sized, Payload: NodeArg, F> {
167    pub(crate) join_target: JoinTarget<T, Payload>,
168    pub(crate) map: F,
169}
170
171/// A configured join destination for branches with mixed payload types.
172///
173/// Build an `AnyJoinTarget` from an [`AnyJoinInput`] node with
174/// [`NodeId::join_any`](crate::NodeId::join_any).
175#[must_use]
176pub struct AnyJoinTarget<T: TaskNode<Input = AnyJoinInput> + ?Sized> {
177    pub(crate) definition: JoinDefinition,
178    _marker: PhantomData<T>,
179}
180
181/// A value accepted by [`FanOutTransition::join_with`].
182#[doc(hidden)]
183pub trait JoinDestination {
184    #[doc(hidden)]
185    fn into_definition(self) -> JoinDefinition;
186}
187
188impl<T, Payload> JoinDestination for JoinTarget<T, Payload>
189where
190    T: TaskNode<Input = JoinInput<Payload>> + ?Sized,
191    Payload: NodeArg,
192{
193    fn into_definition(self) -> JoinDefinition {
194        self.definition
195    }
196}
197
198impl<T> JoinDestination for AnyJoinTarget<T>
199where
200    T: TaskNode<Input = AnyJoinInput> + ?Sized,
201{
202    fn into_definition(self) -> JoinDefinition {
203        self.definition
204    }
205}
206
207impl<T, Payload> JoinTarget<T, Payload>
208where
209    T: TaskNode<Input = JoinInput<Payload>> + ?Sized,
210    Payload: NodeArg,
211{
212    pub(crate) fn new(node_id: NodeId<T>) -> Self {
213        Self {
214            definition: JoinDefinition::typed::<Payload>(node_id.id()),
215            _marker: PhantomData,
216        }
217    }
218
219    /// Maps each joining branch output into the payload stored for the join node.
220    ///
221    /// # Examples
222    ///
223    /// ```no_run
224    /// use swiftide_tasks::{JoinInput, NodeError, Task, Transition};
225    ///
226    /// let mut task = Task::<i32, i32>::new();
227    /// let start = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input) });
228    /// let branch = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input + 1) });
229    /// let join = task.register_node_fn(|input: &JoinInput<i32>| -> Result<i32, NodeError> {
230    ///     Ok(input.iter().copied().sum())
231    /// });
232    ///
233    /// task.starts_with(start);
234    /// task.register_transition(start, move |value| {
235    ///     Transition::fan_out(&branch, value)
236    ///         .join_with(join.join())
237    /// })?;
238    /// task.register_transition(branch, join.join().map(|value| value * 2))?;
239    /// # Ok::<(), Box<dyn std::error::Error>>(())
240    /// ```
241    pub fn map<F>(self, map: F) -> MappedJoinTarget<T, Payload, F>
242    where
243        F: Send + Sync + 'static,
244    {
245        MappedJoinTarget {
246            join_target: self,
247            map,
248        }
249    }
250
251    /// Maps each joining branch output asynchronously before storing it for the join node.
252    ///
253    /// # Examples
254    ///
255    /// ```no_run
256    /// use swiftide_tasks::{JoinInput, NodeError, Task, Transition};
257    ///
258    /// let mut task = Task::<i32, i32>::new();
259    /// let start = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input) });
260    /// let branch = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input + 1) });
261    /// let join = task.register_node_fn(|input: &JoinInput<i32>| -> Result<i32, NodeError> {
262    ///     Ok(input.iter().copied().sum())
263    /// });
264    ///
265    /// task.starts_with(start);
266    /// task.register_transition(start, move |value| {
267    ///     Transition::fan_out(&branch, value)
268    ///         .join_with(join.join())
269    /// })?;
270    /// task.register_transition_async(branch, join.join().map_async(|value| async move { value * 2 }))?;
271    /// # Ok::<(), Box<dyn std::error::Error>>(())
272    /// ```
273    pub fn map_async<F>(self, map: F) -> MappedJoinTarget<T, Payload, F>
274    where
275        F: Send + Sync + 'static,
276    {
277        MappedJoinTarget {
278            join_target: self,
279            map,
280        }
281    }
282}
283
284impl<T: TaskNode<Input = AnyJoinInput> + ?Sized> AnyJoinTarget<T> {
285    pub(crate) fn new(node_id: NodeId<T>) -> Self {
286        Self {
287            definition: JoinDefinition::any(node_id.id()),
288            _marker: PhantomData,
289        }
290    }
291}
292
293/// Describes how task execution should continue after a node completes.
294///
295/// Most transitions are created either through [`NodeId::transitions_with`] for the linear case or
296/// [`Transition::fan_out`] plus [`FanOutTransition::join_with`] for branching. Use
297/// [`Transition::pause`] and [`Transition::error`] when a transition closure needs to control task
298/// execution directly.
299///
300/// # Examples
301///
302/// ```no_run
303/// use swiftide_tasks::{NodeError, Task, TaskRunOutcome, Transition};
304///
305/// # #[tokio::main(flavor = "current_thread")]
306/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
307/// let mut task = Task::<i32, i32>::new();
308///
309/// let start = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input) });
310/// let left = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input + 1) });
311/// let right = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input + 2) });
312/// let join = task.register_node_fn(
313///     |input: &swiftide_tasks::JoinInput<i32>| -> Result<i32, NodeError> {
314///         Ok(input.iter().copied().sum())
315///     },
316/// );
317///
318/// task.starts_with(start);
319/// task.register_transition(start, move |value| {
320///     Transition::fan_out(&left, value)
321///         .and(&right, value)
322///         .join_with(join.join())
323///         .concurrency_model(swiftide_tasks::ConcurrencyModel::Parallel)
324/// })?;
325/// task.register_transition(left, join.join())?;
326/// task.register_transition(right, join.join())?;
327/// task.register_transition(join, task.transitions_to_finish())?;
328///
329/// assert_eq!(task.run(1).await?, TaskRunOutcome::Completed(5));
330/// # Ok(())
331/// # }
332/// ```
333#[derive(Debug)]
334#[must_use]
335pub struct Transition {
336    pub(crate) action: TransitionAction,
337    pub(crate) concurrency_model: Option<ConcurrencyModel>,
338}
339
340/// A linear transition that preserves the destination node type.
341///
342/// This is returned by [`Transition::next`] and [`NodeId::transitions_with`], then accepted by
343/// [`Task::register_transition`](crate::Task::register_transition) closures.
344#[must_use]
345pub struct MarkedTransition<To: TaskNode + ?Sized>(Transition, PhantomData<To>);
346
347impl<To: TaskNode + ?Sized> MarkedTransition<To> {
348    pub(crate) fn new(transition: Transition) -> Self {
349        Self(transition, PhantomData)
350    }
351
352    pub(crate) fn into_inner(self) -> Transition {
353        self.0
354    }
355
356    /// Overrides the concurrency model for work scheduled by this transition.
357    pub fn concurrency_model(self, concurrency_model: ConcurrencyModel) -> Self {
358        Self::new(self.0.concurrency_model(concurrency_model))
359    }
360}
361
362impl<To: TaskNode + ?Sized> From<MarkedTransition<To>> for Transition {
363    fn from(marked_transition: MarkedTransition<To>) -> Self {
364        marked_transition.into_inner()
365    }
366}
367
368/// A typed fan-out transition builder that must be connected to a join before it can run.
369///
370/// Build one with [`Transition::fan_out`] and add additional branch targets with
371/// [`FanOutTransition::and`]. Each branch input is type-checked before the branch target is erased
372/// for runtime scheduling.
373///
374/// Fan-out always represents a joined branch group. True unjoined fan-out with multiple
375/// independent task endings is not supported; model that shape by sending each branch to a join
376/// node and finishing from the join node.
377#[derive(Debug)]
378#[must_use]
379pub struct FanOutTransition {
380    targets: Vec<NextNode>,
381    concurrency_model: Option<ConcurrencyModel>,
382}
383
384impl FanOutTransition {
385    /// Adds another typed branch target to this fan-out.
386    ///
387    /// The branch input must convert into the target node's input type.
388    pub fn and<T, Input>(mut self, node_id: &NodeId<T>, context: Input) -> Self
389    where
390        T: TaskNode + ?Sized,
391        Input: Into<T::Input>,
392    {
393        self.targets.push(NextNode::new(*node_id, context.into()));
394        self
395    }
396
397    /// Attaches every branch from this fan-out to the provided join target.
398    ///
399    /// This defines the branch group and the join node that waits for every branch in that group.
400    /// Each branch still needs its own registered transition to the same join target when it is
401    /// ready to contribute its output. If branches are conceptually terminal, register each branch
402    /// to transition to this join target and finish from the join node.
403    pub fn join_with<J>(self, join_target: J) -> Transition
404    where
405        J: JoinDestination,
406    {
407        Transition {
408            action: TransitionAction::FanOut {
409                targets: self.targets,
410                join: join_target.into_definition(),
411            },
412            concurrency_model: self.concurrency_model,
413        }
414    }
415
416    /// Overrides the concurrency model for work scheduled by this fan-out.
417    pub fn concurrency_model(mut self, concurrency_model: ConcurrencyModel) -> Self {
418        self.concurrency_model = Some(concurrency_model);
419        self
420    }
421}
422
423#[derive(Debug)]
424pub(crate) enum TransitionAction {
425    Next(NextNode),
426    FanOut {
427        targets: Vec<NextNode>,
428        join: JoinDefinition,
429    },
430    Pause,
431    Error(Box<dyn std::error::Error + Send + Sync>),
432    Finish(Arc<dyn Any + Send + Sync>),
433}
434
435impl Transition {
436    pub(crate) fn from_next_node(next_node: NextNode) -> Self {
437        Self {
438            action: TransitionAction::Next(next_node),
439            concurrency_model: None,
440        }
441    }
442
443    /// Continues execution at `node_id` with the provided input.
444    ///
445    /// # Examples
446    ///
447    /// ```no_run
448    /// use swiftide_tasks::{NodeError, Task, Transition};
449    ///
450    /// let mut task = Task::<i32, i32>::new();
451    /// let start = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input + 1) });
452    /// let finish = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input * 2) });
453    ///
454    /// task.starts_with(start);
455    /// task.register_transition(start, move |value| Transition::next(&finish, value))?;
456    /// # Ok::<(), Box<dyn std::error::Error>>(())
457    /// ```
458    pub fn next<T: TaskNode + ?Sized>(
459        node_id: &NodeId<T>,
460        context: T::Input,
461    ) -> MarkedTransition<T> {
462        MarkedTransition::new(Self::from_next_node(NextNode::new(*node_id, context)))
463    }
464
465    /// Builds a fan-out that schedules one or more branches from the current node output.
466    ///
467    /// The returned [`FanOutTransition`] must attach an explicit join with
468    /// [`FanOutTransition::join_with`] before it can be returned from a task transition.
469    /// Branches in that fan-out are joined branches, not independent task endings. To express
470    /// multiple logical endings, transition each branch to a join node and finish the task from the
471    /// join node.
472    ///
473    /// # Examples
474    ///
475    /// ```no_run
476    /// use swiftide_tasks::{NodeError, Task, Transition};
477    ///
478    /// let mut task = Task::<i32, i32>::new();
479    /// let start = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input) });
480    /// let left = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input + 1) });
481    /// let right = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input + 2) });
482    ///
483    /// task.starts_with(start);
484    /// let join = task.register_node_fn(
485    ///     |input: &swiftide_tasks::JoinInput<i32>| -> Result<i32, NodeError> {
486    ///         Ok(input.iter().copied().sum())
487    ///     },
488    /// );
489    ///
490    /// task.register_transition(start, move |value| {
491    ///     Transition::fan_out(&left, value)
492    ///         .and(&right, value)
493    ///         .join_with(join.join())
494    /// })?;
495    /// # Ok::<(), Box<dyn std::error::Error>>(())
496    /// ```
497    pub fn fan_out<T, Input>(node_id: &NodeId<T>, context: Input) -> FanOutTransition
498    where
499        T: TaskNode + ?Sized,
500        Input: Into<T::Input>,
501    {
502        FanOutTransition {
503            targets: vec![NextNode::new(*node_id, context.into())],
504            concurrency_model: None,
505        }
506    }
507
508    /// Pauses the current branch.
509    ///
510    /// # Examples
511    ///
512    /// ```no_run
513    /// use swiftide_tasks::{NodeError, Task, TaskRunOutcome, Transition};
514    ///
515    /// # #[tokio::main(flavor = "current_thread")]
516    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
517    /// let mut task = Task::<i32, ()>::new();
518    /// let start = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input) });
519    ///
520    /// task.starts_with(start);
521    /// task.register_transition(start, |_value| Transition::pause())?;
522    ///
523    /// assert_eq!(task.run(1).await?, TaskRunOutcome::Paused);
524    /// # Ok(())
525    /// # }
526    /// ```
527    pub fn pause() -> Self {
528        Self {
529            action: TransitionAction::Pause,
530            concurrency_model: None,
531        }
532    }
533
534    /// Fails the current branch with the provided error.
535    ///
536    /// # Examples
537    ///
538    /// ```no_run
539    /// use std::io::Error;
540    ///
541    /// use swiftide_tasks::{NodeError, Task, Transition};
542    ///
543    /// # #[tokio::main(flavor = "current_thread")]
544    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
545    /// let mut task = Task::<i32, i32>::new();
546    /// let start = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input) });
547    ///
548    /// task.starts_with(start);
549    /// task.register_transition(start, |_value| Transition::error(Error::other("boom")))?;
550    ///
551    /// assert!(task.run(1).await.is_err());
552    /// # Ok(())
553    /// # }
554    /// ```
555    pub fn error(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
556        Self {
557            action: TransitionAction::Error(error.into()),
558            concurrency_model: None,
559        }
560    }
561
562    pub(crate) fn finish<T: NodeArg>(output: T) -> Self {
563        Self {
564            action: TransitionAction::Finish(Arc::new(output) as Arc<dyn Any + Send + Sync>),
565            concurrency_model: None,
566        }
567    }
568
569    /// Overrides the concurrency model for work scheduled by this transition.
570    pub fn concurrency_model(mut self, concurrency_model: ConcurrencyModel) -> Self {
571        self.concurrency_model = Some(concurrency_model);
572        self
573    }
574}
575
576/// The typed aggregated view of branches that have reached a join node.
577///
578/// `JoinInput<T>` is the normal join input. It is only available when every branch contributes a
579/// `T`, or maps its output into `T` before joining. For mixed payload types, use [`AnyJoinInput`].
580#[derive(Clone)]
581pub struct JoinInput<T: NodeArg> {
582    branches: Vec<Arc<T>>,
583}
584
585impl<T: NodeArg> std::fmt::Debug for JoinInput<T> {
586    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
587        f.debug_struct("JoinInput")
588            .field("payload_type", &std::any::type_name::<T>())
589            .field("len", &self.branches.len())
590            .finish()
591    }
592}
593
594impl<T: NodeArg> JoinInput<T> {
595    pub(crate) fn new(branches: Vec<Arc<T>>) -> Self {
596        Self { branches }
597    }
598
599    /// Iterates over every ready branch payload in stable branch creation order.
600    ///
601    /// This is the usual way to read join payloads.
602    pub fn iter(&self) -> impl Iterator<Item = &T> {
603        self.branches.iter().map(Arc::as_ref)
604    }
605
606    /// Returns the number of branch payloads in this join.
607    pub fn len(&self) -> usize {
608        self.branches.len()
609    }
610
611    /// Returns `true` when this join has no branch payloads.
612    pub fn is_empty(&self) -> bool {
613        self.branches.is_empty()
614    }
615}
616
617/// The type-erased aggregated view of branches that have reached a join node.
618///
619/// Use this for joins that intentionally collect mixed branch payload types. Homogeneous joins
620/// should prefer [`JoinInput<T>`](JoinInput), which is checked when transitions are registered.
621#[derive(Clone)]
622pub struct AnyJoinInput {
623    branches: Vec<ErasedPayload>,
624}
625
626impl std::fmt::Debug for AnyJoinInput {
627    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
628        f.debug_struct("AnyJoinInput")
629            .field("len", &self.branches.len())
630            .finish()
631    }
632}
633
634impl AnyJoinInput {
635    pub(crate) fn new(branches: Vec<ErasedPayload>) -> Self {
636        Self { branches }
637    }
638
639    /// Iterates over every type-erased branch payload in stable branch creation order.
640    pub fn iter_any(&self) -> impl Iterator<Item = &(dyn Any + Send + Sync)> {
641        self.branches.iter().map(Arc::as_ref)
642    }
643
644    /// Iterates over every ready branch payload that can be downcast to `T`.
645    ///
646    /// Values are yielded in stable branch creation order. This is the usual way to read join
647    /// payloads from a mixed join when you only need one payload type.
648    ///
649    /// # Examples
650    ///
651    /// ```no_run
652    /// use swiftide_tasks::{AnyJoinInput, NodeError, Task, Transition};
653    ///
654    /// let mut task = Task::<i32, i32>::new();
655    /// let start = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input) });
656    /// let left = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input + 1) });
657    /// let right = task.register_node_fn(|input: &i32| -> Result<i32, NodeError> { Ok(*input + 2) });
658    /// let join = task.register_node_fn(|input: &AnyJoinInput| -> Result<i32, NodeError> {
659    ///     Ok(input.iter::<i32>().copied().sum())
660    /// });
661    ///
662    /// task.starts_with(start);
663    /// task.register_transition(start, move |value| {
664    ///     Transition::fan_out(&left, value)
665    ///         .and(&right, value)
666    ///         .join_with(join.join_any())
667    /// })?;
668    /// task.register_transition(left, join.join_any())?;
669    /// task.register_transition(right, join.join_any())?;
670    /// # Ok::<(), Box<dyn std::error::Error>>(())
671    /// ```
672    pub fn iter<T: NodeArg>(&self) -> impl Iterator<Item = &T> {
673        self.iter_any()
674            .filter_map(|value| value.downcast_ref::<T>())
675    }
676
677    /// Returns the number of branch payloads in this join.
678    pub fn len(&self) -> usize {
679        self.branches.len()
680    }
681
682    /// Returns `true` when this join has no branch payloads.
683    pub fn is_empty(&self) -> bool {
684        self.branches.is_empty()
685    }
686}