If you know Temporal's sleep-for-days Go sample and want to see the same pattern in Resonate, this brief covers the loop shape: perform a step (notify, send, emit), sleep a fixed duration, repeat, survive crashes. Temporal models this with workflow.ExecuteActivity + workflow.NewTimer + NewSelector; Resonate models it with ctx.RPC + ctx.Sleep, both of which record server-side durable promises.
One scoping note up front: there is a sibling brief covering the single long durable wait and crash recovery primitive on its own — if that is what you need, start there. This brief is specifically about the repeated loop-of-ticks shape.
Temporal (samples-go/sleep-for-days)
There is no exact "countdown" sample in temporalio/samples-go. The closest structural match is sleep-for-days (a loop + completion signal), with the timer sample showing the NewTimer/Selector mechanism and cron showing a server-scheduled repeating activity. This mapping is structural, not one-to-one — the exit condition and signal handling differ.
sleep-for-days runs indefinitely, racing a 30-day timer against a "complete" signal each iteration:
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
}Each iteration: fire the activity (dropping its future — sleep-for-days does not call .Get()), construct a selector that races a 30-day timer against the signal channel, then block on selector.Select. The selector + closure machinery is the cost of racing two futures in Temporal's deterministic replay model.
Resonate (example-countdown-go)
The Resonate version counts down from Start to 1, calling ctx.RPC("notify", …) on each tick and sleeping between ticks:
func countdown(ctx *resonate.Context, args CountdownArgs) (CountdownResult, error) {
sent := 0
for i := args.Start; i > 0; i-- {
f, err := ctx.RPC("notify", NotifyArgs{Count: i, URL: args.NotifyURL})
if err != nil {
return CountdownResult{}, err
}
var r NotifyResult
if err := f.Await(&r); err != nil {
return CountdownResult{}, fmt.Errorf("notify %d: %w", i, err)
}
sent++
if i > 1 {
s, err := ctx.Sleep(time.Duration(args.StepSeconds) * time.Second)
if err != nil {
return CountdownResult{}, err
}
if err := s.Await(nil); err != nil {
return CountdownResult{}, fmt.Errorf("sleep before %d: %w", i-1, err)
}
}
}
return CountdownResult{Sent: sent}, nil
}
// from example-countdown-go/main.go:42-66ctx.RPC("notify", args) creates a durable promise for the child call and returns (*Future, error). f.Await(&r) blocks until the promise settles. ctx.Sleep(d) creates a durable timer promise; s.Await(nil) blocks until it fires. Both calls return (*Future, error) — check the error before calling Await.
The if i > 1 guard skips the trailing sleep: the workflow exits immediately after the final notification without queuing an unnecessary timer promise.
Registration and invocation in main:
r, err := resonate.New(resonate.Config{URL: "http://localhost:8001"})
// ...
cdFn, err := resonate.Register(r, "countdown", countdown)
// ...
if _, err := resonate.Register(r, "notify", notify); err != nil { ... }
id := fmt.Sprintf("countdown-%d", time.Now().UnixNano())
h, err := cdFn.Run(ctx, id, CountdownArgs{Start: *start, StepSeconds: *step, NotifyURL: *url})
// ...
out, err := h.Result(ctx)
// from example-countdown-go/main.go:97-125resonate.Register returns (*RegisteredFunc, error) — both values matter. The notify function is registered separately so ctx.RPC("notify", …) can route to it; functions registered in the same process are resolved locally.
What maps to what
| Temporal | Resonate |
|---|---|
workflow.ExecuteActivity(ctx, fn, args) (fire activity) | ctx.RPC("name", args) → f.Await(&r) |
workflow.NewTimer(ctx, d) + selector.Select(ctx) | ctx.Sleep(d) → s.Await(nil) |
workflow.NewSelector + AddFuture / AddReceive | not needed for a bounded loop with no external signal |
workflow.GetSignalChannel(ctx, "complete") | ctx.Promise(…) + external settle (see example-human-in-the-loop-go) |
workflow.ActivityOptions{StartToCloseTimeout: …} | resonate.RPCOpts{Timeout: …} (optional, not required) |
w.RegisterWorkflow + w.RegisterActivity (two types) | resonate.Register(r, "name", fn) (one call per function) |
| Task queue | resonate.Config{URL: …} + optional Group |
| Workflow ID (stable, caller-supplied) | promise ID passed to cdFn.Run(ctx, id, args) |
| Worker process + Starter process | single main() in this example |
Porting it
-
Collapse the selector dance into two lines per tick. Delete the
NewSelector,AddFuture, and closure. Replace withs, err := ctx.Sleep(d)thens.Await(nil). The sleep is now a named durable promise, not an anonymous closure registered with a selector. -
Replace
ExecuteActivitywithctx.RPC. Eachworkflow.ExecuteActivity(ctx, fn, args)becomesctx.RPC("name", args). You get back a(*Future, error); callf.Await(&r)to block.sleep-for-daysdrops the activity future (never calls.Get()); in Resonate you always get the future back — callf.Awaitor discard it explicitly. -
Drop
ActivityOptions. There is no mandatory timeout to declare. If you need one, passresonate.RPCOpts{Timeout: d}as a third arg toctx.RPC. For local development, omit it. -
Handle both return values from
ctx.RPCandctx.Sleep. Both return(*Future, error). The error is non-nil if the SDK cannot record the promise (server unreachable, etc.); check it before callingAwait. -
Keep loop termination explicit.
sleep-for-daysexits via a signal channel; a bounded countdown exits whenireaches zero. Port your exit condition directly — there is no signal-channel equivalent needed for a bounded loop. -
Register both functions. Both
countdownandnotifyneed aresonate.Registercall.resonate.Registerreturns(*RegisteredFunc, error)— a bare call that discards both return values compiles but silently swallows any registration error and loses the handle needed to call.Run. -
Use a stable promise ID for production.
main.gomintsidfromtime.Now().UnixNano(). A deterministic business key (e.g., a job ID from your database) lets a re-submitted request reconnect to the running workflow rather than start a new one.
What's actually different
Replay model, no history-size concern. Both Temporal and Resonate re-execute the function body from the top on resume. In Temporal the replay is driven by an event log; each loop iteration appends events, and long or unbounded loops need ContinueAsNew to avoid hitting history size limits. In Resonate already-settled child promises short-circuit by promise ID (a durable-promise cache, not an event log), so history size is not a concern for long loops. The observable behavior on crash-resume is the same in both systems: ticks that already completed are skipped. Side effects written outside ctx.RPC / ctx.Sleep (a bare fmt.Println, for instance) will re-run on every resume in both systems.
No workflow/activity distinction. Temporal distinguishes workflows (deterministic, replayed via event log) from activities (free I/O, no replay), which drives the workflow.Context vs context.Context split, separate registration calls, and the required ActivityOptions timeout. In Resonate all functions share *resonate.Context; durability comes from whether a call goes through ctx.RPC / ctx.Run (recorded as a promise) or is called directly (not recorded).
No Selector. sleep-for-days uses NewSelector to race a timer against a signal. A bounded countdown has no external signal, so no selector is needed. If you need to race a sleep against an external event in Resonate, the equivalent is a latent promise (ctx.Promise) resolved from outside — see example-human-in-the-loop-go for that pattern.
Server URL is explicit. resonate.New(resonate.Config{URL: "http://localhost:8001"}) connects to a running Resonate server. There is no automatic fallback to an in-process local mode. Run resonate dev before starting the binary.
Notes & coverage
- No exact countdown sample in
samples-go.sleep-for-daysruns until a"complete"signal arrives; it is not a count-down.cronis closer in spirit (periodic scheduled work) but uses a server-side schedule rather than an in-workflow loop. The structural mapping — loop + activity + timer per iteration — is valid; the exit condition and signal handling are not directly ported. ctx.Sleepskips on last tick. Theif i > 1guard is intentional: no sleep promise is queued after the final notification. Temporal'ssleep-for-dayshas no equivalent guard because it exits via signal rather than by counting.- Child promise IDs are SDK-generated. The top-level workflow ID is caller-supplied; child IDs for each
ctx.RPCandctx.Sleepcall are generated worker-side via an internal sequence counter. resonate-sdk-gois pre-release. The example pins to a specific commit. API signatures may change beforev0.1.0.
Sources
- Example repo: github.com/resonatehq-examples/example-countdown-go
- Temporal sample (
sleep-for-days): github.com/temporalio/samples-go/sleep-for-days - Temporal sample (
timer): github.com/temporalio/samples-go/timer - Temporal sample (
cron): github.com/temporalio/samples-go/cron - Concept guide, all SDKs: docs.resonatehq.io/evaluate/coming-from/temporal
