Scheduler

Automate your workflows with flexible scheduling and concurrency.

Behind the scheduler feature gate (features = ["scheduler"]).

The Scheduler provides workflow scheduling capabilities for background jobs and automated workflows. It supports intervals, cron expressions, and manual triggers. Each registered workflow carries a Resources dictionary whose setup() and teardown() lifecycle hooks run once per scheduler.start() / scheduler.stop() call โ€” not once per scheduled run.

Type Constraint

All workflows registered with a single Scheduler instance must share the same TState type. The scheduler is generic over Scheduler<TState>, so all registered workflows use the same state enum. For workflows with different state enums, create separate Scheduler instances.


Lifecycle: SchedulerRunningScheduler

The scheduler is split into two halves to make a double-start impossible at the type level:

  • Scheduler is the builder. Register workflows with every / cron / manual and, if you want something other than BackoffPolicy::default(), override it per flow via set_backoff. Scheduler is not Clone.
  • RunningScheduler is the live handle returned by scheduler.start().await?. It owns the spawned driver and per-flow loop tasks. It is cheap to clone โ€” every clone shares the same command channel and flow registry, so you can call trigger, status, list, reset_flow, cancel_flow, and stop from any task.

start consumes the builder, so the compiler prevents you from starting the same scheduler twice or mutating the registry mid-flight. stop().await on any clone signals graceful shutdown and waits for it to complete; wait().await blocks without sending Stop, useful for "main blocks until Ctrl+C handler stops the scheduler" patterns.


Overlap Prevention

The scheduler prevents overlapping executions of the same workflow. If a previous execution is still running when the next interval or cron trigger fires, the new run is skipped. This prevents resource exhaustion from slow-running workflows that accumulate concurrent instances over time.

For example, if a workflow is configured to run every 30 seconds but a particular execution takes 45 seconds, the scheduler will skip the trigger at the 30-second mark and wait for the next interval after the current run completes.


Scheduling Strategies

Interval

Run workflows at fixed time intervals.

scheduler.every_seconds(...)

Cron

Run workflows based on cron expressions.

scheduler.cron(..., "0 0 9 * * *")

Manual

Trigger workflows on-demand via API.

scheduler.manual(...)


Scheduling Strategy Examples

The Scheduler supports multiple scheduling strategies. Here are complete examples for each.

1. Interval Scheduling - Fixed Time Intervals

Run workflows at regular time intervals. Best for periodic tasks like health checks or data syncing.

Interval Scheduling Timeline

Interval scheduling every 30 seconds: a 2-second run is followed by a 28-second wait, so the workflow is busy for only 2 seconds out of every 30. 30s period Workflow wait 28s wait 28s Run 1 Run 2 Run 3 0s 10s 20s 30s 40s 50s 60s 2s busy, 28s idle โ€” the loop sleeps to the next 30s boundary, it does not spin
use cano::prelude::*;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum State { Start, Complete }

#[derive(Clone)]
struct HealthCheckTask;

#[task(state = State)]
impl HealthCheckTask {
    async fn run(&self, res: &Resources) -> Result<TaskResult<State>, CanoError> {
        println!("Running health check...");

        // Check system health
        let store = res.get::<MemoryStore, _>("store")?;
        let status = "healthy".to_string();
        store.put("last_health_check", status)?;

        Ok(TaskResult::Single(State::Complete))
    }
}

#[tokio::main]
async fn main() -> Result<(), CanoError> {
    let mut scheduler = Scheduler::new();
    let store = MemoryStore::new();

    let workflow = Workflow::new(Resources::new().insert("store", store.clone()))
        .register(State::Start, HealthCheckTask)
        .add_exit_state(State::Complete);

    // Run every 30 seconds
    scheduler.every_seconds("health_check", workflow, State::Start, 30)?;

    // start() consumes the builder and returns a clone-able RunningScheduler.
    // wait() blocks until somebody calls stop() on a clone.
    let running = scheduler.start().await?;
    running.wait().await?;
    Ok(())
}

Runnable examples: cargo run --example scheduler_duration_scheduling --features scheduler (interval-only) and cargo run --example scheduler_scheduling --features scheduler (intervals plus cron and manual flows).

2. Cron Scheduling - Time-Based Expressions

Run workflows based on cron expressions. Perfect for scheduled reports, backups, or time-specific tasks.

