Durable workflows
Cloudflare Workflows' API (WorkflowEntrypoint, step.do, step.sleep, step.waitForEvent) on sp00ky's own job engine, the same code on a VM, under spky dev and as a Worker backend.
Durable workflows are new. TypeScript and JavaScript backends only, through
@spooky-sync/backend.
A durable workflow is a class in your backend whose run() is a list of steps. Each step’s
result is stored, so a crash, a deploy, a retry or a long sleep resumes the workflow where it left
off instead of starting over. The API is Cloudflare Workflows’ (WorkflowEntrypoint, step.do,
step.sleep, step.waitForEvent), but nothing runs on Cloudflare Workflows: the engine is a small
replay loop in the SDK and the queue is your backend’s own outbox. The same code runs
on a VM, under spky dev and as a Worker backend.
Write one
Declare it on the backend, which needs an outbox method (its runs are jobs in that table):
withWorkflows(app, { BINDING: Class }) does two things: every request your app handles sees
env.<BINDING>, and POST /__spky/workflow/<BINDING>, the route the job runner calls, runs the
instance. It wraps a Hono app, an { fetch } object or a plain fetch function, and works on Node
and on Workers alike. On Node, serve the wrapped handler:
How it runs
create() writes an instance row (_00_wf_instance) and an outbox job in one transaction. The SSP
posts the job to your backend. The run replays run() from the top: a step with a stored result
returns it at once, the first step without one executes, and its result is stored in _00_wf_step.
The run goes on until the workflow finishes, fails, has to wait (a sleep, a retry backoff, an event)
or spends its time budget, then hands over to a new job, due when the wait ends.
- Retries.
step.do(name, { retries: { limit, delay, backoff }, timeout }, fn), Cloudflare’s defaults (5 retries, 10 s, exponential, a 10-minute timeout). A short backoff waits inside the run; a longer one becomes a delayed job. ANonRetryableErrorfails the step at once. A step out of retries throws intorun(), where atry/catchmay still run steps. - Sleeps.
step.sleep(name, '1 hour')andstep.sleepUntil(name, date)are delayed jobs. A sleeping workflow costs nothing: on Cloudflare a sleep longer than 30 seconds waits in the database and the SSP wakes when it ends, about a second late. - Events.
step.waitForEvent(name, { type, timeout })resolves with the next event of that type sent throughinstance.sendEvent({ type, payload }), including one sent before the wait, and throws when the timeout (default 24 hours) passes first. - Time budget. A run starts new steps until about 80% of the job’s timeout is spent (at most
60 s), then hands over. Raise
deploy.timeoutfor steps that take long. - One run at a time. A run holds a lease on the instance; a second job for the same instance answers 503 and the job runner retries it. Every write a run makes is fenced, so a run that lost the instance (it hung, or the instance was terminated) writes nothing.
Rules
These are Cloudflare’s rules too.
- Code between steps must be deterministic.
run()replays from the top on every job and only step results are stored. PutDate.now(),Math.random(),crypto.randomUUID()and reads of data that changes inside a step. - Step names are the cache key, by name and occurrence: the third
step.do('poll')of a loop is its own step. Keep the order of steps the same between replays. - Steps run at least once. A crash after a step’s side effect and before its result is stored runs it again. Make steps idempotent (upserts, ids derived from the instance id).
- Results are JSON. A
Datecomes back as its ISO string,undefinedfields disappear. The first run sees the same JSON round trip as every replay. - At most 1 MiB per step result, params and output. Put files and large data in a bucket and return the key.
The binding
| Prop | Type | Default | Description |
|---|---|---|---|
| create({ id?, params?, owner? }) | Promise<WorkflowInstance> | - | Start an instance. id defaults to a UUID. owner (a record id such as user:abc) may read the instance and its steps. |
| createBatch([...]) | Promise<WorkflowInstance[]> | - | Several create() calls. |
| get(id) | Promise<WorkflowInstance> | - | An existing instance; throws when there is none. |
| instance.status() | Promise<{ status, error?, output? }> | - | queued, running, waiting, complete, errored or terminated. |
| instance.terminate() | Promise<void> | - | Stop it: a running run stops at its next write. |
| instance.restart() | Promise<void> | - | Forget every step and run from the start. |
| instance.sendEvent({ type, payload }) | Promise<void> | - | Deliver an event to waitForEvent; wakes the instance now. |
pause() and resume() are not supported yet.
Progress, admin and retention
Instances and steps are ordinary tables: _00_wf_instance (status, steps_done, current_step,
wake_at, params, output, error) and _00_wf_step (one row per step with its attempts, output,
error and retry or wake time). An instance created with an owner is readable by that user, so a
client can LIVE SELECT it on the database. To show progress inside your app’s synced data, write
it to your own rows from inside a step.
The admin API and its MCP server list, inspect, cancel and retry instances
(durable_instances_list, durable_instance_get, durable_instance_cancel,
durable_instance_retry), and the jobs list shows their runs with the origin durable. A finished
instance is deleted with its steps on the project’s run retention window (retention.runSuccess /
runFailed).
retry is the way out when an instance’s job ran out of attempts (the backend was down for
several minutes): the instance stays running or waiting with nothing to run it, and retry gives
it a fresh job.
Coming from Cloudflare Workflows
Code written for Cloudflare Workflows changes in two places, plus sp00ky.yml:
- The import:
WorkflowEntrypoint,WorkflowEventandWorkflowStepcome from@spooky-sync/backendinstead ofcloudflare:workers, andNonRetryableErrorinstead ofcloudflare:workflows. TheWorkflowbinding type too. - The default export:
export default withWorkflows(app, { INGEST: IngestMaterial }). - Move the binding from
worker.workflowsto the backend’sworkflows.
worker.workflows keeps running real Cloudflare Workflows on a Cloudflare project. It is
Cloudflare-only; workflows runs everywhere.
Every outbox column a workflow job does not set must be optional (option<...>) and payload
must accept an object (any), the same as for schedules. The outbox
template spky add api writes already does.
Workflows and the workflows: DAG
The top-level workflows: key is a different feature: a static DAG in
sp00ky.yml whose steps are separate jobs, possibly on different backends, run by the SSP or the
scheduler. Durable workflows are code in one backend with any control flow (loops over pages,
polling with sleeps, waiting for a person). A DAG step can start a durable workflow by calling a
route that calls create().