Skip to content
Technical preview. This site is published for review. Everything on it, including the API, tokens and module protocol, is subject to change.
Life of a workflow

Life of a workflow

Everything below happens inside AutoFlow. The workflow in mind is the one from the policy enforcement guide: it asks the policy module, waits for an approval, then acts.

Eight steps

  1. Someone starts the workflow. A caller sends the workflow definition, its arguments, a namespace and an idempotency key to the gRPC API. AutoFlow records the new workflow and answers with a workflow key and a workflow token. The workflow is durable from this moment: a crash right after the call does not lose it. Sending the same key again returns the same workflow instead of a second one.

  2. An AutoFlow instance picks it up. Each instance owns a slice of the workflow keyspace. The owner of this one takes the pending work item (a workflow task) and starts the first round.

  3. The workflow loads its modules. The first thing the run does is resolve every load() in the script, once, as a recorded step (the LoadModules activity). The workflow waits for the answer and the round ends. When the answer is recorded, it wakes the workflow for the next round.

  4. The script runs, from line 1. With the modules known, the interpreter runs the script’s top level and calls main(). From here on the script drives.

  5. An action call becomes a recorded step. When the script calls an action such as evaluate(...) and waits for it with gather(), the call is recorded as scheduled (an activity), a work item for an activity worker is queued, and the workflow goes to sleep. A worker runs the action with a stable idempotency key, records the result, and wakes the workflow again.

  6. Waiting costs nothing. sleep() records a due time and the workflow goes to sleep holding no resources; when the time comes, the timer fires into history and wakes it. Waiting on a channel works the same way: a value sent from outside lands in an inbox, is recorded in history as a signal, and wakes the workflow. A two-day wait for an approval holds nothing in memory for two days.

  7. The workflow finishes. main() returns, or fails. The result and the final state are recorded in one transaction, together with the cleanup of anything the workflow still had scheduled.

  8. The result is read back. The caller presents the workflow token to GetWorkflow and reads the state and the result. There is no callback; the caller polls.

    sequenceDiagram
  participant G as Caller
  participant F as AutoFlow
  participant H as History (PostgreSQL)
  participant M as Module

  G->>F: Start this workflow
  F->>H: Record workflow created
  F-->>G: Workflow key and token
  F->>H: Record action scheduled
  Note over F: The workflow sleeps, holding no resources
  F->>M: Run the action
  M-->>F: Result
  F->>H: Record action finished, with the result
  Note over F,H: The workflow wakes up and continues
  F->>H: Record workflow completed
  G->>F: How did it go?
  F-->>G: Completed, here is the result
  
For engineers

The transaction that makes a workflow real. StartWorkflow first pins (namespace_id, idempotency_key) to one (shard_id, workflow_id) in the central directory; a resubmission adopts the existing pair, which makes everything after it idempotent. Then, in a single transaction on the workflow’s shard, it inserts the Running execution row, inserts the workflow_timeout scheduled task (its database-computed fire_at is the deadline the next event records), appends the WorkflowCreated event at sequence id 1, and enqueues a pending workflow task. The caller gets its key and token after that commit.

Rounds and the yield. A round is one execution of the workflow function against history. The owning instance claims the workflow task, loads the execution row and the history in a fenced read, and runs the function in its own goroutine. When the function blocks on something unresolved (a pending action, a timer, an empty channel) it yields: the schedules it accumulated are written as history events plus their task rows, the workflow task is deleted, and the goroutine is suspended. All of that is one fenced transaction. The suspended goroutine is kept for a while (AutoFlow documents 1 minute of inactivity) so the next round can resume it in place; after that it is dropped and the next round replays from the top.

Activity tasks. An action call is ExecuteActivity(InvokeAction, ...). The yield writes an ActivityScheduled event and an activity_task row. An activity worker claims the row, runs the module call under a claim-to-complete timeout, and in one fenced transaction appends ActivityCompleted (or ActivityFailed once retries are exhausted), enqueues a new workflow task, and deletes the activity task. An action its module marks inlineable is first attempted inside the round (200 ms by default) and queued only if that fails; the script sees one future either way. Several activities of one workflow can run at once; rounds of one workflow never do.

Timers as scheduled rows. sleep(d) and timer(d) write a TimerStarted event and a scheduled_task row with a fire_at. A scanner on the owning instance picks up due rows, appends TimerFired, enqueues a workflow task, and deletes the row. A timer due within 10 minutes also arms an in-memory hint so it does not wait for the next scan; the hint is an optimization, the scanner is the guarantee.

Signals promoted into history. A value sent to a channel is one unfenced insert into signal_inbox plus a signal_delivery ledger row keyed by (workflow_id, idempotency_key), which is what makes a resend a no-op. The owning instance’s signal scanner drains the inbox in a fenced transaction, runs AutoFlow’s promotion interceptor to verify the channel token and unwrap the payload, appends one SignalReceived event per value, and enqueues one workflow task for the batch. A channel is a named stream over these events; the script reads it with gather() or select().

Terminal. When main() returns, one transaction appends the terminal event, sets the execution state, deletes the workflow task, purges the workflow’s remaining scheduled tasks and pending activity tasks, and sends a cancel signal to any awaitable child still pending. Terminal states are Completed, Failed, Canceled, TimedOut and SystemFailed.

Next: what happens when the process running a round dies, in Crash and replay.

Last updated on