Cron Scheduling Timeline

Cron scheduling: the workflow is idle through the day and runs for one hour at 09:00 and again at 18:00, repeating at the same wall-clock times daily. 0 0 9 * * * 0 0 18 * * * Run 1 ยท 09:00 Run 2 ยท 18:00 Workflow idle 1h 1h 00:00 03:00 06:00 09:00 12:00 15:00 18:00 21:00 24:00 two 1-hour runs a day โ€” idle the other 22 hours, then the same times tomorrow
use cano::prelude::*;
use chrono::Utc;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum State { Start, Complete }

#[derive(Clone)]
struct DailyReport {
    report_type: String,
}

#[task(state = State)]
impl DailyReport {
    async fn run(&self, res: &Resources) -> Result<TaskResult<State>, CanoError> {
        println!("Preparing {} report...", self.report_type);

        let store = res.get::<MemoryStore, _>("store")?;

        // Load data for report
        let data = vec!["metric1".to_string(), "metric2".to_string(), "metric3".to_string()];
        store.put("report_start", Utc::now().to_rfc3339())?;

        println!("Generating report with {} records", data.len());
        let result = format!("{} report: {} records processed", self.report_type, data.len());

        println!("Report completed: {}", result);
        store.put("last_report", result)?;

        Ok(TaskResult::Single(State::Complete))
    }
}

#[tokio::main]
async fn main() -> Result<(), CanoError> {
    let mut scheduler = Scheduler::new();
    let store = MemoryStore::new();

    // Morning report workflow
    let morning_report = Workflow::new(Resources::new().insert("store", store.clone()))
        .register(State::Start, DailyReport {
            report_type: "Morning".to_string()
        })
        .add_exit_state(State::Complete);

    // Evening report workflow
    let evening_report = Workflow::new(Resources::new().insert("store", store.clone()))
        .register(State::Start, DailyReport { 
            report_type: "Evening".to_string() 
        })
        .add_exit_state(State::Complete);

    // Run daily at 9 AM: "0 0 9 * * *"
    scheduler.cron("morning_report", morning_report, State::Start, "0 0 9 * * *")?;

    // Run daily at 6 PM: "0 0 18 * * *"
    scheduler.cron("evening_report", evening_report, State::Start, "0 0 18 * * *")?;

    let running = scheduler.start().await?;
    running.wait().await?;
    Ok(())
}

3. Manual Triggering - On-Demand Execution

Trigger workflows manually via API. Ideal for user-initiated tasks or event-driven processing.

Type-safe lifecycle

trigger() lives on RunningScheduler, which is only obtained by calling scheduler.start().await?. The compiler will not let you trigger a workflow before the scheduler is running.

Manual Trigger Sequence

Manual triggering: an API request calls trigger on the running scheduler, which starts the workflow and relays its completion back to the caller. API Request Scheduler Workflow trigger("data_export") Start Workflow Complete Success โœ“ Nothing runs until trigger() is called โ€” no interval, no cron
use cano::prelude::*;
use std::time::Duration;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum State { Start, Complete }

#[derive(Clone)]
struct DataExportTask;

#[task(state = State)]
impl DataExportTask {
    async fn run(&self, res: &Resources) -> Result<TaskResult<State>, CanoError> {
        println!("Starting data export...");

        // Export data to CSV
        let store = res.get::<MemoryStore, _>("store")?;
        let export_path = "/tmp/export.csv".to_string();
        store.put("export_path", export_path)?;

        println!("Export completed");
        Ok(TaskResult::Single(State::Complete))
    }
}

#[tokio::main]
async fn main() -> Result<(), CanoError> {
    let mut scheduler = Scheduler::new();
    let store = MemoryStore::new();

    let export_workflow = Workflow::new(Resources::new().insert("store", store.clone()))
        .register(State::Start, DataExportTask)
        .add_exit_state(State::Complete);

    // Register as manual-only workflow
    scheduler.manual("data_export", export_workflow, State::Start)?;

    // Start consumes the builder and returns a live, clone-able handle.
    let running = scheduler.start().await?;

    // Trigger manually when needed
    println!("Triggering export...");
    running.trigger("data_export").await?;

    // Can be triggered again later
    tokio::time::sleep(Duration::from_secs(5)).await;
    running.trigger("data_export").await?;

    // stop() sends the Stop command and waits for graceful shutdown.
    running.stop().await?;
    Ok(())
}

