Recursive durable factorial in Python on Resonate

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

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.7 in pyproject.toml; SDK source links below are pinned to tag v0.6.7 so cited line numbers stay stable)

Resonate.remote constructor: 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 for ctx.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=...) on RFC / RFI: https://github.com/resonatehq/resonate-sdk-py/blob/v0.6.7/resonate/coroutine.py#L89 (def options, line 89; RFI at line 126, RFC at line 131)

SDK removal of Resonate.remote / Resonate.local on main (compat note): https://github.com/resonatehq/resonate-sdk-py/blob/main/tests/test_auth.py#L255