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:1The 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:4The 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— registersfactorialas 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 thefactorial-workergroup and pins the durable promise ID tofactorial-{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 promisefactorial-{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 forfactorial-{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 6twice produces the same promise IDfactorial-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)afterfactorial(5)has resolved means thefactorial-5promise is already terminal — the recursion short-circuits atn=5and onlyfactorial-6 … factorial-8execute. 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
- Example repo: https://github.com/resonatehq-examples/example-recursive-factorial-py
- Python SDK repo: https://github.com/resonatehq/resonate-sdk-py (example pins
resonate-sdk>=0.6.7inpyproject.toml; SDK source links below are pinned to tagv0.6.7so cited line numbers stay stable) Resonate.remoteconstructor: https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/resonate.py#L195 (def remote, line 195)Context.rpc(RFC entry point, alias forctx.rfc): https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/resonate.py#L917 (def rpc, overloads at lines 917, 924, 930)RFX.options(target=..., id=...)onRFC/RFI: https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/coroutine.py#L89 (def options, line 89;RFIat line 126,RFCat line 131)- SDK removal of
Resonate.remote/Resonate.localonmain(compat note): https://github.com/resonatehq/resonate-sdk-py/blob/main/tests/test_auth.py#L255
