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.

At a glance — 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))
    }
}
TimerTask vs PollTask

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 TimerTask waits exactly once: wait returns a TimerOutcome, the engine schedules a single sleep for that duration or until that instant, then after_wait runs and its TaskResult moves the workflow to the next state. TimerOutcome wakes once wait sleep once Duration(d) · Until(instant) exactly one wake-up — not a poll loop after_wait TaskResult Next State

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.

Inference form — #[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).

Wiring a timer into a workflow
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.

Explicit form — #[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::Until with a monotonic instant;
  • you want a deliberate pause that doesn't depend on polling an external condition — if it does, use a PollTask instead.
Recovery

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().

Runnable example

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

to navigate to open esc to close