Code a workflow engine
Open in the interactive app: practice, quiz, mock interview →In one minute
- A workflow engine is a durable state machine for a multi-step business process. The hard part is not running step one then step two; it is that the process, the machine and the network can each fail halfway, and the engine must still end in a state you can explain.
- Persist progress after every attempt, in a store the engine does not own, so a restarted engine picks up exactly where the last one stopped. Memory is a cache of the record, never the record.
- Retries need a policy (attempts, exponential delay, a cap, jitter) and a rule for which failures are worth retrying. Retrying a permanent error only delays the bad news.
- A saga replaces a distributed transaction: when a step fails for good, run the undo action of every completed step in reverse order, and treat each undo as a retried, idempotent step in its own right.
- The shape is multi-step consistency with partial failure and duplicates. It reappears in order checkout (reserve, charge, ship), cloud provisioning (network, disk, VM) and AI agent runs (plan, call tools, write results).
Requirements
Functional:
- A workflow is an ordered list of steps. Each step has an action, an optional compensation (undo), a retry policy and an optional timeout.
start(id)creates a run;run(id)performs whatever attempts are due and returns the run's status.- A failed attempt is retried after an exponentially growing, capped, optionally jittered delay, up to a maximum number of attempts. A step may fail permanently, which skips the remaining retries.
- A step that exceeds its timeout counts as failed, and its late result is ignored.
- When a step fails for good, the engine compensates completed steps in reverse order and ends in a terminal status.
- Progress is saved to a store after every attempt. A new engine over the same store resumes the run without redoing finished steps.
Non-functional:
- Deterministic and testable: time and randomness are injected, and the engine never sleeps.
- Every action receives a stable idempotency key, because delivery is at least once.
- Every run ends in
COMPLETED,ROLLED_BACKorCOMPENSATION_FAILED, and the history says why.
Out of scope: parallel branches, timers and signals, sub-workflows, versioning of running definitions, and a real database. Mention them in Extensibility.
Entities and relationships
- Step is a value: a name, an
action, an optionalcompensate, and the policy knobsmax_attempts,base_delay,factor,max_delayandtimeout. Resist a subclass per step type. What differs between steps is the function they call, so a function-valued field carries it. - Engine owns the definition (the ordered steps), the clock, the random source and a store. It holds no run state in its fields, which is what makes a restart safe.
- Run record is the persisted state of one workflow id:
status,indexof the next step,attemptcount for the current step,next_at(when the next attempt is due), thedonestack of completed step indices and ahistoryof outcomes. It is plain data that serialises to JSON. - Store has
load(id)andsave(id, record). In production it is a table row or a document; here it is in memory. The engine treats it as the single source of truth. - Clock supplies integer milliseconds. Rng supplies a fraction used for jitter.
The status field is the state machine. RUNNING moves to COMPLETED when the last step succeeds, or to COMPENSATING when a step fails for good. COMPENSATING moves to ROLLED_BACK when the done stack is empty, or to COMPENSATION_FAILED when an undo exhausts its attempts. Three states are terminal, and nothing leaves them. Within RUNNING, a failed attempt does not change status; it moves next_at into the future.
The engine is a composition: it owns its steps, and it uses the store, clock and rng through narrow interfaces, so each can be replaced by a fake.
Class diagram
classDiagram
class Engine {
-steps: Step[]
+start(id)
+run(id) Status
+status(id) Status
+history(id) string[]
+nextRunAt(id) int
}
class Step {
+name: string
+action(key)
+compensate(key)
+maxAttempts: int
+baseDelay: int
+factor: float
+maxDelay: int
+timeout: int
}
class RunRecord {
+status: Status
+index: int
+attempt: int
+nextAt: int
+done: int[]
+history: string[]
}
class Store {
<<interface>>
+load(id) RunRecord
+save(id, record)
}
class Clock {
<<interface>>
+now() int
}
Engine "1" *-- "many" Step
Engine --> Store
Engine --> Clock
Store "1" o-- "many" RunRecord
MemoryStore ..|> Store
FakeClock ..|> ClockKey flows
One call to run loops: load the record, stop if it is terminal or not yet due, otherwise perform a single attempt, save, and repeat. The sequence below shows a step that fails once, then succeeds after its backoff, followed by a later step that fails permanently and triggers compensation.
sequenceDiagram
participant C as Caller
participant E as Engine
participant S as Store
participant A as Step action
C->>E: run(w)
E->>S: load(w)
E->>A: action(key)
A-->>E: error
E->>S: save next_at = now + delay
E-->>C: RUNNING
C->>E: run(w) after the delay
E->>S: load(w)
E->>A: action(key)
A-->>E: ok
E->>S: save index + 1
Note over E,A: next step fails with a permanent error
E->>S: save status COMPENSATING
E->>A: compensate previous step
A-->>E: ok
E->>S: save ROLLED_BACK
E-->>C: ROLLED_BACKNote that run never sleeps. A waiting run just returns RUNNING with a next_at, and something else (a scheduler, a timer wheel, a test) calls it again. That separation is the reason the engine is testable.
Design patterns used and why
- State for the run lifecycle, kept light: a status enum with guarded transitions inside one method. A class per state would spread six lines of logic across four files.
- Strategy for the retry policy. In the practice it is five fields on
Step; in a larger engine it becomes an object (fixed, exponential, decorrelated jitter) behind onedelay(attempt)method. Delay and "is this error retryable" are the two things teams tune. - Memento for the run record: a snapshot of everything needed to resume, saved after each attempt. It is the same idea as a database checkpoint.
- Command for compensation. Each step pairs a do with an undo, and the
donestack is a command history that unwinds in reverse. See the saga pattern for the distributed version. - Dependency injection for the clock, rng and store. The test suite runs an hour of retries in microseconds because of it.
Concurrency and edge cases
- Two engines, one run. After a restart or a rolling deploy, two workers may both load the same record and both run the next attempt. Give each record a version and save with compare-and-set (
UPDATE ... WHERE version = $v), or lease the run to one worker. Without it, an action runs twice concurrently. - Crash between action and save. The action's effect happened, but the record still says it is pending. On resume the engine runs it again. That is at-least-once, and it is why every action gets a stable key (
workflow id : step name) that the downstream service uses to deduplicate. The AWS Builders' Library puts it plainly: an API with side effects is not safe to retry unless it is idempotent (Amazon Builders' Library). - Timeouts do not cancel. A timeout means the engine stopped waiting, not that the action stopped. The late call may still succeed, so a timed-out step's retry must be as idempotent as any other. In this design the late result is discarded and the attempt counts as failed.
- Permanent versus transient. Retrying "insufficient funds" three times wastes time and, for a side-effecting call, risks harm. Let actions signal permanent failure explicitly. Temporal makes the same split with non-retryable error types in the retry policy (Temporal docs).
- Compensation can fail. An undo is a call to another system and can time out too. Retry it with the same policy. When it still fails, stop in
COMPENSATION_FAILEDand alert a human; silently skipping an undo leaves the world half-changed with no record. Azure's guidance adds that compensations should be idempotent commands because they may run more than once (Azure Architecture Center). - The failed step itself. A step that fails is not in
done, so it is not undone. If it can half-succeed, its action must clean up after itself. - Clocks.
next_atis a wall-clock timestamp saved in the store, so it must come from a clock all workers agree on within the smallest delay. Durations inside one attempt (the timeout) should use a monotonic reading where the platform has one.
In the wild
- AWS, 2011: the EBS re-mirroring storm. A network change cut Elastic Block Store nodes off from their replicas. When connectivity returned, the nodes all searched for free space to re-mirror at once, exhausted the cluster's capacity, and, by AWS's own account, did not back off aggressively enough when they could not find space, so the searching itself made the shortage worse. About 13% of volumes in the zone were stuck at the peak, and recovery took days. AWS changed the retry logic to back off harder and reconnect to the previous replicas first (AWS post-event summary).
- Uber, 2023: Cadence after six years. Uber's open-source workflow engine handled more than 12 billion executions and 270 billion actions a month for over 1,000 services. Its feature list is this lesson's scope at scale: automatic retries, region failover, and resets that rewind an execution to an earlier point after a bad deploy (Uber Engineering).
- Jepsen on TiDB, 2019: automatic retries broke transactions. Jepsen found read skew and lost updates in TiDB 2.1.7 because two automatic retry paths re-applied writes from conflicting transactions, with one path returning reads from the aborted attempt (a set test lost 64 of 378 inserts). TiDB 3.0.0-rc.2 turned automatic retries off by default. A retry that is not idempotent is a second write (Jepsen).
Down the stack
Layer 1The contract
run(id) returns the status after performing every attempt due at the current clock time. It never performs an attempt before next_at. For a step with maxAttempts = n, the action is called at most n times in one forward pass, at least once per resume in the worst case, and never after the step has succeeded and been saved. If a step fails for good, undo actions run in reverse order of completion, each at most maxAttempts times, and the failed step itself is not undone. A terminal status is final. Each attempt appends exactly one name:outcome:n entry to the history, so the audit trail matches the calls made.
Layer 2The mechanism
The delay after the nth failure is min(max_delay, base * factor^(n-1)), multiplied by a random fraction when jitter is on. AWS's analysis of contention found that exponential backoff alone still clusters retries and that adding jitter spreads them and reduces total work (AWS Architecture Blog). Real engines expose the same four knobs. Temporal defaults to a 1 second initial interval, a coefficient of 2.0, a cap of 100 times the initial interval, and unlimited attempts (Temporal docs). AWS Step Functions defaults to IntervalSeconds 1, MaxAttempts 3 and BackoffRate 2.0, and its JitterStrategy defaults to NONE (Step Functions docs). A timeout in Step Functions is itself an error name, States.Timeout, which a Retry block can match.
Where this lesson stores a record and rewrites it, Temporal and Cadence store an append-only event history and rebuild state by replaying workflow code against it, which forces the code to be deterministic. Temporal caps one execution at 51,200 events and offers Continue-As-New to start a fresh history (Temporal docs). The record design is simpler; the log gives audit and rewind. The saga idea itself is older: Garcia-Molina and Salem proposed splitting a long transaction into sub-transactions, each with a compensating one, in 1987 (Sagas, SIGMOD 1987).
Layer 3The machine
A save is the expensive operation. On Postgres a commit waits for the write-ahead log fsync, roughly 0.1 to 1 ms on NVMe and several milliseconds on network storage, so a five-step workflow with no failures costs five commits, a few milliseconds of engine overhead beside steps that make network calls of 10 to 500 ms. Saving after every attempt is therefore cheap relative to the work and buys exactness at restart. A record of this shape is a few hundred bytes of JSON, so 10 million active runs is a few gigabytes. The scheduling side is the costlier part: finding runs whose next_at has passed needs an index on (status, next_at) and a poller, or a delay queue, so due runs are found in O(log n) instead of a table scan. The poller must tolerate the same row being claimed twice, which returns to compare-and-set.
Why it's built this way
- Business processes touch several systems that cannot share one transaction, so the engine owns the "what happened so far" record and compensation replaces rollback.
- Processes and machines crash mid-step, so state is saved after every attempt to a store outside the process.
- A crash between an effect and its save cannot be eliminated, so delivery is at-least-once and steps carry idempotency keys.
- Failures are often correlated overload, so retries use capped exponential delay with jitter, and permanent errors skip retries.
- Waiting must not tie up a thread, so the engine records
next_atand returns, and a clock and rng injected from outside keep all of it testable.
Extensibility
- Parallel branches: replace
indexwith a set of ready steps and a dependency graph.donestays a stack of completions for reverse compensation, ordered by completion time. - Timers and human approval: a step that returns "wait until T" or "wait for signal X" writes
next_ator a pending-signal field. The scheduler wakes the run; the engine is unchanged. - Versioning: store the definition version on the record at
start, and keep old definitions loadable until their runs finish. Changing step order under a live run is the classic way to corrupt one.
Leadership lens
- Build first: the persisted record, the retry timing and the reverse-order compensation, with the fake clock tests. Dashboards and a designer UI can wait; a run that loses its place cannot be repaired later.
- Ask in review: what makes each action idempotent; what happens on a crash between an effect and the save; who is paged when a run reaches
COMPENSATION_FAILED; how a definition changes while runs are live. - Operational cost: a stuck-run view (status, step, attempts, next_at, last error) and a way to retry, skip or cancel one run will save more hours than any feature. Buy an engine like Temporal, Step Functions or Cadence unless your needs are small; build your own only for narrow, in-process cases.
- Ownership: the platform team owns the engine and its guarantees; each product team owns its steps and, critically, their compensations.
Check yourself
A worker crashes after the payment step charged the card but before it saved progress. What happens on restart, and what prevents a double charge?
Show answer
The record still says the payment step is pending, so the engine runs it again (at-least-once). The action sends a stable idempotency key derived from the workflow id and step name, and the payment service returns the first result for a repeated key.
Why does the engine record
next_atand return instead of sleeping for the backoff?Show answer
Sleeping holds a thread and is lost on crash. A saved
next_atsurvives restarts, frees the worker, and lets tests drive time with a fake clock.Why add jitter to exponential backoff, and what does it cost?
Show answer
Without it, many failed runs retry at the same instants and re-create the overload. Jitter spreads them out. The cost is a less predictable individual delay and harder exact-timing tests, solved by an injected rng.
Step 4 of 5 fails permanently. Which compensations run, in which order, and is step 4's undo among them?
Show answer
Steps 3, 2, 1 in that order, skipping any step without a compensation. Step 4 never completed, so it is not in the done stack; if it can leave partial effects, its own action must clean up or tolerate retries.
Sources and further study
Primary
- Sagas, Garcia-Molina and Salem (SIGMOD 1987): the original proposal to split a long transaction into sub-transactions with compensating ones.
- Exponential Backoff and Jitter (AWS Architecture Blog): simulation of contending clients with and without jitter.
- Timeouts, retries, and backoff with jitter (Amazon Builders' Library): why retries need idempotency and how to bound them.
Systems docs
- Temporal: retry policies: the four retry knobs, their defaults and non-retryable errors.
- Temporal: events and event history: replay, history limits and Continue-As-New.
- AWS Step Functions: error handling:
Retry,Catch,States.Timeoutand jitter strategy in a declarative state machine. - Azure: Compensating Transaction pattern: idempotent undo steps and what to do when they fail.
Books
- Building Microservices (2nd edition), Sam Newman: chapter 6 "Workflow", on sagas, orchestration and choreography; chapter 12 "Resiliency".
- Release It! (2nd edition), Michael Nygard: chapter 4 "Stability Antipatterns" and chapter 5 "Stability Patterns", for timeouts, retries and circuit breakers.
- Designing Data-Intensive Applications (1st edition), Martin Kleppmann: chapter 7 "Transactions" and chapter 8 "The Trouble with Distributed Systems", for why distributed atomic commit is avoided.
Other curricula
- microservices.io: Saga pattern: frames sagas as orchestration versus choreography, with compensating transactions for each step.
Watch
- Applying the Saga Pattern (Caitie McCaffrey, GOTO 2015): the saga idea, compensations and the failure cases that make them hard.
- Distributed Sagas: A Protocol for Coordinating Microservices (Caitie McCaffrey, JOTB17): a later, more protocol-focused version of the same talk.
Free resources
- Saga pattern (Azure Architecture Center): orchestration and choreography trade-offs, with the problems each brings.
- Temporal: workflows: how a production engine describes durable execution.
- Circuit Breaker (Martin Fowler): the companion to retries when a dependency stays down.
- State pattern (Refactoring.Guru): the pattern behind the run lifecycle.
- Enterprise Integration Patterns: the catalogue of messaging patterns that workflow engines build on.