4. Mixed Scheduling - Combining Strategies

Use multiple scheduling strategies together for complex automation scenarios.

Mixed Strategy Overview

Mixed scheduling over one day: an interval flow pulses all day long, two cron flows take one hour each at 03:00 and 09:00, and a manual export fires once at 14:30. every 5 min ยท 288 runs/day (cadence illustrated) Interval Daily Backup Weekly Report Cron Emergency Export ยท 14:30 Manual 00:00 03:00 06:00 09:00 12:00 15:00 18:00 21:00 24:00 one scheduler, three trigger sources โ€” interval, cron, and on-demand
use cano::prelude::*;
use std::time::Duration;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum State { Start, Complete }

#[tokio::main]
async fn main() -> Result<(), CanoError> {
    let mut scheduler = Scheduler::new();
    let store = MemoryStore::new();

    // Define simple tasks
    #[derive(Clone)]
    struct DataSyncTask;

    #[task(state = State)]
    impl DataSyncTask {
        async fn run(&self, _res: &Resources) -> Result<TaskResult<State>, CanoError> {
            println!("Syncing data...");
            Ok(TaskResult::Single(State::Complete))
        }
    }

    #[derive(Clone)]
    struct BackupTask;
    #[task(state = State)]
    impl BackupTask {
        async fn run(&self, _res: &Resources) -> Result<TaskResult<State>, CanoError> {
            println!("Running backup...");
            Ok(TaskResult::Single(State::Complete))
        }
    }

    #[derive(Clone)]
    struct WeeklyReportTask;

    #[task(state = State)]
    impl WeeklyReportTask {
        async fn run(&self, _res: &Resources) -> Result<TaskResult<State>, CanoError> {
            println!("Generating weekly report...");
            Ok(TaskResult::Single(State::Complete))
        }
    }

    #[derive(Clone)]
    struct EmergencyExportTask;

    #[task(state = State)]
    impl EmergencyExportTask {
        async fn run(&self, _res: &Resources) -> Result<TaskResult<State>, CanoError> {
            println!("Emergency export...");
            Ok(TaskResult::Single(State::Complete))
        }
    }

    // 1. Interval: Data sync every 5 minutes
    let sync_workflow = Workflow::new(Resources::new().insert("store", store.clone()))
        .register(State::Start, DataSyncTask)
        .add_exit_state(State::Complete);

    scheduler.every_seconds("data_sync", sync_workflow, State::Start, 300)?;

    // 2. Cron: Daily backup at 3 AM
    let backup_workflow = Workflow::new(Resources::new().insert("store", store.clone()))
        .register(State::Start, BackupTask)
        .add_exit_state(State::Complete);

    scheduler.cron("daily_backup", backup_workflow, State::Start, "0 0 3 * * *")?;

    // 3. Cron: Weekly report on Mondays at 9 AM
    let report_workflow = Workflow::new(Resources::new().insert("store", store.clone()))
        .register(State::Start, WeeklyReportTask)
        .add_exit_state(State::Complete);

    scheduler.cron("weekly_report", report_workflow, State::Start, "0 0 9 * * MON")?;

    // 4. Manual: Emergency data export
    let export_workflow = Workflow::new(Resources::new().insert("store", store.clone()))
        .register(State::Start, EmergencyExportTask)
        .add_exit_state(State::Complete);

    scheduler.manual("emergency_export", export_workflow, State::Start)?;

    // Start consumes the builder and returns a live handle.
    let running = scheduler.start().await?;

    // Monitor and trigger as needed
    loop {
        tokio::time::sleep(Duration::from_secs(60)).await;

        // Check status of all workflows
        let workflows = running.list().await;
        for info in workflows {
            println!("{}: {:?} (runs: {})", info.id, info.status, info.run_count);
        }

        // Example: Trigger emergency export if needed based on some condition
        // running.trigger("emergency_export").await?;
    }
}

Runnable example: cargo run --example scheduler_mixed_workflows --features scheduler โ€” interval, cron, and manual flows side by side, plus a trigger on the manual one.


Backoff & Trip State

When a scheduled flow keeps failing, the scheduler backs it off and can trip it. How that works, how to override the policy, the Status variants, and how to recover a tripped flow live on a dedicated page:


Cancelling a Flow

