workflow
Durable multi-step processes: steps, waiting, signals, runs.
Declare
local settle = workflow "settle-sale" (function(wf, input)
-- run body
return { outcome = "done" }
end)
Referencing one declared elsewhere:
local settle = workflows "settle-sale"
One script per project declares a name; a second declaration is refused at publish.
Starting and addressing runs
local run = settle:start(input, { id = "sale-lot-42" })
local run = settle:get("sale-lot-42")
run.started -- true when start created the run
run:signal("paid", { by = "ada" })
run:cancel("no longer needed")
run:status() -- { kind, seq, at } of the newest journal entry
| Rule | Refusal |
|---|---|
start takes the input and { id = "…" } | start takes the input and { id = "..." }; the id is the run's identity, so a retried start joins it. |
an id is a non-empty string without / | A run id is a non-empty string without '/'. |
A deterministic id ("sale-" .. lot) lets routes address a run
without a lookup table. cancel propagates to every run this one
spawned.
wf verbs
wf:step(name, body)
wf:step(name, { retries = 3, backoff = "5s", timeout = "30s" }, body)
wf:sleep(duration)
wf:await(name, { timeout = duration })
wf:race({ "a", "b" }, { timeout = duration })
wf:all({ "a", "b" }, { timeout = duration })
wf:spawn("definition-name", input)
| Verb | Returns |
|---|---|
step | whatever the body returned, recorded on first run |
sleep | nothing, after the duration |
await | the signal payload, or nil on timeout |
race | payload, name of the first to arrive; nil, nil on timeout. Losers keep running and their signals stay consumable; an empty set is refused |
all | payloads in argument order; nil per timed-out entry. The timeout applies to each entry in turn, not to the whole |
spawn | a job handle whose completion all and race accept; takes the definition's name, not its handle |
step options
| Option | Default | Means |
|---|---|---|
retries | 1 | total attempts, not attempts after the first; 0 is read as 1 |
backoff | 2s | wait before attempt 2, doubling after that, uncapped |
timeout | none | how long one attempt may run |
| Failure | Text |
|---|---|
an attempt runs past timeout | step 'name' timed out after Nms |
| the last attempt throws | step 'name' failed after N attempts: …; the run parks as failed |
| replay meets code that differs from the journal | journal divergence: expected step, code reached step 'name' |
wf.step_id
The executing effect's name, for anything outside the platform that deduplicates: an idempotency key, a row id, an object key.
wf:step("charge", function()
return Payments("desk"):charge(sale.price, wf.step_id)
end)
The value reads <run>/<step>#<occurrence>.<try>, for example
settle-sale/sale-lot-42/charge#1.1.
| Across | The id |
|---|---|
| a crash between the effect and its recorded result | stays the same, so the repeat is recognised |
| a retry after a recorded failure | changes, because that attempt is a new effect |
| the same step name reached again (a loop) | changes, because each pass is its own effect |
| a replay of an already-recorded step | never runs the body at all |
| outside a step body | an error: wf.step_id names the effect a step is performing; it exists only inside a step body. |
Determinism
Run code replays from the journal after every park, crash or deploy. It must take the same path given the same journal.
| In run code | Behaviour |
|---|---|
os.time(), os.clock(), math.random(), uuid.v4() | journaled, replayed identically |
secret "name" | pinned to the version the run first resolved |
os.date, math.randomseed | refused |
| kv, database and object calls outside a step | refused: not available in workflow code, perform effects inside wf:step |
http, crypto, Jwt | absent, step bodies included; call an object from a step |
Example
local sales = database "sales" { migrations = "migrations/sales" }
local Account = objects "Account"
local settle = workflow "settle-sale" (function(wf, sale)
wf:step("notify", { retries = 3 }, function()
Account(sale.winner):notify("pay-up", { price = sale.price })
end)
local _, which = wf:race({ "paid", "cancelled" }, { timeout = "72h" })
if which ~= "paid" then
return { outcome = "void" }
end
local confirmations = wf:all({ "shipped", "received" }, { timeout = "7d" })
if confirmations[1] == nil or confirmations[2] == nil then
return { outcome = "disputed" }
end
wf:step("settle", { retries = 3 }, function()
sales:exec("UPDATE sales SET state = 'settled' WHERE id = ?", { sale.id })
end)
return { outcome = "settled" }
end)
Gotchas
retries = 3is three attempts in total.- Steps do not nest. One effect per step; a
wf:stepinside a step body is an error. - Renaming a step breaks runs already in flight. Replay compares the journal against the code it re-executes and fails on a mismatch rather than guessing.
- A duplicate signal is safe. An unconsumed extra is set aside instead of breaking replay, because buttons get clicked twice.