Core Concepts — Runs, Tasks, Events, Leases and the Outbox
Building a Distributed Workflow Engine·Part 2 of 11·7 min read

Core Concepts — Runs, Tasks, Events, Leases and the Outbox

On this page · 9 sections
  1. Definition, run, node, task
  2. Three kinds of nodes
  3. Events and actions
  4. Functional core, imperative shell
  5. One lock per run
  6. Leases: one executor at a time
  7. Fencing with the attempt number
  8. The transactional outbox
  9. At-least-once execution

Before you write code, learn the handful of ideas the whole engine is built on. Each one solves one specific problem. Once you see the problem, the idea feels obvious.

Definition, run, node, task

A definition is a static graph; a run created from it has one task per reached node: fetch and is_large completed, approval waiting, ship not reached yet definition “order” v3 · the recipefetchis_largeapprovalshipstart runrun 0192f… · one executionfetch COMPLETEDis_large COMPLETEDapproval WAITINGship (no task yet)nodes are static · tasks are per run
The recipe and one execution of it
Term Meaning
Definition A versioned graph of nodes and edges, written in YAML. One version per workflow is deployed.
Run One execution of a definition version with its own input. A run is pinned to the version it started on, so deploying v4 never changes a run that started on v3.
Node A step in the graph: "call this URL", "run this Python", "branch here".
Task What a node becomes inside a run. When a run reaches a node, it creates at most one task for that node. The task has a status, an attempt number, an input and an output.

Rule to remember: nodes are static, tasks are per run. The database enforces "one task per node per run" with a unique index on (run_id, node_id).

Three kinds of nodes

Not every node needs a worker:

Three kinds of nodes: worker nodes go to a worker over a queue, control nodes are resolved by the coordinator, wait nodes are parked in the database worker nodesHTTP · CODEsent to a workerover a queuecontrol nodesSWITCH · FORK · JOINresolved instantlyby the coordinatorno I/Owait nodesWAIT · WAIT_FOR_EVENTparked in the databaseuntil a timer firesor an event arrives
Only worker nodes ever leave the coordinator

Keeping control and wait nodes inside the coordinator means a SWITCH costs no network hop, and a two-day WAIT costs only a database row.

Events and actions

Everything that happens to a run is an event:

Event What happened
RunStarted a client started the run
NodeCompleted a worker finished a task attempt
NodeFailed a worker's task attempt failed
TimerFired a WAIT's timer (or a WAIT_FOR_EVENT's timeout) came due
SignalReceived an external event arrived for the run
RunRetried a user restarts a finished run from some node
RunCancelled a user cancels the run

The engine reacts to one event at a time and produces a list of actions — the changes that must be saved:

The state machine turns NodeCompleted(fetch) into CompleteTask(fetch), CreateTask(is_large, COMPLETED) and CreateTask(approval, WAITING) NodeCompletedfetchstate machinedecideeventCompleteTask(fetch)CreateTask(is_large, COMPLETED)CreateTask(approval, WAITING)actions, in ordership gets no task yet: its edge from approval is still open
One event in, an ordered list of actions out

This split — what happened in, what to change out — is the heart of the design.

Functional core, imperative shell

The component that turns events into actions is a pure function in spirit. It does not open database connections, read the clock, or send messages. It gets everything it needs passed in.

The imperative shell begins a transaction, locks and loads the run, calls the pure Machine.Apply, then writes the actions and commits imperative shell · pkg/coordBEGIN · lock run · load snapshotfunctional core · pkg/statemachineMachine.Apply(event) → []Action · no I/O, no clockwrite actions as SQL + outbox · COMMITevent
All decisions in a pure core; all I/O in the shell around it

Why bother?

  • Testability. You can test "a join waits for both branches" with a few lines and no database.
  • Atomicity. The shell writes all actions in one transaction. Either the whole decision is saved, or none of it is.
  • Clarity. All workflow rules live in one small package. All SQL lives in another.

One lock per run

Two workers can finish two branches of the same run at the same instant. If both events are processed in parallel, each might see the other branch as still running, and neither would start the join node. The run would hang.

The fix is simple: process events for one run one at a time. The shell takes a row lock on the run (SELECT … FOR UPDATE) at the start of every transaction. Different runs proceed in parallel; events within a run are serialized.

Worker B's event waits for the run lock held by worker A; after A commits, B sees branch A done and starts the join worker Abranch_a doneworker Bbranch_b donelock rundecidecommitlock runwaits for the lockdecide: sees A donestart joinlock releasedtime
The run row lock serializes events of one run

Because the lock lives in PostgreSQL, the coordinator itself holds no state. You can run as many coordinator replicas as you like.

Leases: one executor at a time

A task message can be delivered to a worker more than once (a worker crashed, a message was reclaimed). You must not let two workers execute the same task at the same time.

Before executing, a worker asks the coordinator for a lease: "I, worker W, own task T for the next 90 seconds." The lease is a pair of columns on the task row (lease_owner, lease_expires_at). Only one worker can hold it. While working, the worker renews it every 30 seconds. If the worker dies, the lease expires and another worker may take over.

Worker A acquires and renews the lease, then crashes; the lease expires and worker B acquires it, runs the task and reports worker Aworker Bacquirerenewrenewcrashlease held by A (renewed every TTL/3)expiresacquirerunreporttime
A lease that is not renewed expires, and another worker may take over

Fencing with the attempt number

Leases stop concurrent execution. They do not stop a late report. Picture this: worker A is slow, its lease expires, worker B re-runs the task and reports success, the task retries for some other reason, and then A's ancient report arrives.

Each task carries an attempt number. Every dispatch, lease and report includes it. The engine applies a result only if (task_id, attempt) matches the task's current state. Anything else is ignored. The attempt number acts as a fencing token.

The transactional outbox

When the state machine decides "dispatch task T", two things must happen:

  1. Save the task row in PostgreSQL.
  2. Publish a message to Redis so a worker picks it up.

If you do them separately, a crash in between leaves you with a task nobody will run (saved, not published) or a message for a task that does not exist (published, rolled back). There is no transaction that spans PostgreSQL and Redis.

The outbox pattern fixes this. Instead of publishing directly, you insert an outbox row in the same transaction as the task row. A separate loop reads committed outbox rows and publishes them to Redis, deleting each row after a successful publish.

The task row and the outbox row are written in one transaction; after commit the enqueuer publishes the row to Redis one transactionINSERT task_instances (…)INSERT outbox (stream, payload)COMMITenqueuerXADDRedisworst case: a duplicate publish, dropped by the lease
Write the message in the same transaction, publish it after commit

The worst case is now a duplicate publish (the enqueuer crashes after publishing but before deleting the row). Duplicates are harmless because of the lease.

At-least-once execution

Put the pieces together and you get the engine's execution guarantee:

  • A step always eventually runs (outbox + recovery).
  • A step never runs twice concurrently (lease).
  • A step may run again after its previous attempt's lease was lost (crash).

That is at-least-once. Side-effecting steps should be idempotent. With these ideas in hand, set up the project.