5 min readResonate HQ

Coming from Temporal: Recursive Factorial in Go

Temporal's child-workflow mechanism collapses into one registered function that calls itself by name via ctx.RPC. No separate child type, no ContinueAsNew, no per-child options.

Resonate brand card on a dark background with an ember spectrum wave at the lower left, the headline 'Recursive Factorial' in white Sansation, and the subtitle 'Coming from Temporal · Go SDK'.

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-40

Name 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-31

The 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-43

r.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

TemporalResonate
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 typesOne function; it calls itself

Porting it

  1. 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.

  2. Replace ExecuteChildWorkflow(...).Get(ctx, &r) with ctx.RPC + f.Await. ctx.RPC(Name, Args{N: args.N-1}) returns (Future, error). Call f.Await(&sub) where you previously called .Get(ctx, &result).

  3. Drop WithChildOptions and ChildWorkflowOptions. There are no per-recursive-call options to configure. The registered name is the routing key; the worker group is the dispatch scope.

  4. Register only on the worker; do not register on the client. The client binary constructs resonate.New with just a URL and calls r.RPC to invoke by name. If the client joined the worker group without registering Factorial, the server could dispatch recursive steps to it and fail with "function not found."

  5. Drop ContinueAsNew entirely. If you were using child-workflow-continue-as-new to bound history depth, there is nothing to replace. Resonate does not build per-execution event history; each ctx.RPC call creates an independent durable promise, so depth does not accumulate in a structure that needs to be bounded.

  6. Choose a stable root ID. The client uses fmt.Sprintf("factorial-%d", *n) — invoking the client again with the same -n returns 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.Run for wrapping side effects. Those shapes are covered in the Async RPC brief.
  • No @workflow/@activity split. Resonate has no decorator-based distinction between workflow functions and activity functions. resonate.Register makes 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 with ChildWorkflowOptions.WorkflowID.
  • Pre-release SDK. resonate-sdk-go has no semver tag yet. This example pins to a specific commit; expect API changes before v0.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.Run calls.

Sources