ACTIAS

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
RuleRefusal
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)
VerbReturns
stepwhatever the body returned, recorded on first run
sleepnothing, after the duration
awaitthe signal payload, or nil on timeout
racepayload, name of the first to arrive; nil, nil on timeout. Losers keep running and their signals stay consumable; an empty set is refused
allpayloads in argument order; nil per timed-out entry. The timeout applies to each entry in turn, not to the whole
spawna job handle whose completion all and race accept; takes the definition's name, not its handle

step options

OptionDefaultMeans
retries1total attempts, not attempts after the first; 0 is read as 1
backoff2swait before attempt 2, doubling after that, uncapped
timeoutnonehow long one attempt may run
FailureText
an attempt runs past timeoutstep 'name' timed out after Nms
the last attempt throwsstep 'name' failed after N attempts: …; the run parks as failed
replay meets code that differs from the journaljournal 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.

AcrossThe id
a crash between the effect and its recorded resultstays the same, so the repeat is recognised
a retry after a recorded failurechanges, 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 stepnever runs the body at all
outside a step bodyan 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 codeBehaviour
os.time(), os.clock(), math.random(), uuid.v4()journaled, replayed identically
secret "name"pinned to the version the run first resolved
os.date, math.randomseedrefused
kv, database and object calls outside a steprefused: not available in workflow code, perform effects inside wf:step
http, crypto, Jwtabsent, 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 = 3 is three attempts in total.
  • Steps do not nest. One effect per step; a wf:step inside 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.
  • Workflows: when to use one at all.
  • queue: the anonymous alternative.