4 min readResonate HQJust published

Recursive durable factorial in Python on Resonate

How a self-calling function stays durable and content-addressed when every invocation is its own durable promise.

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

A workflow that needs to call itself is awkward on durable-execution stacks that split code into "workflow" and "activity" tiers — each recursive step bloats the event history and adds network round-trips. Resonate collapses that split: every function invocation is a durable promise, so a function can recurse into itself with no extra primitives, and a promise ID chosen from the input makes the result content-addressed and reusable across runs. This example computes factorial(n) by having a factorial function rpc itself for factorial(n-1), distributed across one or more worker processes.

The shape of the solution

from resonate import Resonate
from threading import Event
 
resonate = Resonate.remote(
    group="factorial-worker",
)
 
@resonate.register
def factorial(ctx, n):
    print(f"Calculating factorial of {n}")
    if n <= 1:
        return 1
    result = yield ctx.rpc("factorial", n - 1).options(target="poll://any@factorial-worker", id=f"factorial-{n-1}")
    return n * result
 
def main():
    resonate.start()
    print("Factorial worker is running...")
    Event().wait()
 
if __name__ == "__main__":
    main()
 
# from example-recursive-factorial-py/factorial_worker.py:1

The client side is the same pattern, invoked with a deterministic promise ID:

resonate = Resonate.remote(
    group="factorial-client",
)
 
# ...
promise_id = f"factorial-{n}"
result = resonate.options(target="poll://any@factorial-worker").rpc(promise_id, "factorial", n=n)
 
# from example-recursive-factorial-py/factorial_client.py:4

The durable primitives in play

  • Resonate.remote(group=...) — constructs a client/worker that connects to a Resonate server as both promise store and message source; workers claim tasks routed to their group. (factorial_worker.py:4, factorial_client.py:4)
  • @resonate.register — registers factorial as a durable function recoverable by name "factorial" so any worker can pick up an invocation. (factorial_worker.py:8)
  • ctx.rpc("factorial", n - 1) — a Remote Function Invocation (RFI/RFC) issued from inside a durable function; yields control until the callee's promise resolves. The string form is a name reference, so the recursive call doesn't require holding a Python reference to the function. (factorial_worker.py:13)
  • .options(target="poll://any@factorial-worker", id=f"factorial-{n-1}") — routes the recursive invocation to any member of the factorial-worker group and pins the durable promise ID to factorial-{n-1}, making the result content-addressed by input. (factorial_worker.py:13)
  • resonate.options(target=...).rpc(promise_id, "factorial", n=n) — the client-side blocking RPC entry point: it creates (or returns) the durable promise factorial-{n} and waits for its terminal value. (factorial_client.py:16)
  • resonate.start() — boots the worker's event loop and begins claiming tasks routed to its group. (factorial_worker.py:17)

What the SDK handles vs. what you write

You write a plain Python generator function that recurses by yielding ctx.rpc(...).options(...). You pick the promise ID. That is the whole programming model.

The SDK and server handle: creating a durable promise per invocation; persisting it in PENDING until the worker resolves or rejects it; routing the invocation to a worker in the named group via the poll://any@factorial-worker target; deduplicating on promise ID, so a second call with id factorial-5 does not re-run factorial(5) and instead returns the stored value; replaying the parent function on worker crash by walking through its yielded children and short-circuiting any whose durable promise is already resolved; and propagating the child's resolved value back into the parent's generator so result = yield ctx.rpc(...) resumes with the answer.

The factorial body has no retry loop, no cache layer, no checkpoint calls, no event-history bookkeeping. The id=f"factorial-{n-1}" is the whole caching mechanism — the durable promise is a write-once register, so once factorial-5 resolves, every later invocation with the same ID gets the cached result directly from the store.

Failure modes covered

  • Worker crash mid-recursion. When a worker dies after yielding ctx.rpc("factorial", n-1) but before the child resolves, the server reassigns the parent invocation. On resume, the parent generator replays; the yield for factorial-{n-1} resolves immediately if its promise is already terminal, otherwise the parent waits as before. The user-supplied promise ID is what makes the replay deterministic. (factorial_worker.py:13)
  • Duplicate top-level invocation. Running factorial_client.py 6 twice produces the same promise ID factorial-6. The second call attaches to the existing durable promise; if the first run completed, the second returns the stored value with no re-execution. (factorial_client.py:15)
  • Recomputation of already-known sub-results. Computing factorial(8) after factorial(5) has resolved means the factorial-5 promise is already terminal — the recursion short-circuits at n=5 and only factorial-6 … factorial-8 execute. The README calls this out as the observable behavior: re-running the same number, or any smaller number, is "almost instant". (README.md:85–89)
  • Worker pool elasticity. target="poll://any@factorial-worker" lets any healthy worker in the group claim each recursive step. Removing a worker mid-run does not strand work — the server reissues claims to surviving workers. (factorial_worker.py:13)

SDK compatibility note

The example code calls Resonate.remote(group=...) as the constructor. That classmethod exists on the pinned SDK release resonate-sdk==0.6.7 (resonate/resonate.py:195 at tag v0.6.7) and the example runs as-shipped against current PyPI. On the SDK's main branch the Resonate.remote and Resonate.local classmethods have been removed in favor of constructing Resonate(...) directly — the SDK test suite asserts the removal at tests/test_auth.py:255–257. Agents reading this post against a post-0.6.7 SDK build should expect to replace Resonate.remote(group=...) with Resonate(group=...); the surrounding @resonate.register + ctx.rpc(...).options(...) surface is unchanged.

When to reach for this pattern

  • If you have a function that naturally recurses (tree walks, divide-and-conquer, dependency resolution) and you want each sub-result to survive a crash without writing checkpoint code.
  • If you want a memoization cache that is durable, distributed, and shared across all workers and clients with zero cache-invalidation logic — because the inputs deterministically derive the promise ID.
  • If sub-computations are expensive enough that you want to amortize them across runs, requests, or processes by content-addressing the result.
  • If you need to spread recursive work across a worker pool rather than blocking a single process, and want at-least-once dispatch with deduplication on promise ID.
  • If you want to avoid the workflow/activity split that other durable stacks impose, and write the recursion as plain language code.

Sources