Recursive workflow with cached results in Rust on Resonate

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-26

The 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 annotated async fn invocable 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 the factorial-workers group; the next recursive step can land on a different worker. (src/bin/worker.rs:22)

resonate.rpc(&promise_id, "factorial", n) with promise_id = format!("factorial-{n}") — the client supplies an explicit promise ID, so the top-level invocation is keyed by n and 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 the factorial-workers group; 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 at poll://any@factorial-workers reach 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.rpc call has already created a durable promise on the Resonate server (src/bin/worker.rs:21). When a worker restarts (or another worker in the factorial-workers group 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). Running cargo run --bin client 6 a second time hits the same promise ID; because durable promises are write-once, the resolved value is returned without re-entering factorial (README §"About this example" lines 87-95).

Partial overlap between runs. Calling factorial(8) and later factorial(5) shares the factorial-5, factorial-4, … sub-promises. The recursive ctx.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-sdk to branch = "main" in Cargo.toml:17)

Worker source: src/bin/worker.rs (the factorial function, registration, and worker group config)

Client source: src/bin/client.rs (top-level invocation with promise_id = factorial-{n})

Pattern docs: https://docs.resonatehq.io/get-started/examples/recursive-factorial

Rust SDK guide: https://docs.resonatehq.io/develop/rust