TimerTask
Wait once, then transition — a single scheduled sleep, not a poll loop.
A TimerTask decides how long to wait, the engine sleeps exactly once, and then
the task decides where to go next. There is no loop and no condition re-check: a 30-minute
timer schedules one tokio::time::sleep and wakes a single time. It is one of the
Task family of processing models, alongside
RouterTask, PollTask,
BatchTask, SteppedTask, and
StreamTask. A
TimerTask reads typed dependencies from Resources the same
way every other model does. New to Cano? Read Workflows and
Resources first.
wait returns a TimerOutcome, then after_wait routes
use cano::prelude::*;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Stage { CoolDown, Done }
struct CoolDown;
#[task::timer(state = Stage)]
impl CoolDown {
async fn wait(&self, _res: &Resources) -> Result<TimerOutcome, CanoError> {
Ok(TimerOutcome::Duration(Duration::from_secs(30))) // one scheduled wake-up
}
async fn after_wait(&self, _res: &Resources) -> Result<TaskResult<Stage>, CanoError> {
Ok(TaskResult::Single(Stage::Done))
}
}
Reach for a TimerTask when you just need to pause, then continue — a
cool-down, a debounce, or waking after a known delay. Reach for a
PollTask when you need to re-check a condition while
waiting. A timer wakes once; a poller loops.
How the Timer Works
wait → sleep once → after_wait
A timer implements two required methods. async fn wait(&self, res: &Resources) -> Result<TimerOutcome, CanoError>
runs first and decides the delay; the engine then sleeps for that TimerOutcome; finally
async fn after_wait(&self, res: &Resources) -> Result<TaskResult<TState>, CanoError>
runs and returns the next state.
TimerOutcome variant |
Effect |
|---|---|
Duration(d) |
sleep for the relative duration d, then call after_wait (Duration::ZERO transitions immediately) |
Until(instant) |
sleep until the monotonic instant, then call after_wait (a past instant fires immediately) |
Quick Start with #[task::timer]
Attach #[task::timer(state = MyState)] to an inherent impl block. You write
wait and after_wait; the macro injects default bodies for config
and name if you don't, synthesises the
impl TimerTask<MyState> for MyTimer header, and emits a companion
impl Task<MyState> for MyTimer whose run schedules the sleep (via
cano::task::timer::run_timer) — so a timer is just an ordinary single-task state whose
run happens to sleep first. No engine changes.
#[task::timer(state = ...)] on an inherent impl
use cano::prelude::*;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Step { CoolDown, Process, Done }
struct CoolDown { delay: Duration }
#[task::timer(state = Step)]
impl CoolDown {
fn config(&self) -> TaskConfig {
// cap the whole wait at 5s
TaskConfig::minimal().with_attempt_timeout(Duration::from_secs(5))
}
async fn wait(&self, _res: &Resources) -> Result<TimerOutcome, CanoError> {
Ok(TimerOutcome::Duration(self.delay))
}
async fn after_wait(&self, _res: &Resources) -> Result<TaskResult<Step>, CanoError> {
Ok(TaskResult::Single(Step::Process))
}
}
Registering a Timer Task
Register a timer with plain Workflow::register — to the FSM a timer is an ordinary
Single state, so there is no special builder method (the companion impl Task
the macro generated does the wait internally).
use cano::prelude::*;
let workflow = Workflow::bare()
.register(Step::CoolDown, CoolDown { delay: Duration::from_millis(50) })
.register(Step::Process, ProcessResults)
.add_exit_state(Step::Done);
Duration vs Until
TimerOutcome::Duration(d)
Wait a relative delay measured from now. Best for cool-downs, debounces, and
fixed pacing between phases. Duration::ZERO skips the sleep entirely.
TimerOutcome::Until(instant)
Wait until a monotonic std::time::Instant (a process-relative reading, not
a wall-clock/calendar time). Best for "wake after a known delay" hand-offs. An instant already in
the past fires immediately.
Bounding the Wait
A timer runs as a single dispatch attempt, so an attempt_timeout from
config() caps the whole wait. If the timer hasn't fired by the deadline, the
workflow engine cancels it and produces CanoError::Timeout.
The default config() is TaskConfig::minimal() — no retries.
A single scheduled sleep doesn't benefit from outer retry wrapping; attach an
attempt_timeout instead if you need a bound.
Explicit Trait-Impl Form
Prefer writing the trait header yourself? Put a bare #[task::timer] on an
impl TimerTask<...> for ... block. The companion impl Task is still
emitted; explicit method definitions always win.
#[task::timer] on a trait impl
use cano::prelude::*;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Step { CoolDown, Process, Done }
struct CoolDown;
#[task::timer]
impl TimerTask<Step> for CoolDown {
async fn wait(&self, _res: &Resources) -> Result<TimerOutcome, CanoError> {
Ok(TimerOutcome::Duration(Duration::from_secs(30)))
}
async fn after_wait(&self, _res: &Resources) -> Result<TaskResult<Step>, CanoError> {
Ok(TaskResult::Single(Step::Process))
}
}
Type-Erased Aliases
| Alias | Expands to |
|---|---|
DynTimerTask<TState, TResourceKey> |
dyn TimerTask<TState, TResourceKey> |
TimerTaskObject<TState, TResourceKey> |
Arc<dyn TimerTask<TState, TResourceKey>> |
When to Use TimerTask
Reach for a TimerTask when:
- you need a fixed cool-down or debounce between workflow phases;
- you want to wake after a known delay via
TimerOutcome::Untilwith a monotonic instant; - you want a deliberate pause that doesn't depend on polling an external condition — if it does, use a PollTask instead.
A TimerTask is a checkpointed single-task state: a
resumed run re-runs wait() from the start. A
Duration(30 min) timer that crashes at minute 29 sleeps the full 30 minutes again, and
an Until deadline built from Instant::now() is recomputed relative to the
new "now" — so it shifts forward by the downtime. std::time::Instant is monotonic and
process-relative, not a calendar time. If you need a delay that survives a crash, derive it inside
wait() from a timestamp you persist in Resources or a
checkpoint, not from Instant::now().
The crate ships a complete example — run it with cargo run --example timer_task.