If you know how Temporal's child-workflow Go sample works and you want to see what changes in Resonate, this is the shortest possible diff. Temporal models recursive invocation through a dedicated child-workflow primitive — a separate execution with its own history, its own ID options, and (for unbounded loops) a ContinueAsNew escape hatch to keep history bounded. Resonate models the same shape as a registered function that calls itself by name through ctx.RPC; each recursive call is a durable promise on the server.
Temporal (samples-go/child-workflow)
The parent workflow wraps child options onto the context, then executes a named child and blocks on the result:
func SampleParentWorkflow(ctx workflow.Context) (string, error) {
cwo := workflow.ChildWorkflowOptions{
WorkflowID: "ABC-SIMPLE-CHILD-WORKFLOW-ID",
}
ctx = workflow.WithChildOptions(ctx, cwo)
var result string
err := workflow.ExecuteChildWorkflow(ctx, SampleChildWorkflow, "World").Get(ctx, &result)
if err != nil {
return "", err
}
return result, nil
}SampleChildWorkflow is a separate registered workflow type. For unbounded recursion — where each iteration must not grow the parent's history — the child-workflow-continue-as-new sample shows the pattern:
func SampleChildWorkflow(ctx workflow.Context, totalCount, runCount int) (string, error) {
totalCount++
runCount--
if runCount == 0 {
return fmt.Sprintf("Child workflow execution completed after %v runs", totalCount), nil
}
return "", workflow.NewContinueAsNewError(ctx, SampleChildWorkflow, totalCount, runCount)
}Each iteration closes the current execution and opens a fresh one, keeping history bounded. Without this, a deeply recursive workflow would exhaust Temporal's per-execution history limit.
Resonate (example-recursive-factorial-go)
The workflow function calls itself through ctx.RPC, passing its own registered name as the target:
func Workflow(ctx *resonate.Context, args Args) (int, error) {
if args.N <= 1 {
return 1, nil
}
f, err := ctx.RPC(Name, Args{N: args.N - 1})
if err != nil {
return 0, err
}
var sub int
if err := f.Await(&sub); err != nil {
return 0, err
}
return args.N * sub, nil
}
// from example-recursive-factorial-go/factorial/factorial.go:27-40Name is the package constant "Factorial" (line 11 of factorial.go). ctx.RPC(Name, args) creates a durable promise on the server for factorial(n-1) and returns a future. f.Await(&sub) blocks the current execution until that promise settles.
The worker registers the function into a named group so that the server dispatches recursive steps only to processes that have Factorial registered:
network := httpnet.NewHTTP("http://localhost:8001", httpnet.HTTPOptions{
Group: factorial.WorkerGroup,
})
r, err := resonate.New(resonate.Config{Network: network})
// ...
if _, err := resonate.Register(r, factorial.Name, factorial.Workflow); err != nil {
log.Fatalf("Register: %v", err)
}
// from example-recursive-factorial-go/cmd/worker/main.go:21-31The client invokes by registered name, targeting the same group. It does not call Register:
id := fmt.Sprintf("factorial-%d", *n)
h, err := r.RPC(ctx, id, factorial.Name, factorial.Args{N: *n},
resonate.RPCOptions{Target: factorial.WorkerGroup})
// ...
var result int
if err := h.Result(ctx, &result); err != nil {
log.Fatalf("Result: %v", err)
}
fmt.Printf("factorial(%d) = %d\n", *n, result)
// from example-recursive-factorial-go/cmd/client/main.go:29-43r.RPC on the client creates the root durable promise. h.Result(ctx, &result) blocks until the full recursion tree settles and returns the typed value.
What maps to what
| Temporal | Resonate |
|---|---|
workflow.ExecuteChildWorkflow(ctx, Fn, args) | ctx.RPC(Name, args) — uses the registered string name |
.Get(ctx, &result) | f.Await(&result) — blocks until the sub-call promise settles |
workflow.ChildWorkflowOptions{WorkflowID: id} | ID is managed automatically by the SDK for recursive steps; root ID is the string passed to r.RPC |
workflow.WithChildOptions(ctx, cwo) | Not needed — options are passed inline to ctx.RPC / r.RPC |
workflow.NewContinueAsNewError(...) | Not needed — no per-execution history accumulates |
| Task queue (declared on worker, targeted in child options) | Worker group (factorial.WorkerGroup) via httpnet.HTTPOptions{Group: …} and RPCOptions{Target: …} |
w.RegisterWorkflow(Fn) + w.RegisterActivity(Fn) | resonate.Register(r, Name, Fn) — one registration, no type distinction |
| Separate parent and child workflow types | One function; it calls itself |
Porting it
-
Delete the parent/child type split. Write one
func(_ *resonate.Context, args T) (R, error). There is no separate child workflow type, no separate registration step for the recursive call. -
Replace
ExecuteChildWorkflow(...).Get(ctx, &r)withctx.RPC+f.Await.ctx.RPC(Name, Args{N: args.N-1})returns(Future, error). Callf.Await(&sub)where you previously called.Get(ctx, &result). -
Drop
WithChildOptionsandChildWorkflowOptions. There are no per-recursive-call options to configure. The registered name is the routing key; the worker group is the dispatch scope. -
Register only on the worker; do not register on the client. The client binary constructs
resonate.Newwith just aURLand callsr.RPCto invoke by name. If the client joined the worker group without registeringFactorial, the server could dispatch recursive steps to it and fail with "function not found." -
Drop ContinueAsNew entirely. If you were using
child-workflow-continue-as-newto bound history depth, there is nothing to replace. Resonate does not build per-execution event history; eachctx.RPCcall creates an independent durable promise, so depth does not accumulate in a structure that needs to be bounded. -
Choose a stable root ID. The client uses
fmt.Sprintf("factorial-%d", *n)— invoking the client again with the same-nreturns the cached settled result rather than recomputing. Make the ID meaningful to your domain so retries deduplicate automatically.
What's actually different
No event-history replay, no history-depth problem. Temporal's replay model stores the full event history of a workflow execution on the server. Each child workflow gets its own execution and its own history, which is why the pattern exists at all — a single unbounded execution would exhaust the history limit. Resonate does not replay event history to restore state. When a worker crashes, the server re-dispatches the unresolved durable promise to another worker; the function re-runs from the top of its body with already-settled child promises short-circuiting by ID. Because there is no accumulating event log, deep recursion does not create a history-size problem, and ContinueAsNew has no equivalent to port.
The tradeoff is different: Resonate gives you at-least-once re-execution of a function body after a crash rather than deterministic replay, so side effects in the function body need to be idempotent or guarded by additional durable promises (ctx.Run).
Worker groups vs. task queues. Temporal routes dispatches via task queues named at startup and referenced in activity and child-workflow options. Resonate uses a group: the worker joins factorial.WorkerGroup through httpnet.HTTPOptions, and the client targets the same string with RPCOptions{Target: factorial.WorkerGroup}. The key difference: recursive calls inside Workflow inherit the group from the worker's registration — no separate option is required at each call site.
Idempotency is the default. Temporal requires explicit WorkflowIDReusePolicy (or WorkflowIDConflictPolicy: UseExisting) to return the result of a duplicate ID. In Resonate a second r.RPC with an already-settled ID returns the cached result unconditionally. The client's fmt.Sprintf("factorial-%d", *n) relies on this: re-running with the same -n is free.
For related shapes — await chains, detached fan-out, fire-and-forget RPC — see the Async RPC brief in this corpus.
Notes & coverage
- This brief covers: a function calling itself via
ctx.RPC+f.Await; the worker/client binary split; worker groups as the dispatch scope; why ContinueAsNew has no equivalent. - Not covered here: fan-out patterns where multiple children are dispatched and awaited concurrently; detached execution;
ctx.Runfor wrapping side effects. Those shapes are covered in the Async RPC brief. - No
@workflow/@activitysplit. Resonate has no decorator-based distinction between workflow functions and activity functions.resonate.Registermakes any function durable. There are no timeout options required on the recursive call itself. - Promise ID namespace. The SDK generates child IDs worker-side automatically (visible at
http://localhost:8001). You do not manage them manually the way you would withChildWorkflowOptions.WorkflowID. - Pre-release SDK.
resonate-sdk-gohas no semver tag yet. This example pins to a specific commit; expect API changes beforev0.1.0. - At-least-once semantics. If a worker crashes after a side effect but before the enclosing durable promise settles, the side effect may run again. Design functions to be idempotent or confine mutations to their own
ctx.Runcalls.
Sources
- Example repo: github.com/resonatehq-examples/example-recursive-factorial-go
- Temporal sample (child-workflow): github.com/temporalio/samples-go/child-workflow
- Temporal sample (child-workflow-continue-as-new): github.com/temporalio/samples-go/child-workflow-continue-as-new
- Concept-level guide, all SDKs: docs.resonatehq.io/evaluate/coming-from/temporal
