Split & Join

Fan work out across many tasks, then join the results back into the FSM.

A workflow normally runs one task per state. When you need parallelism — scatter-gather queries, redundant API calls, batch processing, hedged requests against an SLA — register a split for that state: a list of tasks that run concurrently, plus a JoinConfig that decides when the workflow may advance and which next state it lands on. The join strategy controls the termination condition; an optional bulkhead bounds how many of those tasks run at once.


Splitting a State

Use register_split(state, tasks, join_config) in place of register() to map a state to a set of tasks that run in parallel. JoinConfig::new(strategy, next_state) defines the termination condition and the state the workflow transitions to once the strategy is satisfied; .with_timeout(duration) caps how long the join waits, and .with_bulkhead(n) caps how many split tasks run concurrently. Like every other builder method, register_split() consumes self and returns a new Workflow — capture the return value.

Split / Join Pattern

graph TD A[Process State] -->|Split| B[Task 1] A -->|Split| C[Task 2] A -->|Split| D[Task 3] B --> E{Join Strategy} C --> E D --> E E -->|Satisfied| F[Aggregate State] E -->|Failed/Timeout| G[Error State]
register_split + JoinConfig
let join_config = JoinConfig::new(JoinStrategy::All, State::Aggregate)
    .with_timeout(Duration::from_secs(5));

Workflow::new(Resources::new().insert("store", store))
    .register(State::Start, Loader)
    .register_split(State::Fanout, vec![Worker { id: 1 }, Worker { id: 2 }, Worker { id: 3 }], join_config)
    .register(State::Aggregate, Aggregator)
    .add_exit_state(State::Complete)
Two ways to fan out

A state can be a split because you registered it with register_split() (the tasks and join strategy are fixed at build time), or because a single task returned TaskResult::Split(...) at runtime to spawn a dynamic set of follow-on states. A single task that returns TaskResult::Split for a state registered with plain register() fails with CanoError::Workflow — use register_split() for that state.


Join Strategies

The JoinStrategy enum controls when a split is considered done and the workflow may advance.

All

Wait for all tasks to complete successfully.

JoinStrategy::All

Any

Proceed after the first task completes successfully.

JoinStrategy::Any

Quorum(n)

Wait for n tasks to complete successfully.

JoinStrategy::Quorum(2)

Percentage(p)

Wait for p% of tasks to complete successfully (value in (0.0, 1.0]).

JoinStrategy::Percentage(0.5)

PartialResults(n)

Proceed once n tasks complete (successes or failures).

JoinStrategy::PartialResults(3)

PartialTimeout

Accept whatever completes before timeout expires. Requires .with_timeout().

JoinStrategy::PartialTimeout
PartialTimeout needs a timeout

A JoinConfig using PartialTimeout without a configured timeout fails at execution time with CanoError::Configuration. Always pair it with .with_timeout(duration).


Bounding Concurrency with a Bulkhead

A split fans out as many tasks as the runtime can schedule. When you need to cap concurrent in-flight work — to bound resource use, protect a downstream service, or stabilise tail latency — set a bulkhead on the JoinConfig. Internally this gates each spawned task body on a shared tokio::sync::Semaphore; excess tasks queue until a permit is free, but the join strategy still applies once results come in.

Bulkhead config
let join_config = JoinConfig::new(JoinStrategy::All, State::Aggregate)
    .with_bulkhead(4); // at most 4 split tasks run at once
Validation

with_bulkhead(0) is rejected at execution time with CanoError::Configuration. Leave the bulkhead unset (None) for unbounded concurrency. Bulkheads compose with PartialTimeout and any other join strategy.

Runnable example: cargo run --example split_bulkhead — 8 split tasks behind a with_bulkhead(2), with start/end timestamps printed so you can see the 4 waves of 2.


Complete Example

A runnable end-to-end split/join: a loader writes input data into the store, three processor tasks fan out in parallel, the All strategy waits for every one of them, and an aggregator reads the per-task results back out. Run the full version with cargo run --example workflow_split_join.

use cano::prelude::*;
use std::time::Duration;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum DataState { Start, ParallelProcessing, Aggregate, Complete }

#[derive(Clone)]
struct DataLoader;

#[task(state = DataState)]
impl DataLoader {
    async fn run(&self, res: &Resources) -> Result<TaskResult<DataState>, CanoError> {
        let store = res.get::<MemoryStore, _>("store")?;
        store.put("input_data", vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10])?;
        Ok(TaskResult::Single(DataState::ParallelProcessing))
    }
}

#[derive(Clone)]
struct ProcessorTask { task_id: usize }
impl ProcessorTask { fn new(task_id: usize) -> Self { Self { task_id } } }

#[task(state = DataState)]
impl ProcessorTask {
    async fn run(&self, res: &Resources) -> Result<TaskResult<DataState>, CanoError> {
        let store = res.get::<MemoryStore, _>("store")?;
        let data: Vec<i32> = store.get("input_data")?;
        tokio::time::sleep(Duration::from_millis(100 * self.task_id as u64)).await;
        let result: i32 = data.iter().map(|&x| x * self.task_id as i32).sum();
        store.put(&format!("result_{}", self.task_id), result)?;
        Ok(TaskResult::Single(DataState::Aggregate))
    }
}

#[derive(Clone)]
struct Aggregator;

#[task(state = DataState)]
impl Aggregator {
    async fn run(&self, res: &Resources) -> Result<TaskResult<DataState>, CanoError> {
        let store = res.get::<MemoryStore, _>("store")?;
        let total: i32 = (1..=3)
            .filter_map(|i| store.get::<i32>(&format!("result_{}", i)).ok())
            .sum();
        store.put("final_result", total)?;
        Ok(TaskResult::Single(DataState::Complete))
    }
}

#[tokio::main]
async fn main() -> Result<(), CanoError> {
    let store = MemoryStore::new();

    let processors = vec![
        ProcessorTask::new(1),
        ProcessorTask::new(2),
        ProcessorTask::new(3),
    ];

    // Wait for ALL processors, give up after 5 seconds.
    let join_config = JoinConfig::new(JoinStrategy::All, DataState::Aggregate)
        .with_timeout(Duration::from_secs(5));

    let workflow = Workflow::new(Resources::new().insert("store", store.clone()))
        .register(DataState::Start, DataLoader)
        .register_split(DataState::ParallelProcessing, processors, join_config)
        .register(DataState::Aggregate, Aggregator)
        .add_exit_state(DataState::Complete);

    let result = workflow.orchestrate(DataState::Start).await?;
    let final_result: i32 = store.get("final_result")?;
    println!("Workflow completed: {:?} — total {}", result, final_result);
    Ok(())
}

Continue reading

This page covers splitting a state, the strategy options, the bulkhead, and a complete example. Two companion pages go deeper: