3 min readResonate HQ

Coming from Temporal: Fan-Out / Fan-In in Go

The two-loop discipline — dispatch all before awaiting any — is identical in both systems. `workflow.ExecuteActivity` / `future.Get` simply becomes `ctx.RPC` / `f.Await`.

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

If you know how Temporal's splitmerge-future Go sample works and want to see what changes in Resonate, this is the shortest possible diff. The fan-out / fan-in pattern has one structural rule that both systems enforce the same way: start all units of work before awaiting any of them, or the work runs in series. The Temporal names change; the discipline does not.

Temporal (samples-go/splitmerge-future)

Two explicit loops — one to dispatch, one to collect — with workflow.ActivityOptions applied before the first dispatch:

func SampleSplitMergeFutureWorkflow(ctx workflow.Context, processorCount int) (ChunkResult, error) {
    ao := workflow.ActivityOptions{
        StartToCloseTimeout: 10 * time.Second,
    }
    ctx = workflow.WithActivityOptions(ctx, ao)
 
    var results []workflow.Future
    for i := 0; i < processorCount; i++ {
        // ExecuteActivity returns a Future immediately — does not block.
        future := workflow.ExecuteActivity(ctx, ChunkProcessingActivity, i+1)
        results = append(results, future)
    }
 
    var totalItemCount, totalSum int
    for i := 0; i < processorCount; i++ {
        var result ChunkResult
        // Blocks until this activity's result is available.
        err := results[i].Get(ctx, &result)
        if err != nil {
            return ChunkResult{}, err
        }
        totalItemCount += result.NumberOfItemsInChunk
        totalSum += result.SumInChunk
    }
    return ChunkResult{totalItemCount, totalSum}, nil
}

Resonate (example-fan-out-fan-in-go)

The same two-loop shape, minus the activity options and the workflow/activity split:

// fanout dispatches one ctx.RPC call per channel without awaiting in between,
// then awaits all of them. The dispatches run concurrently on the server side;
// the Awaits block until every child promise settles.
func fanout(ctx *resonate.Context, args FanoutArgs) (FanoutResult, error) {
	futures := make([]*resonate.Future, 0, len(args.Channels))
	for _, ch := range args.Channels {
		f, err := ctx.RPC("send", SendArgs{Channel: ch, Message: args.Message})
		if err != nil {
			return FanoutResult{}, err
		}
		futures = append(futures, f)
	}
 
	out := FanoutResult{Delivered: make([]Delivery, 0, len(futures))}
	for i, f := range futures {
		var d Delivery
		if err := f.Await(&d); err != nil {
			out.Delivered = append(out.Delivered, Delivery{
				Channel: args.Channels[i],
				OK:      false,
				Reason:  err.Error(),
			})
			continue
		}
		out.Delivered = append(out.Delivered, d)
	}
	return out, nil
}
// from example-fan-out-fan-in-go/main.go:39-63

The child function is a plain Go function; there is no separate activity type:

func send(_ *resonate.Context, args SendArgs) (Delivery, error) {
	fmt.Printf("  [send] %-8s <- %q\n", args.Channel, args.Message)
	return Delivery{Channel: args.Channel, OK: true}, nil
}
// from example-fan-out-fan-in-go/main.go:68-71

Both are registered and run from one main:

r, err := resonate.New(resonate.Config{URL: "http://localhost:8001"})
// ...
fanoutFn, err := resonate.Register(r, "fanout", fanout)
// ...
if _, err := resonate.Register(r, "send", send); err != nil { ... }
 
h, err := fanoutFn.Run(ctx, *id, args)
// ...
out, err := h.Result(ctx)
// from example-fan-out-fan-in-go/main.go:84-106

What maps to what

