5 min readResonate HQ

Coming from Temporal: Durable Countdown Loop in Go

The durable-loop-of-ticks pattern — ctx.RPC then ctx.Sleep each iteration — replaces workflow.ExecuteActivity + workflow.NewTimer + NewSelector with two two-liners per tick.

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

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-66

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

resonate.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

TemporalResonate
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 / AddReceivenot 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 queueresonate.Config{URL: …} + optional Group
Workflow ID (stable, caller-supplied)promise ID passed to cdFn.Run(ctx, id, args)
Worker process + Starter processsingle main() in this example

Porting it

  1. Collapse the selector dance into two lines per tick. Delete the NewSelector, AddFuture, and closure. Replace with s, err := ctx.Sleep(d) then s.Await(nil). The sleep is now a named durable promise, not an anonymous closure registered with a selector.

  2. Replace ExecuteActivity with ctx.RPC. Each workflow.ExecuteActivity(ctx, fn, args) becomes ctx.RPC("name", args). You get back a (*Future, error); call f.Await(&r) to block. sleep-for-days drops the activity future (never calls .Get()); in Resonate you always get the future back — call f.Await or discard it explicitly.

  3. Drop ActivityOptions. There is no mandatory timeout to declare. If you need one, pass resonate.RPCOpts{Timeout: d} as a third arg to ctx.RPC. For local development, omit it.

  4. Handle both return values from ctx.RPC and ctx.Sleep. Both return (*Future, error). The error is non-nil if the SDK cannot record the promise (server unreachable, etc.); check it before calling Await.

  5. Keep loop termination explicit. sleep-for-days exits via a signal channel; a bounded countdown exits when i reaches zero. Port your exit condition directly — there is no signal-channel equivalent needed for a bounded loop.

  6. Register both functions. Both countdown and notify need a resonate.Register call. resonate.Register returns (*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.

  7. Use a stable promise ID for production. main.go mints id from time.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-days runs until a "complete" signal arrives; it is not a count-down. cron is 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.Sleep skips on last tick. The if i > 1 guard is intentional: no sleep promise is queued after the final notification. Temporal's sleep-for-days has 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.RPC and ctx.Sleep call are generated worker-side via an internal sequence counter.
  • resonate-sdk-go is pre-release. The example pins to a specific commit. API signatures may change before v0.1.0.

Sources