RunningScheduler::cancel_flow(id) requests cooperative cancellation of a flow's in-flight run. The running workflow aborts at its next await point, its saga compensation stack drains (rolling back completed steps), and the flow returns to Status::Idle. A deliberate cancel is not counted as a failure against the BackoffPolicy โ€” the streak is left untouched and the flow never trips, so its next scheduled run fires normally. Cancelling a flow that isn't currently running is an idempotent no-op.

// Stop the in-flight run of a flow; its saga rolls back and the flow goes Idle.
running.cancel_flow("order").await?;

Runnable example: cargo run --example scheduler_cancellation --features scheduler โ€” triggers a saga, cancels it mid-flight, and watches the compensators roll back in reverse.


Graceful Shutdown

When stop() is called, the scheduler signals all scheduling loops to stop and then cooperatively cancels every in-flight flow โ€” interval, cron, and manual alike โ€” the same way cancel_flow does: each running workflow aborts at its next await and drains its saga before the scheduler runs resource teardown (reverse registration order) and returns. Shutdown latency is therefore bounded by the time to the next await plus the compensation drain, not by how long the workflows would naturally take.

// Stop the scheduler: in-flight flows are cancelled + rolled back, then teardown runs.
running.stop().await?;

A bounded wait (up to 30 seconds) caps the drain; a second stop() after success is idempotent โ€” it returns the same cached result.

Runnable example: cargo run --example scheduler_graceful_shutdown --features scheduler โ€” spawns a Ctrl-C handler, runs scheduled flows, and shuts down cleanly on signal.


Advanced Pattern: Multi-Level Map-Reduce

The scheduler composes naturally with split/join to give you a two-level map-reduce. Level 1 lives inside a single workflow: a state registered with register_split fans a batch out across parallel tasks, and a JoinConfig reduces their results back into one summary state. Level 2 lives at the scheduler: register several of those batch workflows โ€” each with different parameters (a different batch of records, a different region, a different tenant) โ€” as manual flows or interval flows, and trigger them concurrently. Because every workflow carries its own Resources dictionary, you hand each one a shared accumulator (an Arc<RwLock<โ€ฆ>> wrapped in a Resource); every batch independently appends its summary, and a final reduce step folds them together.

  • Map (level 1) โ€” register_split runs N tasks over a batch in parallel.
  • Reduce (level 1) โ€” JoinConfig (e.g. JoinStrategy::All or Percentage(0.75)) merges the parallel results into a single batch summary.
  • Map (level 2) โ€” the scheduler holds several batch workflows and fires them concurrently via trigger (or on intervals), each with its own parameters and Resources.
  • Reduce (level 2) โ€” a shared accumulator resource collects every batch summary; once all flows finish, a final pass aggregates across batches.

The skeleton โ€” one batch workflow with a parallel state, plus a scheduler wiring up a couple of batches:

// Level 1: a workflow that map-reduces over one batch.
fn batch_workflow(batch: Vec<Item>, results: SharedResults) -> Workflow<State> {
    Workflow::new(Resources::new().insert("results", results))
        .register_split(
            State::Process,
            batch.iter().map(|item| ProcessTask::new(item)).collect::<Vec<_>>(),
            JoinConfig::new(JoinStrategy::All, State::Summarize)
                .with_timeout(Duration::from_secs(60)),
        )
        .register(State::Summarize, SummarizeTask) // appends a batch summary into `results`
        .add_exit_states(vec![State::Done, State::Error])
}

// Level 2: the scheduler runs several batch workflows concurrently.
let results = SharedResults::default();
let mut scheduler = Scheduler::new();
scheduler.manual("batch-a", batch_workflow(batch_a, results.clone()), State::Start)?;
scheduler.manual("batch-b", batch_workflow(batch_b, results.clone()), State::Start)?;
let running = scheduler.start().await?;
running.trigger("batch-a").await?;
running.trigger("batch-b").await?;
// ...wait for both flows to finish, then reduce across all batch summaries in `results`.

The full runnable program is examples/scheduler_mapreduce_books.rs โ€” cargo run --example scheduler_mapreduce_books --features scheduler. It downloads several books from Project Gutenberg, splits download + analysis across parallel tasks within each batch workflow, runs multiple batch workflows concurrently, and reduces all results into global rankings. See also examples/scheduler_book_prepositions.rs for the single-workflow variant.

to navigate to open esc to close