RouterTask

Side-effect-free branching for your workflows.

A RouterTask is a processing model for pure routing: it reads Resources and returns the next TaskResult<TState> — and nothing else. No store mutations, no external I/O, no side effects. Because re-running a router is free, the workflow engine records no checkpoint row for it — see Crash Recovery for what that means. It is one of the Task family of processing models, alongside PollTask, TimerTask, BatchTask, SteppedTask, and StreamTask.

Key concept

A router's job is to decide, not to do. Keep route free of side effects and the engine treats branching as cost-free on resume — there is no recovery row to write, nothing to replay. If your branching logic also needs to write something, reach for a plain Task instead.


Quick Start with #[task::router]

The required method is async fn route(&self, res: &Resources) -> Result<TaskResult<TState>, CanoError>. Everything else has a default: fn config(&self) -> TaskConfig (defaults to TaskConfig::default()) and fn name(&self) -> Cow<'static, str> (defaults to the type name). The recommended form attaches #[task::router(state = MyState)] to an inherent impl block — the macro synthesises the impl RouterTask<MyState> for MyRouter header and emits a companion impl Task<MyState> for MyRouter so the same struct can also be passed to register if you ever want the checkpoint-recording behaviour back.

route() reads, decides, and returns — nothing else

A router dispatch: the Classify state calls route(&Resources), which reads the config out of Resources without writing anything and returns TaskResult::Single — Step::FastPath when the flag is set, Step::SlowPath otherwise; because the decision has no side effects the engine writes no CheckpointRow for the state. reads, never writes register_router Single(Step::FastPath) Single(Step::SlowPath) no side effects Resources config.use_fast_path Step::Classify route(&Resources) → TaskResult<Step> Step::FastPath Step::SlowPath CheckpointRow no row, no sequence
Inference form — #[task::router(state = ...)] on an inherent impl
use cano::prelude::*;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Step { Classify, FastPath, SlowPath, Done }

struct Config { use_fast_path: bool }
#[resource]
impl Resource for Config {}

struct Classifier;

#[task::router(state = Step)]
impl Classifier {
    async fn route(&self, res: &Resources) -> Result<TaskResult<Step>, CanoError> {
        let config = res.get::<Config, _>("config")?;
        if config.use_fast_path {
            Ok(TaskResult::Single(Step::FastPath))
        } else {
            Ok(TaskResult::Single(Step::SlowPath))
        }
    }
}
Heads-up

route must return TaskResult::Single — a router state is dispatched through the single-task path, and returning TaskResult::Split from it fails at run time with CanoError::Workflow (“use register_split() for split tasks”). To fan out into parallel states, route to a state registered with register_split() instead.


Registering a Router

Register a router with Workflow::register_router(state, task)not register. The engine dispatches it exactly like an ordinary single-task state, but with one difference: it writes no CheckpointRow for the router state and consumes no checkpoint sequence number. A router has no side effects, so re-running it on resume costs nothing — there is nothing to recover, so there is nothing to record.

Wiring a router into a workflow
use cano::prelude::*;

let workflow = Workflow::new(resources)
    .register_router(Step::Classify, Classifier)   // router state — leaves no checkpoint row
    .register(Step::FastPath, FastProcessor)
    .register(Step::SlowPath, SlowProcessor)
    .add_exit_state(Step::Done);
Recovery interplay

On a checkpointed workflow, the recovery log skips router states entirely: a Start → Classify (router) → FastPath → Done run records rows for Start, FastPath, and Done — but not Classify. If a crash happens inside FastProcessor, resume_from re-enters at FastPath, having never needed to "remember" the routing decision — it just runs the router again on the way through if the resume point happens to land before it.

Recovery footprint of a run that passes through a router

A Start to Classify to FastPath to Done run: the checkpoint log holds one row per state entered — sequence 0 for Start, 1 for FastPath, 2 for Done — while the Classify router state writes no row and consumes no sequence number. router state Start Classify FastPath Done seq 0 · Start no row written seq 1 · FastPath seq 2 · Done Checkpoint log: one row per state entered — Classify writes none and burns no sequence number.

Explicit Trait-Impl Form

If you prefer to write the trait header yourself — e.g. for a generic impl, or a custom resource-key type — drop the state = ... argument and put a bare #[task::router] on a impl RouterTask<...> for ... block. Both forms emit the companion impl Task<...> for T.

Explicit form — #[task::router] on a trait impl
use cano::prelude::*;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Step { Classify, FastPath, SlowPath, Done }

struct Classifier;

#[task::router]
impl RouterTask<Step> for Classifier {
    fn name(&self) -> std::borrow::Cow<'static, str> {
        "classifier".into()
    }

    async fn route(&self, res: &Resources) -> Result<TaskResult<Step>, CanoError> {
        let config = res.get::<Config, _>("config")?;
        Ok(TaskResult::Single(if config.use_fast_path {
            Step::FastPath
        } else {
            Step::SlowPath
        }))
    }
}

Type-Erased Aliases

For dynamic dispatch — keeping a heterogeneous collection of routers, building one at runtime — Cano exports two aliases mirroring DynTask / TaskObject:

Alias Expands to
DynRouterTask<TState, TResourceKey> dyn RouterTask<TState, TResourceKey>
RouterTaskObject<TState, TResourceKey> Arc<dyn RouterTask<TState, TResourceKey>>

When to Use RouterTask

Reach for a RouterTask when:

  • you need conditional branching and the decision has no side effects — routing on a config flag, on the shape of already-loaded data, on a feature toggle;
  • you want the workflow to leave no recovery footprint for the branch (no checkpoint row, no sequence number burned).

If your branching logic also does work — writing to the store, calling an external system — use a plain Task and match-and-return the next state from run; that's the "Conditional Routing Task" pattern documented on the Tasks page, and those side effects do need a checkpoint, so a plain task is the right tool.

Runnable example

The crate ships a complete example — run it with cargo run --example router_task.

to navigate to open esc to close