Sleep & wakeups
Durable sleep, cron schedules, external signals, and how parked workflows show up in inspection.
Use sleep({...}) from @voltro/workflow for durable waits inside a workflow. It wraps @effect/workflow's DurableClock.sleep and records timer events for the dashboard.
Basic sleep
import { workflow, step, sleep } from '@voltro/workflow'
import { Effect, Schema } from 'effect'
const buildExecute = (ctx: AppContext) =>
({ userId }: { userId: string }) =>
Effect.gen(function* () {
yield* step({
name: 'send-welcome',
success: Schema.Void,
execute: sendEmail(ctx, userId, 'welcome'),
})
yield* sleep({ name: 'retention-delay', duration: '7 days' })
yield* step({
name: 'send-retention-check-in',
success: Schema.Void,
execute: sendEmail(ctx, userId, 'retention-check-in'),
})
})The workflow is not meant to hold a worker thread for the whole delay. The durable clock parks the workflow in the engine; Voltro records timer-set and timer-fired events in _voltro_workflow_run_events.
Sleep until a wall-clock time
Compute a duration, then pass it to sleep:
const millisUntilNextThreeUtc = () => {
const now = new Date()
const target = new Date(now)
target.setUTCHours(3, 0, 0, 0)
if (target <= now) target.setUTCDate(target.getUTCDate() + 1)
return target.getTime() - now.getTime()
}
yield* sleep({
name: 'wait-until-3am-utc',
duration: `${millisUntilNextThreeUtc()} millis`,
})Use this for one-off waits that are part of a larger workflow. For recurring cadence, use schedules.
Recurring work belongs in schedules
Do not build a forever loop with sleep for "run every day" jobs. Use *.cron.tsx:
// apps/api/schedules/daily-rollup.cron.tsx
import { defineSchedule } from '@voltro/runtime'
export default defineSchedule({
name: 'rollup.daily',
cron: '0 3 * * *',
timezone: 'UTC',
onOverlap: 'skip',
handler: async ({ app, scheduledAt }) => {
await runRollup(app.store, scheduledAt)
},
})Schedules give you timezone parsing, overlap policy, backfill policy, cluster-wide coordination, run history, and manual "Run now" controls.
External wakeups with awaitSignal
Use awaitSignal(ctx, ...) when a workflow must wait for an outside decision, such as a human approval or webhook callback.
import { awaitSignal, step } from '@voltro/workflow'
import { Effect, Schema } from 'effect'
const decision = yield* awaitSignal(ctx, {
name: 'approval',
schema: Schema.Struct({
approved: Schema.Boolean,
reviewerId: Schema.String,
}),
timeoutMs: 24 * 60 * 60_000,
})
if (!decision.approved) {
yield* step({
name: 'cancel-order',
success: Schema.Void,
execute: cancelOrder(orderId, decision.reviewerId),
})
}Signals are matched by name inside one workflow run. The payload is decoded with the supplied schema.
Long waits: awaitSignalSuspending
awaitSignal polls from inside a live activity — the run keeps its worker fiber for the whole wait. Right for an approval that lands in seconds; wrong for a wait measured in hours or days, where a thousand parked runs pin a thousand fibers. For those, use the drop-in suspending variant:
import { awaitSignalSuspending } from '@voltro/workflow'
const decision = yield* awaitSignalSuspending(ctx, {
name: 'approval',
schema: Schema.Struct({ approved: Schema.Boolean }),
timeoutMs: 3 * 24 * 60 * 60_000, // three days — a real human-in-the-loop wait
})Same call shape, same senders (ctx.workflows.signal, the dashboard button, the HTTP endpoint resume both variants), but the run returns Suspended while parked — the worker slot is freed and the wake lives in the engine's durable store, exactly like a long sleep. The timeout is durable too.
Rule of thumb: seconds → awaitSignal; anything a human might sleep on → awaitSignalSuspending. The framework nudges you: an awaitSignal declaring a timeout above five minutes logs a one-time hint (once per workflow, never per poll) naming the suspending variant. Tune or effectively silence the threshold with workflows: { suspendSignalHintMs } in app.config.ts, or VOLTRO_WORKFLOW_SUSPEND_HINT_MS at runtime. It is a hint only — the framework never swaps the variant under a run, because the two journal differently and a silent swap mid-history is a replay trap dressed as a favour.
Sending signals
From the dashboard, open a running workflow and use "Send signal".
From the CLI:
voltro workflows signal wfrun_01H... --name approval --payload '{"approved":true,"reviewerId":"usr_123"}'From HTTP:
POST /_voltro/inspect/workflows/runs/:id/signal
Content-Type: application/json
{ "signalName": "approval", "payload": { "approved": true, "reviewerId": "usr_123" } }
The runtime records signal-awaited, signal-sent, and signal-received events in _voltro_workflow_run_events. The polling activity inside awaitSignal is checkpointed; once it receives a payload, replay returns the journaled value.
Cancellation and suspend/resume
Use operational controls, not sentinel rows:
voltro workflows cancel wfrun_01H...
voltro workflows suspend wfrun_01H...
voltro workflows resume wfrun_01H...The inspect API exposes the same actions at:
POST /_voltro/inspect/workflows/runs/:id/cancel
POST /_voltro/inspect/workflows/runs/:id/suspend
POST /_voltro/inspect/workflows/runs/:id/resume
Anti-patterns
- Using
Effect.sleepfor durable workflow waits. Usesleep({...})so timer events are recorded and the durable clock is used. - Forever-loop cron workflows. Use
*.cron.tsxschedules for recurring cadence. - Polling an external system with long sleeps. Prefer webhooks or
awaitSignal. - Sleeping inside a database transaction. Finish the write boundary before the wait.