Join Strategies
Pick a completion condition for a split, and see each strategy wired up in code.
A split advances when its JoinStrategy is satisfied. This page works through all six
strategies with runnable JoinConfig wiring, then compares them side by side. See
Split & Join for how splitting a state and the bulkhead work.
Join Strategy Examples
Each strategy handles parallel task completion differently. The examples below isolate the
JoinConfig wiring for each one.
Runnable example: cargo run --example join_strategies — runs the same parallel split four
times with Any, Quorum, Percentage, and PartialResults,
with staggered task delays so you can see exactly when each one returns.
All — Wait for Every Task
Waits for all tasks to complete successfully. Fails if any task fails. Use it when every result is required.
All Strategy
use cano::prelude::*;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum DataState { Start, ParallelProcessing, Aggregate, Complete }
#[derive(Clone)]
struct DataLoader;
#[derive(Clone)]
struct ProcessorTask { id: u32 }
impl ProcessorTask { fn new(id: u32) -> Self { Self { id } } }
#[derive(Clone)]
struct Aggregator;
#[task(state = DataState)]
impl DataLoader {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::ParallelProcessing))
}
}
#[task(state = DataState)]
impl ProcessorTask {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::Aggregate))
}
}
#[task(state = DataState)]
impl Aggregator {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::Complete))
}
}
fn build_workflow(store: MemoryStore) -> Workflow<DataState> {
// All Strategy: best for workflows requiring complete data
let join_config = JoinConfig::new(JoinStrategy::All, DataState::Aggregate)
.with_timeout(Duration::from_secs(10));
Workflow::new(Resources::new().insert("store", store))
.register(DataState::Start, DataLoader)
.register_split(
DataState::ParallelProcessing,
vec![ProcessorTask::new(1), ProcessorTask::new(2), ProcessorTask::new(3)],
join_config,
)
.register(DataState::Aggregate, Aggregator)
.add_exit_state(DataState::Complete)
}
Any — First to Complete
Proceeds as soon as the first task completes successfully; the rest are cancelled. Ideal for redundant calls where the fastest response wins.
Any Strategy
use cano::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum DataState { Start, ParallelProcessing, Complete }
#[derive(Clone)]
struct DataLoader;
#[task(state = DataState)]
impl DataLoader {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::ParallelProcessing))
}
}
#[derive(Clone)]
struct ApiCallTask { provider: String }
impl ApiCallTask { fn new(provider: &str) -> Self { Self { provider: provider.into() } } }
#[task(state = DataState)]
impl ApiCallTask {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::Complete))
}
}
fn build_workflow(store: MemoryStore) -> Workflow<DataState> {
// Any Strategy: best for redundant API calls or fastest-wins scenarios
let join_config = JoinConfig::new(JoinStrategy::Any, DataState::Complete);
// Call 3 different data sources, use whoever responds first
Workflow::new(Resources::new().insert("store", store))
.register(DataState::Start, DataLoader)
.register_split(
DataState::ParallelProcessing,
vec![
ApiCallTask::new("provider1"),
ApiCallTask::new("provider2"),
ApiCallTask::new("provider3"),
],
join_config,
)
.add_exit_state(DataState::Complete)
}
Quorum(n) — Wait for N Tasks
Proceeds once a specific number of tasks complete successfully; the rest are cancelled. Useful for distributed consensus and majority-vote writes.
Quorum Strategy
use cano::prelude::*;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum DataState { Start, ParallelProcessing, Aggregate, Complete }
#[derive(Clone)]
struct PrepareData;
#[derive(Clone)]
struct WriteReplica { id: u32 }
impl WriteReplica { fn new(id: u32) -> Self { Self { id } } }
#[derive(Clone)]
struct ConfirmWrite;
#[task(state = DataState)]
impl PrepareData {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::ParallelProcessing))
}
}
#[task(state = DataState)]
impl WriteReplica {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::Aggregate))
}
}
#[task(state = DataState)]
impl ConfirmWrite {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::Complete))
}
}
fn build_workflow(store: MemoryStore) -> Workflow<DataState> {
// Quorum Strategy: write to 5 replicas, succeed when 3 confirm
let join_config = JoinConfig::new(JoinStrategy::Quorum(3), DataState::Aggregate)
.with_timeout(Duration::from_secs(5));
Workflow::new(Resources::new().insert("store", store))
.register(DataState::Start, PrepareData)
.register_split(
DataState::ParallelProcessing,
vec![
WriteReplica::new(1),
WriteReplica::new(2),
WriteReplica::new(3),
WriteReplica::new(4),
WriteReplica::new(5),
],
join_config,
)
.register(DataState::Aggregate, ConfirmWrite)
.add_exit_state(DataState::Complete)
}
Percentage(p) — Wait for a Fraction of Tasks
Proceeds once a percentage of tasks complete successfully. Scales with the batch size, so it works well when the number of split tasks varies.
Percentage Strategy
use cano::prelude::*;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum DataState { Start, ParallelProcessing, Aggregate, Complete }
#[derive(Clone)]
struct LoadRecords;
#[derive(Clone)]
struct RecordProcessor { idx: usize }
impl RecordProcessor { fn new(idx: usize) -> Self { Self { idx } } }
#[derive(Clone)]
struct SummarizeResults;
#[task(state = DataState)]
impl LoadRecords {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::ParallelProcessing))
}
}
#[task(state = DataState)]
impl RecordProcessor {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::Aggregate))
}
}
#[task(state = DataState)]
impl SummarizeResults {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::Complete))
}
}
fn build_workflow(store: MemoryStore) -> Workflow<DataState> {
// Percentage Strategy: process 100 records, proceed when 75 complete
let join_config = JoinConfig::new(JoinStrategy::Percentage(0.75), DataState::Aggregate)
.with_timeout(Duration::from_secs(10));
let tasks: Vec<RecordProcessor> = (0..100).map(RecordProcessor::new).collect();
Workflow::new(Resources::new().insert("store", store))
.register(DataState::Start, LoadRecords)
.register_split(DataState::ParallelProcessing, tasks, join_config)
.register(DataState::Aggregate, SummarizeResults)
.add_exit_state(DataState::Complete)
}
PartialResults(n) — Accept Partial Completion
Proceeds once n tasks have completed — successes or failures both count — and
cancels the rest. All outcomes are tracked, so a downstream task can inspect how many succeeded versus
failed. Good for latency-bounded fan-out where some failures are tolerable. A fuller, runnable
walk-through lives in cargo run --example workflow_partial_results.
PartialResults Strategy
use cano::prelude::*;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum DataState { Start, ParallelProcessing, Aggregate, Complete }
#[derive(Clone)]
struct PrepareRequest;
#[derive(Clone)]
struct ServiceCall { name: String }
impl ServiceCall { fn new(name: &str) -> Self { Self { name: name.into() } } }
#[derive(Clone)]
struct MergePartialResults;
#[task(state = DataState)]
impl PrepareRequest {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::ParallelProcessing))
}
}
#[task(state = DataState)]
impl ServiceCall {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::Aggregate))
}
}
#[task(state = DataState)]
impl MergePartialResults {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::Complete))
}
}
fn build_workflow(store: MemoryStore) -> Workflow<DataState> {
// PartialResults Strategy: proceed after any 3 of 4 service calls finish
let join_config = JoinConfig::new(JoinStrategy::PartialResults(3), DataState::Aggregate)
.with_timeout(Duration::from_secs(5));
Workflow::new(Resources::new().insert("store", store))
.register(DataState::Start, PrepareRequest)
.register_split(
DataState::ParallelProcessing,
vec![
ServiceCall::new("fast-service"),
ServiceCall::new("medium-service"),
ServiceCall::new("slow-service"),
ServiceCall::new("backup-service"),
],
join_config,
)
.register(DataState::Aggregate, MergePartialResults)
.add_exit_state(DataState::Complete)
}
PartialTimeout — Deadline-Based Completion
Accepts whatever has completed when the timeout expires and proceeds with those results — the
remaining tasks are cancelled. The go-to strategy for hard SLAs. Requires .with_timeout().
PartialTimeout Strategy
use cano::prelude::*;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum DataState { Start, ParallelProcessing, Aggregate, Complete }
#[derive(Clone)]
struct LoadUserContext;
#[derive(Clone)]
struct RecommendationEngine { kind: String }
impl RecommendationEngine { fn new(kind: &str) -> Self { Self { kind: kind.into() } } }
#[derive(Clone)]
struct AggregateWithinSla;
#[task(state = DataState)]
impl LoadUserContext {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::ParallelProcessing))
}
}
#[task(state = DataState)]
impl RecommendationEngine {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::Aggregate))
}
}
#[task(state = DataState)]
impl AggregateWithinSla {
async fn run_bare(&self) -> Result<TaskResult<DataState>, CanoError> {
Ok(TaskResult::Single(DataState::Complete))
}
}
fn build_workflow(store: MemoryStore) -> Workflow<DataState> {
// PartialTimeout Strategy: real-time recommendations with a 500ms SLA
let join_config = JoinConfig::new(JoinStrategy::PartialTimeout, DataState::Aggregate)
.with_timeout(Duration::from_millis(500));
Workflow::new(Resources::new().insert("store", store))
.register(DataState::Start, LoadUserContext)
.register_split(
DataState::ParallelProcessing,
vec![
RecommendationEngine::new("collaborative"),
RecommendationEngine::new("content-based"),
RecommendationEngine::new("trending"),
RecommendationEngine::new("personalized"),
],
join_config,
)
.register(DataState::Aggregate, AggregateWithinSla)
.add_exit_state(DataState::Complete)
}
Comparison Table
| Strategy | Trigger Condition | Cancels Others | Best Use Case |
|---|---|---|---|
All |
All tasks succeed | No | Complete data required |
Any |
First success | Yes | Redundant API calls |
Quorum(n) |
N tasks succeed | Yes | Distributed consensus |
Percentage(p) |
P% succeed | Yes | Batch processing |
PartialResults(n) |
N tasks complete (success or failure) | Yes | Latency optimization |
PartialTimeout |
Timeout reached | Yes | Strict SLA requirements |