Resonate Durable Sleep + Scheduled Work — Go
Version note. The Go SDK's first tagged release is
0.1.0(go get github.com/resonatehq/resonate-sdk-go@0.1.0— the tag has novprefix, so@latestdoes not resolve to it).0.1.0shipped a top-levelSchedules()sub-client for direct cron-fired promises. This skill covers durable sleep (ctx.Sleep), theSchedules()sub-client, and the patterns for recurring registered-function dispatch (in-workflowctx.Sleeploops; external cron →RPC). Every code block is verified against the0.1.0tag source,example-durable-sleep-go, andexample-countdown-go.
Overview
Three related capabilities in the Go SDK:
- Durable sleep inside a workflow —
ctx.Sleep(d time.Duration)pauses execution; the worker process can exit and resume later without losing its place. The server holds the timer promise; cost is one promise record, not process uptime. - Direct cron schedules —
r.Schedules().Create(...)(shipped in0.1.0) creates a promise from a cron expression, on a recurring basis, without any workflow involved. This is the Go equivalent of Python'sresonate.schedules.create(...)sub-client. - Recurring registered-function dispatch — Go's
0.1.0tag does not yet have the top-levelresonate.schedule(id, cron, fn, args)convenience wrapper that Python/TypeScript/Rust expose, which dispatches a registered function on the cron. Until it lands, use an in-workflowctx.Sleeploop (bounded or long-running periodic task owned by one workflow), an external cron that firesr.RPC(...)on each tick, orSchedules().Createwith the dispatch tags set by hand (shown below).
All three are durable: Resonate holds the continuation (or the schedule) in its store, not in a long-running goroutine.
When to use
- Delays spanning minutes, hours, days, or weeks that must survive crashes
- Reminder sequences (7-day trial expiry, multi-stage onboarding drips)
- Countdown workflows that post a notification per tick
- Periodic jobs where an in-workflow loop is acceptable, or where an external scheduler already exists
- Any place you would reach for
time.Sleepbut need the work to survive a process restart
ctx.Sleep basics
ctx.Sleep takes a time.Duration and returns a *resonate.Future. Call f.Await(nil) to suspend until the timer fires — there is no value to decode.
// From example-durable-sleep-go/main.go
func sleepingWorkflow(ctx *resonate.Context, args SleepArgs) (string, error) {
d := time.Duration(args.Secs) * time.Second
f, err := ctx.Sleep(d)
if err != nil {
return "", fmt.Errorf("ctx.Sleep: %w", err)
}
// Await(nil) — no value to decode; suspends until the timer promise resolves.
if err := f.Await(nil); err != nil {
return "", fmt.Errorf("sleep await: %w", err)
}
return fmt.Sprintf("slept for %d second(s)", args.Secs), nil
}
Crash recovery. With a real Resonate server (-url=http://localhost:8001), killing the worker mid-sleep and restarting with the same promise ID resumes from the outstanding timer rather than restarting the workflow. The localnet transport runs state in process memory, so crash recovery requires a real server.
Duration encoding tip. time.Duration is int64 nanoseconds and round-trips through JSON as a bare number, which is opaque in promise payloads. Store durations as explicit seconds fields (as SleepArgs.Secs does) to keep stored promise data readable.
Reminder / multi-stage sleeps
Sequential ctx.Sleep calls are independent durable checkpoints. A crash mid-sleep resumes from that exact sleep on restart — earlier sleeps that already settled are skipped.
// Three-phase renewal reminder: 7 days out, 1 day out, renewal day.
func renewalReminder(ctx *resonate.Context, subID string) (struct{}, error) {
// 7 days before renewal
if f, err := ctx.Sleep(7 * 24 * time.Hour); err != nil {
return struct{}{}, err
} else if err := f.Await(nil); err != nil {
return struct{}{}, err
}
if f, err := ctx.RPC("send-renewal-warning", subID); err != nil {
return struct{}{}, err
} else if err := f.Await(nil); err != nil {
return struct{}{}, err
}
// 6 more days (1 day before renewal)
if f, err := ctx.Sleep(6 * 24 * time.Hour); err != nil {
return struct{}{}, err
} else if err := f.Await(nil); err != nil {
return struct{}{}, err
}
if f, err := ctx.RPC("send-final-warning", subID); err != nil {
return struct{}{}, err
} else if err := f.Await(nil); err != nil {
return struct{}{}, err
}
// 1 more day — renewal day
if f, err := ctx.Sleep(24 * time.Hour); err != nil {
return struct{}{}, err
} else if err := f.Await(nil); err != nil {
return struct{}{}, err
}
f, err := ctx.RPC("charge-renewal", subID)
if err != nil {
return struct{}{}, err
}
return struct{}{}, f.Await(nil)
}
Countdown loop (in-workflow recurring pattern)
The example-countdown-go canonical example shows the real in-workflow loop pattern: dispatch the side effect via ctx.RPC (durable, checkpointed), then ctx.Sleep between ticks.
// From example-countdown-go/main.go — adapted for clarity.
func countdown(ctx *resonate.Context, args CountdownArgs) (CountdownResult, error) {
sent := 0
for i := args.Start; i > 0; i-- {
// Side effect lives inside ctx.RPC so it's checkpointed — won't double-fire on resume.
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
}
A crash during the ctx.Sleep between ticks resumes mid-loop — completed ctx.RPC ticks short-circuit on replay; the pending sleep re-suspends until its timer fires.
Long-horizon sleeps are cheap
Sleep duration is unbounded. Cost is roughly one promise record; the process does not need to stay alive.
// Sleep for months — the process can exit and the timer holds in the server.
func birthdayGreeting(ctx *resonate.Context, args BirthdayArgs) (struct{}, error) {
f, err := ctx.Sleep(args.UntilBirthday) // weeks or months ahead
if err != nil {
return struct{}{}, err
}
if err := f.Await(nil); err != nil {
return struct{}{}, err
}
gf, err := ctx.RPC("send-birthday-email", args.UserID)
if err != nil {
return struct{}{}, err
}
return struct{}{}, gf.Await(nil)
}
Resonate.Schedules() — direct cron-fired promises
r.Schedules() (a top-level Client API, called from the ephemeral world — not a Context method) creates a schedule that fires a fresh promise on every cron tick, from a promise ID template:
ctx := context.Background()
// Fires a fresh promise every night at 00:00, from the report-{{.timestamp}} template.
s, err := r.Schedules().Create(ctx, "nightly-report", "0 0 * * *", "report-{{.timestamp}}", time.Hour,
resonate.ScheduleCreateOptions{PromiseParam: ReportArgs{Region: "us"}})
if err != nil {
log.Fatalf("Schedules().Create: %v", err)
}
s, err = r.Schedules().Get(ctx, "nightly-report")
err = r.Schedules().Delete(ctx, "nightly-report")
This creates the promise directly — nobody is dispatched to run a registered function unless you arrange it. Schedules().Create does not exist to wrap Rust or TypeScript's resonate.schedule(id, cron, fn, args) examples 1:1 — translating those verbatim (expecting a function to run automatically) will compile but the promise it creates will just sit there for a worker to notice, not auto-dispatch.
To dispatch a registered function on the cron (matching Rust/TypeScript's resonate.schedule(...) behavior), set the dispatch tags and param shape by hand — the same shape RegisteredFunc.Run and Resonate.RPC build internally:
s, err := r.Schedules().Create(ctx, "nightly-reconciliation", "0 2 * * *",
"recon-{{.timestamp}}", 24*time.Hour,
resonate.ScheduleCreateOptions{
PromiseParam: map[string]any{
"func": "reconcile", // must match the name passed to resonate.Register
"args": ReconArgs{Date: "{{.timestamp}}"},
},
PromiseTags: map[string]string{
"resonate:target": "poll://any@default", // routes to a worker group, like RunOptions.Target
},
})
A worker polling that target picks up each fired promise as an ordinary task, same as an RPC dispatch. This is a manual assembly of what a future top-level resonate.Schedule(...) wrapper would do for you — verify the exact param/tag shape against buildRootPromiseCreateReq in resonate.go before shipping, since it is not a documented public contract.
Recurring work without the function-dispatch convenience wrapper
Two further substitutes for recurring registered-function dispatch, useful when you'd rather not hand-assemble the dispatch tags above:
Pattern 1 — In-workflow ctx.Sleep loop
A workflow that loops indefinitely (or for a bounded count) and sleeps between iterations is a self-contained recurring job. The loop is fully durable — a crash mid-sleep resumes at the current iteration.
// Periodic cleanup job: runs every intervalDays days, indefinitely.
func periodicCleanup(ctx *resonate.Context, args CleanupArgs) (struct{}, error) {
for {
// Side effect checkpointed in ctx.Run — won't double-fire on replay.
f, err := ctx.Run(runCleanup, args)
if err != nil {
return struct{}{}, err
}
if err := f.Await(nil); err != nil {
return struct{}{}, err
}
// Durable sleep until next run.
s, err := ctx.Sleep(time.Duration(args.IntervalDays) * 24 * time.Hour)
if err != nil {
return struct{}{}, err
}
if err := s.Await(nil); err != nil {
return struct{}{}, err
}
}
}
Start once with a stable promise ID:
// Invoke from the ephemeral world — deduplicated on the ID, so safe to re-run on deploy.
h, err := cleanupFn.Run(ctx, "periodic-cleanup-prod", CleanupArgs{IntervalDays: 7})
When to use: bounded or long-running periodic task owned by exactly one workflow; interval driven by business logic inside the workflow.
Pattern 2 — External cron → r.RPC
Keep the schedule outside Resonate (OS cron, Cloud Scheduler, GitHub Actions, Kubernetes CronJob). Each tick calls r.RPC (or the resonate invoke CLI) to create a durable invocation.
// cron-trigger/main.go — runs on every cron tick; idempotent on stable ID.
func main() {
r, err := resonate.New(resonate.Config{URL: os.Getenv("RESONATE_URL")})
if err != nil {
log.Fatalf("resonate.New: %v", err)
}
defer func() { _ = r.Stop() }()
// Stable ID for today's run — deduplicates if the cron fires twice.
today := time.Now().UTC().Format("2006-01-02")
id := fmt.Sprintf("nightly-recon/%s", today)
ctx := context.Background()
h, err := r.RPC(ctx, id, "nightly-reconciliation", ReconArgs{Date: today})
if err != nil {
log.Fatalf("RPC: %v", err)
}
var result ReconResult
if err := h.Result(ctx, &result); err != nil {
log.Fatalf("Result: %v", err)
}
log.Printf("reconciliation done: %+v", result)
}
When to use: the schedule already lives in an external system; per-firing invocations are independent (no loop state carried across ticks); or the interval must be changed without redeploying a long-running workflow.
CLI equivalent (no code trigger needed):
resonate invoke nightly-reconciliation --id "nightly-recon/$(date +%F)" --data '{"date":"2026-06-10"}'
Distinct Go idioms
f.Await(nil)— sleep futures carry no value; passniltoAwait(unlikectx.Run/ctx.RPCfutures where you decode into a pointer).time.Duration— all sleep durations are native Go durations (24*time.Hour,time.Minute, etc.); no raw millisecond integers, no cron strings.ctx.Sleepvstime.Sleep—time.Sleepinside a durable function is not durable (lost on crash, blocks the goroutine for its full duration). Always usectx.Sleepfor anything you need to survive a restart.- Options struct last —
ctx.Sleeptakes only atime.Duration; no options struct.ctx.Run/ctx.RPCaccept an optional trailingRunOpts/RPCOptsstruct. - Localnet requires
NoopHeartbeat{}—localnet.NewLocal(...)has no HTTP endpoint; the defaultAsyncHeartbeatwill error. Always pair localnet withHeartbeat: resonate.NoopHeartbeat{}.
Avoid
time.Sleepinside a durable function — ephemeral; lost on crash; holds the goroutine for the full duration. Usectx.Sleep.- Un-checkpointed side effects before a sleep — any code that runs before a
ctx.Sleep(or any other durable boundary) re-executes on resume. Wrap observable side effects (DB writes, emails, webhooks) inctx.Run/ctx.RPCso the durable promise records the result and short-circuits replay. - Assuming
Schedules().Createauto-dispatches a registered function — it creates the cron-fired promise only; translating Rust or TypeScriptresonate.schedule(...)code verbatim compiles but nothing consumes the fired promise unless you set theresonate:targettag and{func, args}param shape yourself, or fall back to one of the patterns above. - Clock precision assumptions —
ctx.Sleep(24*time.Hour)firing in 23–25h is within spec (server/worker drift). Don't treat variance of ±1h as a bug for long-horizon sleeps. - Raw
time.Durationnanoseconds in promise payloads — store durations as explicit integer fields (seconds, minutes) to keep promise data human-readable across crashes and inspections.
Related skills
resonate-basic-durable-world-usage-go—ctx.Run,ctx.RPC,ctx.Promisefundamentals; the same Context thectx.SleepAPI lives onresonate-basic-ephemeral-world-usage-go—Resonate.Promises()/Resonate.Schedules()sub-client reference (Client APIs, not Context APIs)durable-execution— foundational replay semantics; sleep is a durability checkpoint by designresonate-durable-sleep-scheduled-work-typescript— TypeScript sibling; has the top-levelresonate.schedule()function-dispatch wrapper (cron strings, ms durations)resonate-durable-sleep-scheduled-work-rust— Rust sibling; has the top-levelresonate.schedule()function-dispatch wrapper (cron strings,std::time::Duration)- SDK gap note: Go's
0.1.0Schedules()sub-client covers direct cron-fired promises (like Python'sschedules.create(...)sub-client), but Go alone still lacks the higher-level top-levelschedule(id, cron, fn, args)convenience wrapper that dispatches a registered function — Python (0.7.4), TypeScript, and Rust all have one. If porting a scheduled workflow from Python/Rust/TypeScript to Go, either hand-assemble the dispatch tags (see above) or use one of the two patterns in the "Recurring work" section.