4 min readResonate HQ

Coming from Temporal: Async RPC Patterns in Go

ctx.RPC + Await, ctx.Detached, and the two-loop fan-out — mapped from Temporal's child-workflow, ABANDON policy, and splitmerge-future.

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

If you know Temporal's child-workflow, the ParentClosePolicy: ABANDON pattern from batch-sliding-window, and splitmerge-future, this brief shows the Resonate equivalents side by side. All three patterns live in one file — example-async-rpc-go/main.go — so you can run any of them with a single -mode flag and no external server.

This is the RPC-shapes cheat-sheet. The fan-out pattern deliberately overlaps the sibling Fan-Out / Fan-In brief; that brief goes deeper on the two-loop idiom and aggregation trade-offs. The detached pattern's in-process analogue, ctx.Run, is covered in the Recursive Factorial brief.

Temporal (three shapes)

Shape 1 — await-chain (samples-go/child-workflow)

// parent_workflow.go
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)
    // ...
    return result, nil
}

ExecuteChildWorkflow(...).Get(ctx, &result) dispatches and blocks in one expression. The .Get is chained immediately, so the parent cannot move on until the child settles.

Shape 2 — detached (samples-go/batch-sliding-window, ABANDON policy)

// sliding_window_workflow.go (excerpt)
options := workflow.ChildWorkflowOptions{
    ParentClosePolicy: enumspb.PARENT_CLOSE_POLICY_ABANDON,
    WorkflowID:        fmt.Sprintf("%s/%d", workflowId, record.Id),
}
childCtx := workflow.WithChildOptions(ctx, options)
child := workflow.ExecuteChildWorkflow(childCtx, RecordProcessorWorkflow, record)
// child.GetChildWorkflowExecution().Get(ctx, nil) — wait for start, not completion

PARENT_CLOSE_POLICY_ABANDON keeps the child alive when the parent calls ContinueAsNew or returns. The parent can still call .GetChildWorkflowExecution().Get(ctx, nil) to confirm the child has started before proceeding; it holds a future but does not await the result. The PARENT_CLOSE_POLICY_ABANDON constant is imported from go.temporal.io/api/enums/v1.

Shape 3 — fan-out (samples-go/splitmerge-future)

// splitmerge_workflow.go
var results []workflow.Future
for i := 0; i < processorCount; i++ {
    future := workflow.ExecuteActivity(ctx, ChunkProcessingActivity, i+1)
    results = append(results, future)
}
for i := 0; i < processorCount; i++ {
    var result ChunkResult
    err := results[i].Get(ctx, &result)
    // ...
    totalSum += result.SumInChunk
}

Two loops: dispatch without blocking, then await each in order. ExecuteActivity returns a future immediately; .Get blocks on it in the second pass.

Resonate (example-async-rpc-go)

All three shapes register to the same echo leaf function and live in one file. Register everything before starting any workflow — ctx.Detached and ctx.RPC dispatch by name and must find the target registered in the same process when running on localnet.

Shape 1 — await-chain

func awaitChain(ctx *resonate.Context, args ChainArgs) (string, error) {
    var results [3]string
    for i := 0; i < 3; i++ {
        f, err := ctx.RPC("echo", EchoArgs{
            Message: fmt.Sprintf("%s (step %d)", args.Message, i+1),
            From:    "chain",
        })
        if err != nil {
            return "", fmt.Errorf("RPC step %d: %w", i+1, err)
        }
        if err := f.Await(&results[i]); err != nil {
            return "", fmt.Errorf("Await step %d: %w", i+1, err)
        }
    }
    return fmt.Sprintf("chain complete: %s | %s | %s", results[0], results[1], results[2]), nil
}
// from example-async-rpc-go/main.go:60-79

ctx.RPC returns a *resonate.Future. f.Await(&result) blocks the workflow on that promise — if the promise is pending, the runtime suspends (via panic/recover) and replays once it settles. The next ctx.RPC is not dispatched until Await returns.

Shape 2 — detached

func detachedWorkflow(ctx *resonate.Context, args DetachedArgs) (string, error) {
    childID, err := ctx.Detached("echo", EchoArgs{
        Message: args.Message,
        From:    "detached",
    }, resonate.DetachedOpts{Target: "default"})
    if err != nil {
        return "", fmt.Errorf("Detached: %w", err)
    }
    fmt.Printf("  [detached] dispatched child promise id=%s\n", childID)
    return fmt.Sprintf("detached child dispatched (id=%s)", childID), nil
}
// from example-async-rpc-go/main.go:117-131

ctx.Detached returns (string, error) — the string is the child promise ID, not a future. The parent has no handle to await. If you need to observe the child later, fetch it from outside the workflow: h, err := r.Get(ctx, childID), then h.Result(ctx, &out) (or resonate.ResultOf[T](ctx, h) for a typed result). DetachedOpts{Target: "default"} routes the child to the worker group named "default" — match this to the group name passed to localnet.NewLocal.

Shape 3 — fan-out

func fanout(ctx *resonate.Context, args FanoutArgs) (FanoutResult, error) {
    // Loop 1: dispatch all — do not await inside this loop.
    futures := make([]*resonate.Future, 0, len(args.Recipients))
    for _, r := range args.Recipients {
        f, err := ctx.RPC("echo", EchoArgs{
            Message: fmt.Sprintf("%s -> %s", args.Message, r),
            From:    "fanout",
        })
        if err != nil {
            return FanoutResult{}, fmt.Errorf("RPC for %s: %w", r, err)
        }
        futures = append(futures, f)
    }
    // Loop 2: collect results.
    result := FanoutResult{Acks: make([]string, 0, len(futures))}
    for i, f := range futures {
        var ack string
        if err := f.Await(&ack); err != nil {
            return FanoutResult{}, fmt.Errorf("Await for %s: %w", args.Recipients[i], err)
        }
        result.Acks = append(result.Acks, ack)
    }
    return result, nil
}
// from example-async-rpc-go/main.go:175-199

