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 completionPARENT_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-79ctx.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-131ctx.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-199Calling Await inside the dispatch loop turns fan-out into a sequential chain — keep the loops separate.
What maps to what
| Temporal | Resonate | Shape |
|---|---|---|
workflow.ExecuteChildWorkflow(ctx, fn, args).Get(ctx, &result) | f, _ := ctx.RPC("name", args); f.Await(&result) | Await-chain |
ChildWorkflowOptions{ParentClosePolicy: PARENT_CLOSE_POLICY_ABANDON} + ExecuteChildWorkflow | childID, 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.Future → f.Await(&r) | Fan-out |
workflow.WithChildOptions(ctx, cwo) | resonate.DetachedOpts{Target: "groupName"} | Routing |
workflow.WithActivityOptions(ctx, ao) with StartToCloseTimeout | resonate.RPCOpts{Timeout: d} (optional, per-call inside workflow) | Options |
RegisterWorkflow + RegisterActivity (two types) | resonate.Register(r, "name", fn) (one registration) | Registration |
| Task queue | group in localnet.NewLocal / httpnet.HTTPOptions | Worker routing |
| Workflow ID + Run ID | Promise ID (stable key passed to fn.Run or generated for children) | Idempotency |
Porting it
Await-chain
- Remove
workflow.WithChildOptionsandChildWorkflowOptions. No options-context threading is needed. - Replace
workflow.ExecuteChildWorkflow(ctx, fn, arg).Get(ctx, &result)with two statements:f, err := ctx.RPC("registeredName", arg)thenerr = f.Await(&result). - Keep the sequential structure —
RPC→Await→ nextRPC→Await.
Detached
- Replace
workflow.ExecuteChildWorkflow+PARENT_CLOSE_POLICY_ABANDONwithchildID, err := ctx.Detached("name", args, resonate.DetachedOpts{Target: "groupName"}). - The return value is a
string(the child promise ID), not a future. The parent can return immediately after capturingchildID. - To observe the child later, call
r.Get(ctx, childID)on the client side. - Set
Targetto match the group name used inlocalnet.NewLocalorhttpnet.HTTPOptions.Group. OmittingTargetroutes to the worker's own group by default.
Fan-out
- Remove
workflow.WithActivityOptionsandActivityOptions. Per-call timeouts in Resonate are optional (resonate.RPCOpts{Timeout: d}). - Loop 1: replace
workflow.ExecuteActivity(ctx, fn, i)withf, err := ctx.RPC("name", args). Append each*resonate.Futureto a slice. NoAwaitinside this loop. - Loop 2: replace
results[i].Get(ctx, &result)withf.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.Registerreturns two values. Always capture both:chainFn, err := resonate.Register(r, "awaitChain", awaitChain). Discarding either silently drops the registration error or the*RegisteredFunchandle needed to call.Run(ctx, id, args)at the top level. (main.go:258-271)- Localnet and detached timing. On localnet,
ctx.Detacheddispatches within the same in-process state machine. The example addstime.Sleep(200 * time.Millisecond)inrunDetachedto let the actor dispatch before the process exits — do not do this in production. With a real Resonate server, poll the child viar.Get(ctx, childID)instead. (main.go:151) ctx.Runvs detached.ctx.Runspawns an in-process goroutine joined to the parent — a structural match toworkflow.Go.ctx.Detachedis the opposite: its promise is not joined to the parent and explicitly outlives it.- Pre-release SDK.
resonate-sdk-gohas no semver tag yet; the repo pins a specific commit. Expect API changes beforev0.1.0.
Sources
- Example repo: github.com/resonatehq-examples/example-async-rpc-go
- Temporal samples:
child-workflow,batch-sliding-window,splitmerge-future - Concept-level guide, all SDKs: docs.resonatehq.io/evaluate/coming-from/temporal
