Cron-shaped scheduling with durable executions
Register a function to run on a cron schedule. Each invocation is a durable workflow with crash recovery built in.
Register a function to run on a cron expression and Resonate handles the rest. Every fired execution is a durable workflow — a worker that crashes mid-run hands off to a survivor, and a missed run because all workers were down still gets dispatched when one comes back.
TypeScript: @resonatehq/sdk v0.10.1 (current). Python: resonate-sdk v0.6.x against the legacy Resonate Server. Rust: v0.1.0, in active development.
Cron-scheduled report generator with a separate worker process.
Cron-scheduled report generator with a separate worker process.
Cron-scheduled report generator using resonate.schedule() with a separate worker.
The problem#
Cron is fine for "fire a script at a time," but stops there. If the script needs to retry, recover from a crash, dedup against double-fires, or coordinate across multiple machines, you end up wrapping cron in increasingly elaborate scaffolding — usually a queue, a separate worker, and a state table to track which runs completed.
Resonate's solution#
Tell the Resonate Server "run this function on this cron schedule." It dispatches each run as a durable workflow to whichever worker in the group claims it. The worker doesn't care about the schedule — it just runs the function, checkpoints each step, and recovers automatically if anything fails. Schedules persist across server restarts.
Code walkthrough#
Two scripts: a one-shot schedule registration that tells the server about the cron expression, and a worker that executes whatever the schedule fires.
Register the schedule#
import { Resonate } from "@resonatehq/sdk";
import { generateReport } from "./report";
const resonate = new Resonate({ url: "http://localhost:8001" });
resonate.register("generateReport", generateReport);
await resonate.schedule(
"daily_report", // schedule ID — re-running this script is a no-op once it exists
"* * * * *", // cron: every minute (use "0 9 * * *" for daily 9am)
generateReport,
123, // userId argument
);
console.log("Schedule created. Start the worker to process executions.");
resonate.stop();from resonate import Resonate
from report import generate_report
resonate = Resonate(url="http://localhost:8001")
resonate.register(generate_report)
resonate.schedule(
"daily_report", # schedule ID
generate_report,
"* * * * *", # cron: every minute
user_id=123,
)
print("Schedule created. Start the worker to process executions.")use example_schedule_rs::generate_report;
use resonate::prelude::*;
#[tokio::main]
async fn main() {
let resonate = Resonate::new(ResonateConfig {
url: Some("http://localhost:8001".into()),
..Default::default()
});
resonate
.schedule("daily_report", "* * * * *", "generate_report", 123u64)
.await
.expect("schedule create failed");
println!("Schedule created. Start the worker to process executions.");
}The schedule lives on the server, not in the worker. You can run the registration script once and the schedule keeps firing across worker restarts and redeploys. Re-running the script with the same ID is a no-op.
Run the worker#
import { Resonate } from "@resonatehq/sdk";
import { generateReport } from "./report";
const resonate = new Resonate({ url: "http://localhost:8001" });
resonate.register("generateReport", generateReport);
console.log("Worker started. Waiting for scheduled executions...");from resonate import Resonate
from report import generate_report
resonate = Resonate(url="http://localhost:8001")
resonate.register(generate_report)
print("Worker started. Waiting for scheduled executions...")
resonate.start()use example_schedule_rs::generate_report;
use resonate::prelude::*;
#[tokio::main]
async fn main() {
let resonate = Resonate::new(ResonateConfig {
url: Some("http://localhost:8001".into()),
..Default::default()
});
resonate.register(generate_report).unwrap();
println!("Worker started. Waiting for scheduled executions...");
tokio::signal::ctrl_c().await.unwrap();
}The worker is identical to any other Resonate worker — it doesn't know it's being driven by a schedule. The same function can be invoked by hand via resonate invoke or scheduled cron-style; both paths are durable executions.
Run it locally#
git clone https://github.com/resonatehq-examples/example-schedule-ts
cd example-schedule-ts
npm installbrew install resonatehq/tap/resonate
resonate devnpx tsx src/schedule.tsnpx tsx src/worker.tsThe worker prints a log line every minute as the schedule fires. Kill it for a few minutes and restart — runs that fired while it was down are dispatched as soon as it reconnects.
git clone https://github.com/resonatehq-examples/example-schedule-py
cd example-schedule-py
uv syncbrew install resonatehq/tap/resonate
resonate serveuv run python schedule.pyuv run python worker.pyThe worker prints a log line every minute as the schedule fires. Kill it for a few minutes and restart — runs that fired while it was down are dispatched as soon as it reconnects.
git clone https://github.com/resonatehq-examples/example-schedule-rs
cd example-schedule-rs
cargo buildbrew install resonatehq/tap/resonate
resonate devcargo run --bin schedulecargo run --bin workerTry the catch-up story#
Stop the worker for a few minutes. Schedule fires keep accumulating on the server. When you restart the worker, the queued runs dispatch in order — none are lost. Now bring up two workers in the same group and watch fires distribute across them.
Related#
- Load balancing — same group dispatch, focused on parallel work.
- Durable sleep — the in-workflow analogue of cron, for delays inside a single execution.