A recursive computation like factorial(n) is the simplest case where you want a function to call itself, have each call survive worker crashes, spread the work across multiple workers, and never recompute a result you've already produced. Resonate gives this shape by making every function invocation pair with a durable promise: the client invokes the function with a promise ID keyed by n, the function calls itself via ctx.rpc, and Resonate's child-ID scheme propagates the cache key into the recursive subtree so resolved promises become a permanent result cache. This example is a single factorial function plus a client that invokes it; running multiple workers shows the recursion spread across them, and rerunning a previously computed n returns the cached result.
The shape of the solution
use resonate::prelude::*;
#[resonate::function]
async fn factorial(ctx: &Context, n: u64) -> Result<u64> {
println!("Calculating factorial({n})");
if n <= 1 {
return Ok(1);
}
let result: u64 = ctx
.rpc("factorial", n - 1)
.target("poll://any@factorial-workers")
.await?;
Ok(n * result)
}
// from example-recursive-factorial-rs/src/bin/worker.rs:1, 13-26The function is annotated #[resonate::function] and registered with resonate.register(factorial).unwrap(); on the worker. There is no separate "workflow" function and "activity" function — the same function is both the orchestrator and the unit of recursion.
The durable primitives in play
#[resonate::function]— attribute macro that makes the annotatedasync fninvocable as a Resonate function; each invocation pairs with a durable promise. (src/bin/worker.rs:13)resonate.register(factorial)— registers the function on the worker so the worker group can serve invocations of it. (src/bin/worker.rs:36)ctx.rpc("factorial", n - 1)— Remote Function Invocation from inside the function; creates a new durable promise for the recursive call and awaits its resolution. (src/bin/worker.rs:21).target("poll://any@factorial-workers")— routes the RPC to any worker in thefactorial-workersgroup; the next recursive step can land on a different worker. (src/bin/worker.rs:22)resonate.rpc(&promise_id, "factorial", n)withpromise_id = format!("factorial-{n}")— the client supplies an explicit promise ID, so the top-level invocation is keyed bynand becomes the cache entry for that input. (src/bin/client.rs:18, 20-24).target("poll://any@factorial-workers")on the client call — routes the client's top-level RPC to a worker in thefactorial-workersgroup; without this the invocation has nowhere to land. (src/bin/client.rs:22)ResonateConfig { group: Some("factorial-workers".into()), .. }— the worker binds itself to a named group so RPCs targeted atpoll://any@factorial-workersreach it. (src/bin/worker.rs:30-34)
What the SDK handles vs. what you write
You write: one async fn factorial(ctx: &Context, n: u64) -> Result<u64> with a base case and a single recursive RPC. You write the promise-ID scheme on the client (factorial-{n}), and the target string (poll://any@factorial-workers) on the recursive call. You write the worker main that constructs Resonate::new(...), registers the function, and blocks on ctrl_c. That is the entire application — two binaries totalling under 70 lines of code (src/bin/worker.rs, src/bin/client.rs).
The SDK handles: creating a durable promise per invocation; persisting the promise to the Resonate server so a crash mid-computation doesn't lose progress; routing the RPC to a worker in the targeted group; awaiting the recursive promise's resolution; replaying the function on restart by reading the promise state rather than redoing side effects; and — load-bearing for this example — returning the stored value immediately when an invocation arrives with a promise ID that is already RESOLVED. There is no application-level cache, no cache invalidation, no event history bookkeeping in your code.
Failure modes covered
- Worker crashes mid-recursion. Each recursive
ctx.rpccall has already created a durable promise on the Resonate server (src/bin/worker.rs:21). When a worker restarts (or another worker in thefactorial-workersgroup picks up the invocation), it reads the promise state instead of recomputing. The completed sub-results stay resolved. - Repeat invocation with the same input. The client uses
promise_id = format!("factorial-{n}")(src/bin/client.rs:18). Runningcargo run --bin client 6a second time hits the same promise ID; because durable promises are write-once, the resolved value is returned without re-enteringfactorial(README §"About this example" lines 87-95). - Partial overlap between runs. Calling
factorial(8)and laterfactorial(5)shares thefactorial-5,factorial-4, … sub-promises. The recursivectx.rpc("factorial", n - 1)call inside the function does not include a user-supplied ID, but Resonate's deterministic ID scheme for child RPCs (combined with the example's promise-ID convention) means previously computed subtrees are not recomputed (README §"About this example" line 95). - Uneven worker availability.
target("poll://any@factorial-workers")(src/bin/worker.rs:22) means any healthy worker in the group can pick up the next recursive step; a worker going down does not block the chain as long as one peer remains.
When to reach for this pattern
- If a function naturally recurses on its input and each sub-result is expensive enough that you'd otherwise reach for a memoization table or Redis cache.
- If you want distributed computation without writing a coordinator: define one function, run multiple workers, target them via
poll://any@<group>so each recursive step lands on any free worker. - If the inputs to a function form a stable key space (
factorial-{n},render-{document_id}-{revision},score-{model_version}-{example_id}) so promise IDs can be assigned by input. - If results should be permanently cached — durable promises are write-once and never expire from the server's perspective, so the "cache" is the system of record.
- If you previously modeled this as a workflow function calling an activity function calling a workflow function, and the resulting code is hard to read. Collapsing it to one recursive function is the alternative this example demonstrates.
Sources
- Example repo: https://github.com/resonatehq-examples/example-recursive-factorial-rs
- Resonate Rust SDK: https://github.com/resonatehq/resonate-sdk-rs (the example pins
resonate-sdktobranch = "main"inCargo.toml:17) - Worker source:
src/bin/worker.rs(thefactorialfunction, registration, and worker group config) - Client source:
src/bin/client.rs(top-level invocation withpromise_id = factorial-{n}) - Pattern docs: https://docs.resonatehq.io/get-started/examples/recursive-factorial
- Rust SDK guide: https://docs.resonatehq.io/develop/rust
