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, BatchTask, and
SteppedTask.
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.
#[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))
}
}
}
route may return TaskResult::Split as well as TaskResult::Single,
so a router can fan a workflow out into parallel states. See Split & Join
for what happens next.
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.
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);
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.
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.
#[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.
The crate ships a complete example — run it with cargo run --example router_task.