Periodic function on a cron schedule in Rust on Resonate

A traditional cron job has no crash recovery: if the worker dies while running a tick, that tick is silently lost and there is no record on the server that it ever started. Resonate moves the cron expression onto the server, so every tick fires a new durable promise that any worker in the matching group can pick up — if the worker crashes while processing it, the promise is still there for the next worker. The example-schedule-rs repo shows this in three files: a function, a one-shot binary that installs the cron, and a long-running worker binary.

The shape of the solution

use resonate::prelude::*;

// ...

#[resonate::function]
pub async fn generate_report(user_id: u64) -> Result<String> {
let timestamp = chrono::Utc::now().to_rfc3339();
let report = format!("[{timestamp}] Report for user {user_id}");
println!("{report}");
Ok(report)
}
// from example-schedule-rs/src/lib.rs:4-18

That is the entire workflow body. Result here is resonate::prelude::Result, re-exported by the SDK. There is no ctx parameter, no ctx.run, no checkpoint inside the function — the durability boundary is around the function, not inside it. Each cron tick produces a fresh durable promise whose result is whatever this function returns (or the error it raises).

The cron itself is installed once by a separate binary:

let resonate = Resonate::new(ResonateConfig {
url: Some("http://localhost:8001".into()),
group: Some("workers".into()),
..Default::default()
});

resonate.register(generate_report).unwrap();

// Schedule generate_report to run every minute.
// Change the cron expression to "0 9 * * *" for daily at 9am, etc.
let result = resonate
.schedule(
"daily_report", // schedule ID
"* * * * *", // cron: every minute
"generate_report", // function name (matches #[resonate::function])
123_u64, // user_id argument
)
.await;
// from example-schedule-rs/src/bin/schedule.rs:11-28

The conflict case is handled by name: re-running this binary returns a 40901 server error, which the example matches explicitly and treats as success:

match result {
Ok(_) => println!("Schedule created. Start the worker to process executions."),
Err(Error::ServerError { code: 40901, .. }) => {
println!("Schedule already exists. Start the worker to process executions.");
}
Err(e) => {
eprintln!("Failed to create schedule: {e}");
std::process::exit(1);
}
}
// from example-schedule-rs/src/bin/schedule.rs:30-39

The worker binary is the long-running half. It binds to the same workers group, registers the same function under the same name, then blocks on ctrl_c:

let resonate = Resonate::new(ResonateConfig {
url: Some("http://localhost:8001".into()),
group: Some("workers".into()),
..Default::default()
});

resonate.register(generate_report).unwrap();

println!("Worker started. Waiting for scheduled executions...");
tokio::signal::ctrl_c()
.await
.expect("Failed to listen for ctrl-c");

resonate.stop().await.ok();
// from example-schedule-rs/src/bin/worker.rs:10-23

The worker never sees the cron expression. It polls for tasks targeted at the workers group; the server pushes one task per tick.

The durable primitives in play

resonate.schedule(name, cron, func_name, args) — creates a server-side cron entry that, on every tick, creates a new durable promise whose execution is targeted at the registered function. Returns a ResonateSchedule handle whose only operation is .delete(). Source: src/bin/schedule.rs:21-28; SDK builder at resonate/src/resonate.rs:520-536; IntoFuture impl that does the wire call at resonate/src/resonate.rs:1009-1046.

#[resonate::function] + resonate.register(generate_report) — registers the function under the name "generate_report" so the schedule can dispatch it by string. Source: src/lib.rs:12, src/bin/schedule.rs:17, src/bin/worker.rs:16; SDK at resonate/src/resonate.rs:293-303.

Resonate::new(ResonateConfig { group: Some("workers".into()), .. }) — binds the process to a named worker group. The schedule is installed against the same group string, which is how the server knows where to dispatch each tick's task. Source: src/bin/schedule.rs:12-14, src/bin/worker.rs:11-14; SDK config at resonate/src/resonate.rs:32-49.

