If you've built recurring billing on Temporal's Go SDK and want to see what changes in Resonate, this is the shortest path. The pattern — a long-lived loop that charges a customer, sleeps for a billing period, and repeats until tenure ends or the customer unsubscribes — carries over with one meaningful divergence: how cancellation is delivered. Everything else (durable timers, crash recovery, exactly-once steps) maps structurally.
Note: Temporal has no official Go subscription sample. The Temporal code below is authored from documented Go SDK primitives and the TypeScript recurring-billing tutorial; API names are checked against pkg.go.dev/go.temporal.io/sdk/workflow. The guide's own caveat applies — treat it as a faithful illustration, not a copy of an upstream sample.
The pattern
A subscription workflow idles most of its life inside a durable sleep, carries period-count state across those sleeps, and must react to an external termination event. It exercises the three capabilities at the core of any durable-execution system: durable timers, external cancellation, and long-lived in-flight state.
Both systems make the same baseline promise: a worker crash mid-sleep does not restart the subscription, double-charge the customer, or re-send the welcome email. They diverge in how cancellation reaches the workflow body — that is the main thread of this brief. For the single-wait (non-recurring) timer shape, see the Durable Sleep brief. For rollback-on-failure, see the Saga / Compensation brief.
Temporal (modeled on documented Go patterns)
The Temporal subscription is a workflow that maintains a signal channel for cancellation, then races that channel against a timer in a selector on each inter-period sleep:
func SubscriptionWorkflow(ctx workflow.Context, input SubscriptionInput) (result SubscriptionResult, err error) {
cancelCh := workflow.GetSignalChannel(ctx, "cancelSubscription")
ctx = workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: 30 * time.Second,
})
// Cleanup via defer + disconnected context so it runs even after cancellation.
defer func() {
if cancelled {
dctx, cancel := workflow.NewDisconnectedContext(ctx)
defer cancel()
_ = workflow.ExecuteActivity(dctx, SendCancellationNotice,
input.CustomerID, periodsCharged).Get(dctx, nil)
}
}()
// Step 1 — welcome email, durable and exactly-once.
if err = workflow.ExecuteActivity(ctx, SendWelcomeEmail, input.CustomerID).Get(ctx, nil); err != nil {
return result, err
}
// Step 3 — recurring billing loop.
for period := 1; period <= input.MaxPeriods; period++ {
if e := workflow.ExecuteActivity(ctx, ChargeCustomer,
input.CustomerID, period, input.Amount).Get(ctx, nil); e != nil {
return result, e
}
periodsCharged++
if period == input.MaxPeriods { break }
// Race the interval timer against the cancel signal.
timerFired := false
selector := workflow.NewSelector(ctx)
selector.AddFuture(workflow.NewTimer(ctx, input.Interval), func(workflow.Future) {
timerFired = true
})
selector.AddReceive(cancelCh, func(workflow.ReceiveChannel, bool) {
cancelled = true
})
selector.Select(ctx)
if cancelled || !timerFired {
cancelled = true
return result, nil
}
}
_ = workflow.ExecuteActivity(ctx, SendCompletionNotice,
input.CustomerID, periodsCharged).Get(ctx, nil)
return SubscriptionResult{periodsCharged, totalCharged, "completed"}, nil
}Cancellation arrives two ways: client.CancelWorkflow cancels the workflow context so an in-flight workflow.Sleep (or timer future inside the selector) returns a *CanceledError immediately — the timer is interrupted mid-sleep. Or client.SignalWorkflow(..., "cancelSubscription", nil) delivers the named signal, which the selector races against the timer. Either way the workflow body resumes to run defer cleanup.
Resonate (example-subscription-go)
The Resonate body is a plain loop — no signal channel, no selector. Steps run via ctx.Run; inter-period sleeps via ctx.Sleep. Cancellation is not delivered into the body at all:
func subscription(ctx *resonate.Context, args SubscriptionArgs) (SubscriptionResult, error) {
base := ChargeArgs{
SubscriptionID: args.SubscriptionID,
CustomerID: args.CustomerID,
Amount: args.Amount,
}
// Step 1 — welcome email (durable, exactly-once).
fWelcome, err := ctx.Run(sendWelcome, base)
if err != nil {
return SubscriptionResult{}, fmt.Errorf("welcome dispatch: %w", err)
}
if err := fWelcome.Await(nil); err != nil {
return SubscriptionResult{}, fmt.Errorf("welcome: %w", err)
}
// Step 2 — optional trial period.
if args.TrialDuration > 0 {
fTrial, err := ctx.Sleep(args.TrialDuration)
if err != nil { return SubscriptionResult{}, fmt.Errorf("trial sleep: %w", err) }
if err := fTrial.Await(nil); err != nil { return SubscriptionResult{}, fmt.Errorf("trial await: %w", err) }
}
// Step 3 — recurring billing loop.
periodsCharged := 0
totalCharged := 0.0
for period := 1; period <= args.MaxPeriods; period++ {
chargeArgs := ChargeArgs{
SubscriptionID: args.SubscriptionID,
CustomerID: args.CustomerID,
Period: period,
Amount: args.Amount,
}
fCharge, err := ctx.Run(chargeCustomer, chargeArgs)
if err != nil {
return SubscriptionResult{}, fmt.Errorf("charge dispatch period %d: %w", period, err)
}
var cr ChargeResult
if err := fCharge.Await(&cr); err != nil {
return SubscriptionResult{}, fmt.Errorf("charge period %d: %w", period, err)
}
if cr.Succeeded {
periodsCharged++
totalCharged += args.Amount
}
// 3b. Sleep until the next billing interval (unless this is the last period).
if period < args.MaxPeriods {
fSleep, err := ctx.Sleep(args.Interval)
if err != nil {
return SubscriptionResult{}, fmt.Errorf("interval sleep period %d: %w", period, err)
}
if err := fSleep.Await(nil); err != nil {
return SubscriptionResult{}, fmt.Errorf("interval sleep period %d: %w", period, err)
}
}
}
// Step 4 — normal completion notice.
fDone, err := ctx.Run(sendCompletionNotice, ChargeArgs{
SubscriptionID: args.SubscriptionID,
CustomerID: args.CustomerID,
Period: periodsCharged,
})
if err != nil { return SubscriptionResult{}, fmt.Errorf("completion notice dispatch: %w", err) }
if err := fDone.Await(nil); err != nil { return SubscriptionResult{}, fmt.Errorf("completion notice: %w", err) }
return SubscriptionResult{
SubscriptionID: args.SubscriptionID,
CustomerID: args.CustomerID,
Status: "completed",
PeriodsCharged: periodsCharged,
TotalCharged: totalCharged,
}, nil
}
// from example-subscription-go/main.go:178-286ctx.Run(fn, args) dispatches a step as a durable child promise and returns a *Future; f.Await(&out) blocks until it settles and decodes the typed result. ctx.Sleep(d) creates a server-side timer promise; f.Await(nil) suspends until it fires. Step functions (sendWelcome, chargeCustomer, sendCompletionNotice) are plain Go functions passed by value to ctx.Run — they do not need to be registered at the top level.
External cancellation calls a separate helper that settles the root promise directly:
func cancelSubscription(ctx context.Context, r *resonate.Resonate, promiseID string, reason string) error {
rawJSON, _ := json.Marshal(reason)
b64 := base64.StdEncoding.EncodeToString(rawJSON)
quotedB64, _ := json.Marshal(b64)
val := resonate.Value{Data: json.RawMessage(quotedB64)}
req := resonate.PromiseSettleReq{
ID: promiseID,
State: resonate.SettleStateRejectedCanceled,
Value: val,
}
rec, err := r.Sender().PromiseSettle(ctx, req)
// ...
return err
}
// from example-subscription-go/main.go:303-330The value encoding (JSON → base64 → quoted string in Value.Data) must match the SDK codec's format exactly. Do not substitute a high-level value helper here — Sender().PromiseSettle bypasses the codec, so an un-encoded value will not round-trip.
What maps to what
| Temporal | Resonate |
|---|---|
workflow.ExecuteActivity(ctx, ChargeCustomer, …).Get(ctx, nil) | ctx.Run(chargeCustomer, args) + fCharge.Await(&cr) |
Activity func func(context.Context, T) error | Step func func(*resonate.Context, T) (R, error) — typed result alongside error |
workflow.Sleep(ctx, interval) | f, _ := ctx.Sleep(interval) then f.Await(nil) |
workflow.GetSignalChannel + workflow.NewSelector (timer vs. signal race) | no in-body equivalent — external cancellation only |
client.CancelWorkflow / client.SignalWorkflow | r.Sender().PromiseSettle(ctx, {ID, SettleStateRejectedCanceled, Value}) |
defer + workflow.NewDisconnectedContext cleanup | caller-side cleanup after h.Result(ctx) returns the cancelled error |
workflow.SetQueryHandler (in-flight status) | read the settled promise state, or embed counts in SubscriptionResult |
w.RegisterWorkflow + w.RegisterActivity on a task queue | resonate.Register(r, "subscription", subscription) — only the root workflow |
StartWorkflowOptions{ID, TaskQueue} | promise ID passed to subscriptionFn.Run(ctx, id, args) |
we.Get(ctx, &result) | result, err := h.Result(ctx) — typed return, no out-pointer |
Porting it
-
Collapse workflow and activities into one function. Write one
func(_ *resonate.Context, args SubscriptionArgs) (SubscriptionResult, error). Step functions are plain Go functions — no separate activity type, no annotation. -
Replace
ExecuteActivity(...).Get(...)withctx.Run(fn, args)+f.Await(&out). DropWithActivityOptions; passresonate.RunOpts{Timeout, RetryPolicy}as an optional third argument toctx.Runonly where you need non-default behaviour. -
Map the durable sleep directly.
workflow.Sleep(ctx, interval)becomesf, err := ctx.Sleep(interval)thenf.Await(nil). Both back the timer server-side so a crash mid-sleep resumes on the remaining time, not from the top. The period-count state (periodsCharged) lives in local variables — on resume the already-settledctx.Runchildren short-circuit by promise ID and the loop advances correctly. -
Register only the root workflow.
resonate.Register(r, "subscription", subscription)is all that goes inmain; step functions passed toctx.Runare invoked by value and do not need top-level registration. -
Replace
StartWorkflowOptions{ID}with a plain string. The promise ID passed tosubscriptionFn.Run(ctx, id, args)is the idempotency key. CallRunagain with the same ID after a crash and the SDK re-attaches to the existing promise. -
Move cancellation to the caller. Delete the signal channel and selector. When a customer unsubscribes, call
r.Sender().PromiseSettlewithSettleStateRejectedCanceledfrom outside the workflow. Move any post-cancellation cleanup (notification email, pro-rated charge) to the caller side, afterh.Result(ctx)returns the cancelled error. If that cleanup must itself be crash-safe, dispatch it as a separate top-level Resonate workflow from the caller. -
Point at the server.
resonate.New(resonate.Config{URL: "http://localhost:8001"})replacesclient.Dial; runresonate devfor a local server. The example also ships an in-processlocalnetmode — useful for exploring the API, but state is ephemeral so crash recovery requires a real server.
What's actually different
Cancellation — the central trade-off. This is where the two systems diverge most meaningfully.
In Temporal, CancelWorkflow or a cancelSubscription signal interrupts an in-flight workflow.Sleep immediately — the timer is abandoned, the selector resolves, the workflow body resumes to run defer cleanup. The cleanup activity can use workflow.NewDisconnectedContext so it executes even after the parent context is cancelled. The TypeScript tutorial uses the same shape: a cancelSubscription signal flips a flag that condition() watches.
In Resonate, cancellation is a settle of the root promise, not a signal into the body. The sequence: the workflow is suspended inside fSleep.Await(nil) — the task is parked server-side. An external caller settles the root promise as rejected_canceled via r.Sender().PromiseSettle. The caller's h.Result(ctx) wakes immediately because the subscription map for the root promise ID closes its channel. The server-side sleep timer is still ticking. When it fires and the task is re-acquired, the runtime observes the root promise is already settled and fulfills the task as rejected_canceled without re-entering the workflow body.
Temporal (cancel mid-sleep):
period 1 charged
period 2 sleep starts
CancelWorkflow arrives ← timer interrupted immediately
defer runs cleanup (disconnected ctx)
workflow exits "cancelled"
period 2 never charged
Resonate (cancel mid-sleep):
period 1 charged
period 2 sleep starts (timer ticking server-side)
PromiseSettle(rejected_canceled) arrives
h.Result(ctx) on the CALLER returns immediately
server-side timer fires later ← task re-acquired, short-circuits, body NOT re-entered
period 2 never charged
Both customers are never charged after cancelling. The difference is what happens inside the workflow: Temporal re-enters the body so cleanup can run there; Resonate does not re-enter, so cleanup runs on the caller side after h.Result returns.
No in-workflow signal channel or race primitive. The Go SDK currently has no select/race primitive to await "timer OR external event" concurrently inside the body. External root cancellation is the practical substitute: simpler body (no signal channels, no selector), at the cost of the in-workflow cleanup path.
No SetQueryHandler. Temporal supports in-flight status queries. Resonate has no equivalent in-workflow query handler; read the settled promise state directly, or embed running totals in the settled SubscriptionResult.
Step functions are not registered. Only the root workflow goes through resonate.Register. Step functions passed by value to ctx.Run are invoked directly — no task queue routing per step.
Notes & coverage
- No official Temporal Go subscription sample. The Temporal code is authored from documented Go SDK primitives and the TypeScript tutorial; treat it as a faithful illustration of the pattern, not a copy of an upstream sample.
localnetmode. The example ships an in-processlocalnetmode that needs no server. Localnet state is in-memory, so crash recovery is not demonstrated there; use a realresonate devserver for that. Temporal always requires a running server.- Replay model. Both systems resume a crashed run by re-executing the workflow body from the top. In Resonate, already-settled
ctx.Run/ctx.Sleepchildren short-circuit by promise ID. Anything outside those boundaries — a barefmt.Printf— runs again on every resume. - Benchmark mode. The example's
-nflag runs N subscriptions concurrently in parallel goroutines and reports throughput. Because each subscription spends most of its time insidectx.Sleep, it demonstrates how many concurrent long-lived timer-heavy workflows the server carries — a different axis from raw step throughput. - Per-step idempotency. The SDK derives a deterministic child promise ID per
ctx.Runcall site, so each charge runs at most once. Design your step functions (real payment gateway calls) to be idempotent on theTransIDthey derive from the subscription and period number.
Sources
- Example repo: github.com/resonatehq-examples/example-subscription-go
- Temporal reference: TypeScript recurring-billing tutorial · Go SDK workflow package
- Concept-level guide, all SDKs: docs.resonatehq.io/evaluate/coming-from/temporal
