4 min readResonate HQJust published

Recursive workflow with cached results in Rust on Resonate

How a single recursive function plus durable-promise IDs gives you distributed computation and a permanent result cache in one shape.

Resonate brand card on a dark background with an ember spectrum wave at the bottom and the post headline in white Sansation.

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