Durable Sleep
Journaled pauses that resume across restarts — fx.clock.sleep(label, duration) on durable Flows.
Durable sleep suspends a Flow until a future instant without holding a worker thread.
Call await fx.clock.sleep(label, duration) inside a durable: true Flow — the journal
stores wakeAt, releases the run lease, and any instance may resume when due.
For developers building trial reminders, delayed digests, and multi-day provisions on okengine.
The one rule
Signature is fx.clock.sleep(label, duration) — two arguments. A single duration string is
treated as the label, not the wait. Sleep only parks when the Flow is durable.
Durable sleep — survives restart
sleep("wait-for-payment", "7d")sleep- 01
sleep
fx.clock.sleep("wait-for-payment", "7d")
- 02
journal
wakeAt + label written — Console shows the sleeping run
- 03
restart
Deploy / crash — process gone; journal stays
- 04
wake → resume
Continue at the next unfinished step — prior steps never re-run
Non-durable flows resolve the same call immediately so tests read identically — durability is the option that journals.
Smallest Example
Sleep inside a durable Flow
import { on, flow, http } from "okengine";
import { z } from "zod";
export const start = on(
http.post({
in: z.object({ email: z.string().email() }),
}),
flow({
durable: true,
do: async ({ email }, fx) => {
await fx.step("mark-trial", async () => {
await fx.call(startTrial, { email });
});
await fx.clock.sleep("expiry-window", "3d");
await fx.step("notify", async () => {
await fx.send(trialExpiringEmail, { to: email });
});
},
}),
);Call the endpoint
curl -X POST http://localhost:6530/trials/start \
-H "content-type: application/json" \
-d '{"email":"ada@example.com"}'Response: 204 No Content (empty body). The HTTP client is done — wake continues on a
worker three days later. Wrap side effects in fx.step so replay does not re-send mail.
Detailed section
If you only need a pause, the example above is enough. Below: park physics, wake-early, nested-call limits, non-durable no-ops, and how sleep fits with steps / compensation.
Progressive Patterns
From a labelled pause to HTTP parking, nested-call limits, and non-durable behavior:
Durations: "200ms" · "30s" · "2m" · "1h" · "7d". Labels show up in the journal
and Console:
await fx.clock.sleep("morning-window", "8h");
await fx.clock.sleep("verify-window", "2m");Wrong: fx.clock.sleep("8h") — "8h" is the label; duration is missing → immediate
resolve / unexpected behavior.
Sleep Reference
| Call / option | Type | Default | Meaning |
|---|---|---|---|
durable: true | Flow option | false | Journals steps and sleep; required to park |
fx.clock.sleep(label, duration) | park | — | Writes wakeAt, status sleeping, releases lease |
label | string | — | Journal / Console step name |
duration | string | — | "200ms" · "30s" · "2m" · "1h" · "7d" |
Outcomes of a durable attempt that hits sleep: status "sleeping" with wakeAt and
label. After wake, prior fx.step bodies replay from the journal and do not re-execute.
Duration units
Same grammar as fx.clock.ago / fromNow / clock.every:
| Unit | Example | Milliseconds |
|---|---|---|
ms | "200ms" | 200 |
s | "30s" | 30_000 |
m | "2m" | 120_000 |
h | "1h" | 3_600_000 |
d | "7d" | 604_800_000 |
Integer + unit only — no weeks. "d" is exactly 86_400_000 ms, not a calendar day across
DST. Unknown strings parse as 0 ms (wake immediately).
Park Physics
Detailed section
If you only need await fx.clock.sleep("label", "8h"), jump past this section. The park writes a
journal entry, sets status sleeping, and releases the run lease so a parked flow does not
hold a 30s lock for days.
fx.clock.sleep(label, duration)
→ journal entry { kind: "sleep", label, duration, wakeAt }
→ status = sleeping, lease released
→ HTTP / caller sees success (often 204)
→ later: claim due sleep → resume → replay steps → continue after sleepAny instance may claim the row when wakeAt is due (shared journal + lease). Resume replays
completed fx.step values from the journal, then continues past the sleep entry.
| Phase | Status | Lease | What happens |
|---|---|---|---|
| Before sleep | running | Held | Steps append; side effects execute once |
| At sleep | sleeping | Released | wakeAt stored; worker free |
After wakeAt | claim → running | Re-acquired | Replay steps; sleep entry is a no-op past due |
| Done | completed / failed | Released | Terminal — resume refused |
Consequence: multi-day sleeps are safe across deploys — the journal row is the schedule, not an in-memory timer.
With Steps
Each verb of durable work binds the same way — checkpoint, park, then more checkpoints:
Wrap every side effect in a uniquely named fx.step. Sleep sits between steps:
import { on, flow } from "okengine";
import { userSignedUp } from "@/signals";
import { welcomeEmail } from "@/channels/welcome";
export const sendWelcome = on(
userSignedUp,
flow("onboarding.welcome", {
durable: true,
do: async ({ email }, fx) => {
await fx.step("provision", async () => {
await fx.call(createWorkspace, { email });
});
await fx.clock.sleep("morning-window", "8h");
await fx.step("notify", async () => {
await fx.send(welcomeEmail, { to: email });
});
},
}),
);Consequence: after wake, provision returns the journaled value and never re-runs;
notify runs for the first time.
Nested Calls
Detailed section
If you sleep only on the root durable Flow, skip this section. fx.call waits for the callee to
return — a sleeping callee does not keep the caller parked.
fx.call of a durable sleeper: the caller receives undefined and continues; the child
wakes later as its own run.
| Pattern | Safe? | Why |
|---|---|---|
| Sleep on the root durable Flow | Yes | One journal row, clear park |
fx.emit → durable consumer that sleeps | Yes | Separate run owns the wait |
fx.call(sleeperFlow, …) then use the result | No | Caller gets undefined; child parks alone |
Consequence: sleep on the root durable Flow, or split with fx.emit to a consumer —
do not nest sleep behind fx.call.
Wake Early
Operators can advance wakeAt to now from Console (waiting-on / wake-early). With a
flow resolver, the run resumes immediately; without it, only the wake time moves forward.
| Outcome | Meaning |
|---|---|
| Resumed | wakeAt set to now and runDurable continued past the sleep |
| Wake time only | wakeAt advanced; scheduler / next claim picks it up |
ClockResourceNotFoundError | run "{id}" not found — missing id, or status is not sleeping |
Missing or non-sleeping run id → ClockResourceNotFoundError — run "{id}" not found.
Non-durable & Tests
Without a journal session, fx.clock.sleep resolves immediately (tests and sync Flows).
There is no thread sleep and no setTimeout.
For deterministic waits in tests, use a frozen clock and advance:
import { createTimeTravel } from "okengine";
const t = createTimeTravel(0);
// Park a durable run with now: () => t.now()
t.advance("7d");
// Resume — sleep entry is past due and continuesPin journal for the env you need:
| Driver | Default env | Best for |
|---|---|---|
postgres | dev, prod | Shared durable runs across replicas (DATABASE_URL) |
memory | test | Process-local; lost on exit |
file | pin explicitly | One machine — .oke/journal.json |
Unknown ids throw oke boot: unknown journal driver "…" (expected memory · file · postgres).
Postgres without a URL throws oke boot: journal driver "postgres" needs DATABASE_URL.
Full lease / orphan / status tables live on Workflows · Journal.
Troubleshooting
Missing durable: true, or fx.clock.sleep("8h") with one argument. Use fx.clock.sleep("label", "8h"). Non-durable sleep is a no-op.
The Flow parked on fx.clock.sleep — success, not a missing handler. The original client is done;
wake continues on a worker. Return a body before sleep if needed, or emit to a Signal consumer for
the long wait.
The side effect was outside fx.step. Wrap provider calls in uniquely named steps so replay
returns the journaled value.
fx.using is same-attempt cleanup — do not hold handles across fx.clock.sleep. Acquire again
after wake.
Cause: run "{id}" not found. The wake-early target is missing or not sleeping — check the run
id from Console / durable result.
The callee parked. Sleep on the root Flow, or emit to a durable consumer instead of calling a sleeper inline.
Strings that do not match integer + ms|s|m|h|d parse as 0 ms. Use "8h", not "8 hours" or
"1w".
Resume lost the lease race (JournalLeaseBusy). The other holder continues; this instance skips.
Shared postgres journal + default 30s lease — same physics as Signal claims.
Boot is still reclaiming running / sleeping / compensating rows with no live lease. Wait for
the orphan scan; future sleeps stay scheduled until wakeAt.
Learn more
- Workflows —
durable,fx.step, compensate, full sleep section - HTTP — request envelope;
204from park /undefined - Clock overview — schedules and
fx.clockhelpers - Schedules — cron / every (calendar time, not durable pause)
- fx —
fx.clock.sleep,fx.step,fx.using - Configuration —
drivers.journal - Errors —
ClockResourceNotFoundError