6 min readResonate HQ

Coming from Temporal: Durable Subscription Billing in Go

The recurring-billing loop maps almost directly. The one real delta is cancellation: Temporal interrupts the body mid-sleep; Resonate wakes the caller instead.

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

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

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

The 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

TemporalResonate
workflow.ExecuteActivity(ctx, ChargeCustomer, …).Get(ctx, nil)ctx.Run(chargeCustomer, args) + fCharge.Await(&cr)
Activity func func(context.Context, T) errorStep 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.SignalWorkflowr.Sender().PromiseSettle(ctx, {ID, SettleStateRejectedCanceled, Value})
defer + workflow.NewDisconnectedContext cleanupcaller-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 queueresonate.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

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

  2. Replace ExecuteActivity(...).Get(...) with ctx.Run(fn, args) + f.Await(&out). Drop WithActivityOptions; pass resonate.RunOpts{Timeout, RetryPolicy} as an optional third argument to ctx.Run only where you need non-default behaviour.

  3. Map the durable sleep directly. workflow.Sleep(ctx, interval) becomes f, err := ctx.Sleep(interval) then f.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-settled ctx.Run children short-circuit by promise ID and the loop advances correctly.

  4. Register only the root workflow. resonate.Register(r, "subscription", subscription) is all that goes in main; step functions passed to ctx.Run are invoked by value and do not need top-level registration.

  5. Replace StartWorkflowOptions{ID} with a plain string. The promise ID passed to subscriptionFn.Run(ctx, id, args) is the idempotency key. Call Run again with the same ID after a crash and the SDK re-attaches to the existing promise.

  6. Move cancellation to the caller. Delete the signal channel and selector. When a customer unsubscribes, call r.Sender().PromiseSettle with SettleStateRejectedCanceled from outside the workflow. Move any post-cancellation cleanup (notification email, pro-rated charge) to the caller side, after h.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.

  7. Point at the server. resonate.New(resonate.Config{URL: "http://localhost:8001"}) replaces client.Dial; run resonate dev for a local server. The example also ships an in-process localnet mode — 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.
  • localnet mode. The example ships an in-process localnet mode that needs no server. Localnet state is in-memory, so crash recovery is not demonstrated there; use a real resonate dev server 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.Sleep children short-circuit by promise ID. Anything outside those boundaries — a bare fmt.Printf — runs again on every resume.
  • Benchmark mode. The example's -n flag runs N subscriptions concurrently in parallel goroutines and reports throughput. Because each subscription spends most of its time inside ctx.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.Run call site, so each charge runs at most once. Design your step functions (real payment gateway calls) to be idempotent on the TransID they derive from the subscription and period number.

Sources