TemporalResonate
workflow.ExecuteActivity(ctx, Fn, args)ctx.RPC("name", args)
[]workflow.Future (loop 1 collection)[]*resonate.Future
future.Get(ctx, &result)f.Await(&result)
workflow.ActivityOptions{StartToCloseTimeout: …}(none required; optional resonate.RPCOpts{Timeout: d} as third arg)
workflow.WithActivityOptions(ctx, ao)(none)
workflow.Context (orchestrator) + context.Context (activity)one *resonate.Context in both
RegisterWorkflow + RegisterActivity (two types)resonate.Register(r, "name", fn) for each function
Task queue routingGroup on httpnet.HTTPOptions (defaults to "default")
Workflow ID + Run IDone promise ID string passed to fanoutFn.Run

Porting it

  1. Remove workflow.ActivityOptions and workflow.WithActivityOptions. Resonate does not require activity options before dispatch. Delete that block. If you want a per-call deadline, pass resonate.RPCOpts{Timeout: 10 * time.Second} as an optional third argument to ctx.RPC — but you are not required to.

  2. Replace workflow.ExecuteActivity(ctx, Fn, i+1) with ctx.RPC("name", args). The first argument is the string name passed to resonate.Register when registering the child. Return type is (*resonate.Future, error) — check the error before appending. A dispatch error means the child promise could not be created (server unreachable, duplicate ID conflict), not that the child function failed.

  3. Replace results[i].Get(ctx, &result) with f.Await(&result). The signature drops the ctx parameter; the SDK manages context internally. An Await error means the child promise settled with a failure.

  4. Replace workflow.Context with *resonate.Context in the orchestrator signature. The child function uses the same type: func send(_ *resonate.Context, args SendArgs) (Delivery, error).

  5. Register both functions. Call resonate.Register(r, "fanout", fanout) and resonate.Register(r, "send", send). A single binary can register and run both — no worker/starter split is required.

  6. Start the workflow with fanoutFn.Run(ctx, id, args). The ID string becomes the root promise ID. fanoutFn.Run returns a *TypedHandle[FanoutResult], so h.Result(ctx) returns (FanoutResult, error) directly. Pass the same ID on a subsequent run and the SDK returns a handle to the already-resolved promise instead of re-executing — the -id flag in main.go demonstrates this live.

What's actually different

The two-loop discipline is identical. Both systems require all dispatches to happen before any await. In Temporal: build []workflow.Future in loop 1, call .Get in loop 2. In Resonate: build []*resonate.Future in loop 1, call .Await in loop 2. Collapse them into one loop — ctx.RPC(…); f.Await(&d) inline — and you serialize the children. The same footgun exists in both systems.

No workflow/activity split. Temporal differentiates the orchestrator (a workflow, subject to determinism rules) from the units of work (activities, free to do I/O) via workflow.Context vs context.Context and separate RegisterWorkflow / RegisterActivity calls. Resonate uses one *resonate.Context in both places and one resonate.Register call per function. The execution distinction is still present — the function that calls ctx.RPC is the orchestrator and must follow determinism rules; the dispatched function is free to do I/O — but it does not surface as a separate type or registration path.

No activity options required. Temporal returns an error if StartToCloseTimeout (or ScheduleToCloseTimeout) is missing. Resonate applies a 24-hour default when no timeout is specified; durability comes from the promise store and the worker heartbeat rather than a mandatory per-call deadline.

Per-child promise visibility. Each ctx.RPC call creates a named child promise visible on the dashboard at http://localhost:8001. A failed delivery can be inspected and re-triggered independently without replaying the full parent history.

Notes & coverage

  • Completion-order processing. This example, like splitmerge-future, awaits results in dispatch order. Temporal's companion sample splitmerge-selector uses workflow.NewSelector to process results as each activity completes. The Resonate SDK does not currently expose a selector or race primitive; processing in arrival order requires goroutines and channels outside the built-in surface.
  • Async RPC and other dispatch shapes. If you need fire-and-forget dispatch or await-chain variants, the sibling "Async RPC" brief covers those shapes.
  • SDK stability. resonate-sdk-go is pre-release and has no semver tag. The ctx.RPC / *resonate.Future / f.Await API reflects the commit pinned in go.mod. Expect possible changes before v0.1.0.

Sources