Per-tick durable promise — implicit, not in the example's source. Each tick produces a promise whose ID is templated as {prefix}{schedule_id}.{timestamp} (resonate/src/resonate.rs:1027) with the default 24-hour timeout (resonate/src/options.rs:22). The result of the function call is recorded against this promise. The semantic substitution: .id resolves to the schedule name, .timestamp to the tick time.

What the SDK handles vs. what you write

You write the function body, the cron expression, the schedule name, and the function arguments. That is the surface.

The SDK and the Resonate Server handle: parsing the cron expression and computing tick times, creating a fresh durable promise per tick with a unique ID derived from the schedule ID and the tick timestamp, queueing a task against the workers group for that promise, holding the promise open until a worker acquires the task, retrying the dispatch if the acquiring worker crashes or its lease expires, and recording the function's result (or error) against the promise so the execution is auditable after the fact. None of that lives in this repo's code — the async fn main body in the worker binary is 16 lines, and 5 of them are the Resonate::new config block.

The cron itself is also durable: the schedule is a server-side row, not a tokio::spawn loop inside any of the binaries. Killing every worker leaves the schedule intact; killing the cargo run --bin schedule process after the schedule is created leaves the schedule intact. The only thing that runs continuously in this example is the worker, and the worker exists to process ticks, not to fire them.

Failure modes covered

Worker crashes between a tick firing and the function returning. The tick is a durable promise on the server, not a future on the worker's heap. On worker restart (or on another worker in the workers group polling), the task is re-acquired and the function runs again from the top — there is no mid-execution checkpoint inside this function, so "retry" is a fresh call with the same arguments. The README states this explicitly: "If the worker crashes while processing it, Resonate retries automatically. No lost ticks, no manual recovery logic." (README.md:17).

Schedule already exists on re-run. The schedule-installer binary is idempotent on its name: a second cargo run --bin schedule returns server code 40901, which is matched as a non-fatal branch at src/bin/schedule.rs:32-34 and reported as "Schedule already exists." Re-running is safe.

No worker running when a tick fires. The promise is created on the server regardless of whether anyone is polling. When a worker in the workers group eventually comes online and polls, queued task(s) are handed to it. The repo does not write this case directly, but it is a property of installing the cron against a group rather than against a process.

The function returns Err(...). The error is recorded against the per-tick promise as a rejection. The next tick fires a new promise — schedule liveness does not depend on prior ticks succeeding.

The function in this example does not itself fail and does no I/O beyond println!, so the "what if the side effect double-runs" question is not relevant to this code as written. If the function did call a non-idempotent external system, the user would need to scope that call inside a ctx.run(...) with an idempotency key; this example does not show that and the post does not claim it.

When to reach for this pattern

If you have a job that must run on a cron expression and you cannot tolerate a tick being silently dropped when the worker hosting it dies.

If you want the cron itself to survive worker restarts and deploys, without standing up a separate scheduler service.

If multiple workers should be able to share the load of cron-fired work — installing the schedule against a group lets any worker in that group acquire any tick.

If you want each tick to be an auditable durable promise with a stable ID derived from the schedule name and tick timestamp, rather than a fire-and-forget invocation.

If you would otherwise wire up an external scheduler, an at-least-once delivery layer, and a retry queue purely to get crash-safe cron — and the only reason for those three systems is that cron alone has no durability story.

Sources

Example repo: github.com/resonatehq-examples/example-schedule-rs

Rust SDK repo: github.com/resonatehq/resonate-sdk-rs

SDK primitives cited:

resonate/src/resonate.rs — Resonate::new, Resonate::register, Resonate::schedule, ResScheduleTask, ResonateSchedule

resonate/src/promises.rs — Schedules::create

resonate/src/options.rs — default per-tick promise timeout (24h)

Docs:

Schedule example

Schedules concept

Rust SDK guide