Calling Await inside the dispatch loop turns fan-out into a sequential chain — keep the loops separate.

What maps to what

TemporalResonateShape
workflow.ExecuteChildWorkflow(ctx, fn, args).Get(ctx, &result)f, _ := ctx.RPC("name", args); f.Await(&result)Await-chain
ChildWorkflowOptions{ParentClosePolicy: PARENT_CLOSE_POLICY_ABANDON} + ExecuteChildWorkflowchildID, err := ctx.Detached("name", args, resonate.DetachedOpts{Target: "..."})Detached
child.GetChildWorkflowExecution().Get(ctx, nil) (wait for start)(not available — ctx.Detached has no future; use ctx.RPC + do not await if you need a start-gate)Detached
workflow.ExecuteActivity(ctx, fn, i)[]workflow.Future.Get(ctx, &r)ctx.RPC("name", args)[]*resonate.Futuref.Await(&r)Fan-out
workflow.WithChildOptions(ctx, cwo)resonate.DetachedOpts{Target: "groupName"}Routing
workflow.WithActivityOptions(ctx, ao) with StartToCloseTimeoutresonate.RPCOpts{Timeout: d} (optional, per-call inside workflow)Options
RegisterWorkflow + RegisterActivity (two types)resonate.Register(r, "name", fn) (one registration)Registration
Task queuegroup in localnet.NewLocal / httpnet.HTTPOptionsWorker routing
Workflow ID + Run IDPromise ID (stable key passed to fn.Run or generated for children)Idempotency

Porting it

Await-chain

  1. Remove workflow.WithChildOptions and ChildWorkflowOptions. No options-context threading is needed.
  2. Replace workflow.ExecuteChildWorkflow(ctx, fn, arg).Get(ctx, &result) with two statements: f, err := ctx.RPC("registeredName", arg) then err = f.Await(&result).
  3. Keep the sequential structure — RPCAwait → next RPCAwait.

Detached

  1. Replace workflow.ExecuteChildWorkflow + PARENT_CLOSE_POLICY_ABANDON with childID, err := ctx.Detached("name", args, resonate.DetachedOpts{Target: "groupName"}).
  2. The return value is a string (the child promise ID), not a future. The parent can return immediately after capturing childID.
  3. To observe the child later, call r.Get(ctx, childID) on the client side.
  4. Set Target to match the group name used in localnet.NewLocal or httpnet.HTTPOptions.Group. Omitting Target routes to the worker's own group by default.

Fan-out

  1. Remove workflow.WithActivityOptions and ActivityOptions. Per-call timeouts in Resonate are optional (resonate.RPCOpts{Timeout: d}).
  2. Loop 1: replace workflow.ExecuteActivity(ctx, fn, i) with f, err := ctx.RPC("name", args). Append each *resonate.Future to a slice. No Await inside this loop.
  3. Loop 2: replace results[i].Get(ctx, &result) with f.Await(&result).

What's actually different

No workflow/activity distinction. Temporal splits child workflows (deterministic, replay-safe coordinator) from activities (arbitrary I/O). Resonate has one concept: a durable function. Any function registered with resonate.Register can be invoked via ctx.RPC (with a future) or ctx.Detached (fire-and-forget, returns a promise ID). The SDK handles replay via settled durable promises rather than an event log.

ctx.Detached has no future. In Temporal, even with PARENT_CLOSE_POLICY_ABANDON, ExecuteChildWorkflow returns a ChildWorkflowFuture and you can call .GetChildWorkflowExecution().Get(ctx, nil) to wait for the child to start before the parent returns. ctx.Detached returns only the promise ID. If you need a start-gate before the parent returns, use ctx.RPC and hold the future without calling Await — though that means the parent cannot observe the result either. For true fire-and-forget, ctx.Detached is the right call.

Fan-out awaits in dispatch order, not completion order. Both splitmerge-future and the Resonate two-loop pattern collect results in the order dispatches were made. If the first child is slow, the second child's result waits idle. Temporal's splitmerge-selector uses workflow.NewSelector to process results in arrival order; Resonate does not have a built-in selector equivalent yet.

Replay model is structurally the same. Both systems re-execute the workflow function body from the top on resume. Temporal short-circuits completed steps via the event history; Resonate short-circuits them via settled durable promises keyed on a worker-generated child promise ID. Side effects outside ctx.Run / ctx.RPC / ctx.Detached / ctx.Sleep run again on every resume in both systems.

Notes & coverage

  • resonate.Register returns two values. Always capture both: chainFn, err := resonate.Register(r, "awaitChain", awaitChain). Discarding either silently drops the registration error or the *RegisteredFunc handle needed to call .Run(ctx, id, args) at the top level. (main.go:258-271)
  • Localnet and detached timing. On localnet, ctx.Detached dispatches within the same in-process state machine. The example adds time.Sleep(200 * time.Millisecond) in runDetached to let the actor dispatch before the process exits — do not do this in production. With a real Resonate server, poll the child via r.Get(ctx, childID) instead. (main.go:151)
  • ctx.Run vs detached. ctx.Run spawns an in-process goroutine joined to the parent — a structural match to workflow.Go. ctx.Detached is the opposite: its promise is not joined to the parent and explicitly outlives it.
  • Pre-release SDK. resonate-sdk-go has no semver tag yet; the repo pins a specific commit. Expect API changes before v0.1.0.

Sources