If you know how Temporal's sleep-for-days Go sample works and you want to see what changes in Resonate, this is the targeted diff for the timer leg. One thing to name up front: sleep-for-days is not a bare sleep. It is a loop that sends an email on each iteration and then races a 30-day timer against a "complete" signal channel using NewSelector and GetSignalChannel. This brief covers only the timer half — workflow.NewTimer → ctx.Sleep. The completion-signal half (breaking the loop from outside) maps to a latent durable promise; that pattern is shown in the companion example-human-in-the-loop-go. There is also a sibling brief on the sequential durable countdown loop (multiple timed iterations) if that is the shape you are porting.
Temporal (samples-go/sleep-for-days)
The workflow loops indefinitely. On each pass it schedules an email activity, then builds a Selector that races a 30-day timer against a "complete" signal channel. selector.Select blocks until whichever branch fires first. If the timer fires, the loop continues; if the signal fires, isComplete flips and the loop exits.
func SleepForDaysWorkflow(ctx workflow.Context) (string, error) {
ctx = workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: 10 * time.Second,
})
isComplete := false
sigChan := workflow.GetSignalChannel(ctx, "complete")
for !isComplete {
workflow.ExecuteActivity(ctx, SendEmailActivity, "Sleeping for 30 days")
selector := workflow.NewSelector(ctx)
selector.AddFuture(workflow.NewTimer(ctx, time.Hour*24*30), func(f workflow.Future) {})
selector.AddReceive(sigChan, func(c workflow.ReceiveChannel, more bool) {
isComplete = true
})
selector.Select(ctx)
}
return "done", nil
}The timer itself is workflow.NewTimer(ctx, time.Hour*24*30) — a workflow.Future added to the Selector. When the timer fires the selector callback is a no-op; when the signal fires isComplete flips. That loop-plus-signal shape is what makes sleep-for-days a production-useful example rather than a trivial wait.
Resonate (example-durable-sleep-go)
Resonate's equivalent for the timer leg:
func sleepingWorkflow(ctx *resonate.Context, args SleepArgs) (string, error) {
fmt.Printf(" [workflow] sleeping for %d second(s)...\n", args.Secs)
d := time.Duration(args.Secs) * time.Second
f, err := ctx.Sleep(d)
if err != nil {
return "", fmt.Errorf("ctx.Sleep: %w", err)
}
// Await blocks until the timer promise resolves. With a real server this
// is also the suspension point that survives a worker crash.
if err := f.Await(nil); err != nil {
return "", fmt.Errorf("sleep await: %w", err)
}
msg := fmt.Sprintf("slept for %d second(s)", args.Secs)
fmt.Printf(" [workflow] done — %s\n", msg)
return msg, nil
}
// from example-durable-sleep-go/main.go:49-67ctx.Sleep(d) creates a timer promise on the Resonate server and returns a *Future. f.Await(nil) parks the goroutine at that promise. If the worker is killed while the timer is pending, restarting the worker and calling sleepFn.Run with the same promise ID re-attaches to the existing workflow — the timer continues from where it left off on the server, not from zero.
The registration and invocation:
sleepFn, err := resonate.Register(r, "sleepingWorkflow", sleepingWorkflow)
// ...
h, err := sleepFn.Run(ctx, id, args)
// ...
result, err := h.Result(ctx)
// from example-durable-sleep-go/main.go:101-120The id string passed to Run is the idempotency key. Re-running with the same ID after a crash re-attaches to the existing workflow; Resonate deduplicates on that ID server-side.
What maps to what
| Temporal | Resonate | Notes |
|---|---|---|
workflow.NewTimer(ctx, d) | ctx.Sleep(d) | Both accept time.Duration. Both create a server-side timer that survives worker restarts when backed by a real server. |
workflow.Future (from NewTimer) | *resonate.Future (from ctx.Sleep) | Both represent a pending result you block on. |
selector.AddFuture(f, fn) + selector.Select(ctx) | f.Await(nil) | For the single-timer case the Selector collapses to a plain Await. |
workflow.GetSignalChannel + selector.AddReceive | ctx.Promise() + f.Await | The signal/completion branch maps to a latent durable promise. See the human-in-the-loop example. |
workflow.ExecuteActivity + ActivityOptions{StartToCloseTimeout} | ctx.Run(fn, args) | No separate activity type. Wrap side effects (email, DB writes) in ctx.Run for durability; pass RunOpts{Timeout: d} as an optional third argument when you need a child deadline. |
workflow.WithActivityOptions | resonate.RunOpts{Timeout: d, RetryPolicy: p} | Passed as the optional third argument to ctx.Run. Not required for simple calls. |
| Worker task queue | resonate.Config{...} + resonate.Register | The group name in Config is the routing equivalent of a Temporal task queue. |
client.ExecuteWorkflow (by Workflow ID) | sleepFn.Run(ctx, id, args) | The promise ID is the idempotency key. Same ID after a crash re-attaches; different ID starts a new run. |
Porting it
-
Replace the import. Swap
go.temporal.io/sdk/workflowforgithub.com/resonatehq/resonate-sdk-go. For local development without an external server, also importgithub.com/resonatehq/resonate-sdk-go/localnet. -
Change the function signature. Replace
workflow.Contextwith*resonate.Context. Add an explicit args struct for any parameters — Resonate passes workflow arguments as a single serializable value. -
Replace the timer. Every
workflow.NewTimer(ctx, d)becomesctx.Sleep(d). Check the error return before callingAwait. -
Replace
selector.Selectwithf.Await. For the single-timer case, drop theSelectorentirely.f.Await(nil)parks the goroutine until the timer promise resolves. -
Port the loop and completion signal separately. The
for !isComplete+GetSignalChannelshape does not have a direct single-call equivalent. Structure it as: repeatedctx.Runsteps for the loop body, and a latent promise viactx.Promise()that an external caller resolves to break the loop. Seeexample-human-in-the-loop-gofor the promise-resolve pattern. -
Remove activity boilerplate. Delete
workflow.WithActivityOptionsandActivityOptions. Wrap side-effecting calls inctx.Runfor durability;RunOptsis the per-call equivalent when you need a timeout or retry policy. -
Register the workflow.
resonate.Register(r, "sleepingWorkflow", sleepingWorkflow)replaces Temporal's worker task-queue registration. The returned handle exposes.Runfor invocation. -
Use a stable promise ID. Pass the same ID to
sleepFn.Runafter a crash to re-attach to the existing workflow. The example accepts-id=<value>on the command line (defaultdurable-sleep-1) — start the worker, kill it mid-sleep with Ctrl-C, run it again with the same-id, and the workflow resumes from the server-side timer checkpoint rather than restarting.
What's actually different
The Selector has no direct equivalent. Temporal's Selector is a general-purpose racing primitive — it can race any combination of futures, signals, and cancellations. The Resonate Go SDK does not have a Selector equivalent at this time. For the common single-timer case f.Await is sufficient. Racing a timer against an external event requires a different structure: the external event resolves a promise that the workflow is already awaiting, rather than two independent branches racing.
No workflow/activity split. Temporal deliberately separates workflow code (deterministic, replay-safe) from activity code (arbitrary side effects). That separation enforces determinism and provides fine-grained retry and timeout control per activity. Resonate takes a different approach: a plain Go function made durable by ctx.Run serves both roles. There is no workflow/activity annotation and no separate activity-worker process. The tradeoff is less structural enforcement of determinism and a lower-level API for external promise resolution; the gain is a smaller surface area and no inter-process coordination for simple cases.
Localnet mode is in-process only. This example ships with a localnet mode — an in-process transport that needs no external server. Localnet state lives in process memory, so a process crash also loses the timer. Crash recovery requires a real Resonate server (resonate dev). Temporal has no equivalent in-process mode; you always need a server running.
Notes & coverage
- Bare-timer reference. If you arrived from
samples-go/timerrather thansleep-for-days, the mapping is more direct:timer'sworkflow.NewTimer(childCtx, threshold)racing a single timer against an activity future maps cleanly toctx.Sleep(d)+f.Await(nil). - Replay side effects. The
fmt.Printfbeforectx.Sleepprints twice on a localnet run. That is replay: when the timer fires, the runtime re-enters the workflow body from the top. Side effects before aSleeporctx.Runcheckpoint will re-execute on replay unless wrapped in their ownctx.Runcall. This matches the Temporal rule for activity calls. - Resolving a promise externally. The
sleep-for-dayscompletion signal maps to resolving a latent promise from outside the workflow. In the Go SDK this is currently lower-level: callr.Sender().PromiseSettle(ctx, PromiseSettleReq{ID: id, State: resonate.SettleStateResolved, Value: v}), or use theresonate promises resolveCLI. See resonate-sdk-go#28 for tracking. - Duration encoding.
time.Durationround-trips through JSON as a bare nanosecond integer, which is opaque in stored promise payloads. This example passes duration as an explicitint64seconds field and converts at the call site (time.Duration(args.Secs) * time.Second). - 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-durable-sleep-go
- Temporal sample: github.com/temporalio/samples-go/sleep-for-days
- Simpler bare-timer reference: github.com/temporalio/samples-go/timer
- Concept-level guide, all SDKs: docs.resonatehq.io/evaluate/coming-from/temporal
