5 min readResonate HQ

Coming from Temporal: Durable Sleep in Go

Temporal's sleep-for-days races a 30-day timer against a signal channel via NewSelector. Resonate's equivalent is ctx.Sleep(d) + f.Await(nil) — Selector gone, durability kept.

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

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.NewTimerctx.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-67

ctx.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-120

The 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

TemporalResonateNotes
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.AddReceivectx.Promise() + f.AwaitThe 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.WithActivityOptionsresonate.RunOpts{Timeout: d, RetryPolicy: p}Passed as the optional third argument to ctx.Run. Not required for simple calls.
Worker task queueresonate.Config{...} + resonate.RegisterThe 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

  1. Replace the import. Swap go.temporal.io/sdk/workflow for github.com/resonatehq/resonate-sdk-go. For local development without an external server, also import github.com/resonatehq/resonate-sdk-go/localnet.

  2. Change the function signature. Replace workflow.Context with *resonate.Context. Add an explicit args struct for any parameters — Resonate passes workflow arguments as a single serializable value.

  3. Replace the timer. Every workflow.NewTimer(ctx, d) becomes ctx.Sleep(d). Check the error return before calling Await.

  4. Replace selector.Select with f.Await. For the single-timer case, drop the Selector entirely. f.Await(nil) parks the goroutine until the timer promise resolves.

  5. Port the loop and completion signal separately. The for !isComplete + GetSignalChannel shape does not have a direct single-call equivalent. Structure it as: repeated ctx.Run steps for the loop body, and a latent promise via ctx.Promise() that an external caller resolves to break the loop. See example-human-in-the-loop-go for the promise-resolve pattern.

  6. Remove activity boilerplate. Delete workflow.WithActivityOptions and ActivityOptions. Wrap side-effecting calls in ctx.Run for durability; RunOpts is the per-call equivalent when you need a timeout or retry policy.

  7. Register the workflow. resonate.Register(r, "sleepingWorkflow", sleepingWorkflow) replaces Temporal's worker task-queue registration. The returned handle exposes .Run for invocation.

  8. Use a stable promise ID. Pass the same ID to sleepFn.Run after a crash to re-attach to the existing workflow. The example accepts -id=<value> on the command line (default durable-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/timer rather than sleep-for-days, the mapping is more direct: timer's workflow.NewTimer(childCtx, threshold) racing a single timer against an activity future maps cleanly to ctx.Sleep(d) + f.Await(nil).
  • Replay side effects. The fmt.Printf before ctx.Sleep prints 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 a Sleep or ctx.Run checkpoint will re-execute on replay unless wrapped in their own ctx.Run call. This matches the Temporal rule for activity calls.
  • Resolving a promise externally. The sleep-for-days completion signal maps to resolving a latent promise from outside the workflow. In the Go SDK this is currently lower-level: call r.Sender().PromiseSettle(ctx, PromiseSettleReq{ID: id, State: resonate.SettleStateResolved, Value: v}), or use the resonate promises resolve CLI. See resonate-sdk-go#28 for tracking.
  • Duration encoding. time.Duration round-trips through JSON as a bare nanosecond integer, which is opaque in stored promise payloads. This example passes duration as an explicit int64 seconds field and converts at the call site (time.Duration(args.Secs) * time.Second).
  • 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