Tokens & sequence flow: a process engine in 100 lines
Motto — A process is a graph; executing it means moving tokens along the edges until they’re consumed.
Part of Phase 01 — BPMN & the token model. Concept reading: Principles 1 & 3.
The Problem
Your leave-approval flow lives in code. A controller sets status = "PENDING", a
different service checks it, and someone adds an if for the escalation case.
Within a year, nobody can say what the actual process is without reading four
repositories. The business asks “where is Priya’s request stuck?” and the answer
is a database query and a shrug.
A process engine’s first promise is that the flow is explicit: a graph you can draw, deploy, and interrogate — “the token is sitting at Manager Approval, and has been for three days.” Before Flowable’s version of that can feel obvious rather than magical, you need to build the execution model it rests on. That model is smaller than you think.
The Concept
A BPMN model, stripped of its notation, is a directed graph. Nodes are events, tasks, and gateways. Edges are sequence flows. Execution is a token: it’s born at the start event, moves along flows, and is consumed at an end event. The instance is complete when no tokens remain.
<style> .dgm-tsf{background:var(–color-surface);border:1px solid var(–color-border);border-top:3px solid var(–color-border-danger-outline); border-radius:16px;padding:28px 26px 22px;box-shadow:0 1px 3px rgb(var(–shadow-color) / 0.04),0 8px 24px rgb(var(–shadow-color) / 0.04); margin:26px 0;max-width:100%;font-family:inherit;text-align:left;position:relative} .dgm-tsf-chip{position:absolute;top:-11px;left:24px;background:var(–color-background-danger-solid);color:var(–white); font-size:10.5px;font-weight:700;letter-spacing:1px;text-transform:uppercase; border-radius:20px;padding:3px 12px;box-shadow:0 2px 6px color-mix(in oklab, var(–color-background-danger-solid) 28%, transparent)} .dgm-tsf h4{margin:0 0 4px;font-size:11px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:var(–color-text-secondary)} .dgm-tsf-sub{font-size:12px;color:var(–color-text-secondary);margin:0 0 16px} .dgm-tsf-row{display:flex;align-items:center;justify-content:center;gap:6px;flex-wrap:wrap} .dgm-tsf-n{background:var(–color-surface-tertiary);border:1px solid var(–color-border);border-radius:9px;padding:9px 11px;text-align:center; font-size:10.5px;font-weight:700;color:var(–color-text);line-height:1.3} .dgm-tsf-n.accent{background:var(–color-background-danger-surface);border-color:var(–color-border-danger-surface);color:var(–color-text-danger)} .dgm-tsf-n.blue{background:var(–color-background-info-surface);border-color:var(–color-border-info-surface);color:var(–color-text-info)} .dgm-tsf-n.green{background:var(–color-background-success-surface);border-color:var(–color-border-success-surface);color:var(–color-text-success)} .dgm-tsf-n.red{background:var(–color-background-danger-surface);border-color:var(–color-border-danger-surface);color:var(–color-text-danger)} .dgm-tsf-arr{color:var(–color-text-tertiary);font-size:12px;display:flex;flex-direction:column;align-items:center;gap:1px} .dgm-tsf-arr .lbl{font-size:8.5px;font-weight:700;color:var(–color-text-tertiary);text-transform:uppercase;letter-spacing:.3px} @media (max-width:640px){.dgm-tsf{padding:20px 16px 18px}.dgm-tsf-row{flex-direction:column}} </style> <div class=“dgm-tsf”> <span class=“dgm-tsf-chip”>Tokens & sequence flow</span> <h4>A token moves left to right until it’s consumed</h4> <p class=“dgm-tsf-sub”>This whole model is just five boxes and four arrows.</p> <div class=“dgm-tsf-row”> <div class=“dgm-tsf-n blue”>● start</div> <span class=“dgm-tsf-arr”>→</span> <div class="dgm-tsf-n ">Record request</div> <span class=“dgm-tsf-arr”>→</span> <div class=“dgm-tsf-n accent”>Manager approval 👤</div> <span class=“dgm-tsf-arr”>→</span> <div class="dgm-tsf-n ">Notify employee</div> <span class=“dgm-tsf-arr”>→</span> <div class=“dgm-tsf-n green”>● end</div> </div> </div>
Two node behaviours matter today:
- Automatic nodes (start, service tasks, end): the engine executes them and moves the token on immediately. It never stops on its own account.
- Wait states (the user task 👤): the token stops. The engine has done all it can. The world must act — a human completes the task — before the token moves again.
A process engine only ever does one of two operations: advance tokens until every one is asleep or consumed, or wake a specific token when the world provides what it was waiting for.
Build It
No engine, no XML — just plain Python, so the semantics are undeniable. Here is the
heart of code/token_engine.py:
def advance(inst):
"""Move every token forward until it reaches a wait state or is consumed."""
progressed = True
while progressed:
progressed = False
surviving = []
for at in inst.tokens:
node = inst.process.nodes[at]
if node.kind == "user":
surviving.append(at) # wait state: token sleeps
elif node.kind == "end":
progressed = True # token consumed
else:
if node.kind == "service" and node.handler:
node.handler(inst.variables)
surviving.append(inst.process.next_of(at)[0])
progressed = True
inst.tokens = surviving
And the wake-up half:
def complete_user_task(inst, name, variables=None):
assert name in inst.tokens, f"no token waiting at {name!r}"
inst.variables.update(variables or {})
inst.tokens[inst.tokens.index(name)] = inst.process.next_of(name)[0]
advance(inst)
Run it:
$ python3 token_engine.py
tokens after start: ['approve'] ← the engine slept at the user task
tokens after approval: [] ← complete; the log shows every step
Note what start() did not do: it did not block a thread waiting for the
manager. It advanced as far as it could, then returned. That single design choice
— covered properly in
Phase 2
— is why an engine can hold a million in-flight instances on one box.
Use It
In Flowable, the same two operations have names you’ll use constantly:
// advance-until-asleep, from the start event:
runtimeService.startProcessInstanceByKey("leaveRequest", variables);
// wake a specific token:
taskService.complete(taskId, Map.of("approved", true));
Ask the engine where the tokens are:
runtimeService.createExecutionQuery()
.processInstanceId(id).list(); // executions ≈ our tokens list
You’ll run exactly this over REST in lesson 04, against a model you write by hand in lesson 03.
Ship It
This lesson ships outputs/token_engine.py — the toy
engine, packaged as a reusable module. Phase 2 extends it with persistence, and
the gateway lesson next door teaches it to fork.
Check Yourself
Q1. A token reaches a user task. What does the engine do?
- A) blocks a thread until the task is completed
- B) persists the token’s position and returns; nothing runs until the task is completed
- C) polls the assignee every few seconds
- D) fails after a timeout
<details><summary>Answer</summary>B — a user task is a wait state. The engine advances as far as it can, then sleeps. Completion is a separate, later interaction.</details>
Q2. When is a process instance complete?
- A) when the token reaches the last task
- B) when every token has been consumed by an end event
- C) after a configurable timeout
- D) when the start event is re-triggered
<details><summary>Answer</summary>B — no tokens, no instance. With parallel branches (lesson 02) there can be several tokens, and all of them must be consumed.</details>
Q3. In the Build It engine, what distinguishes a service task from a user task?
- A) service tasks have handlers; user tasks are wait states the engine won’t cross on its own
- B) service tasks run in a thread pool
- C) user tasks can’t read variables
- D) nothing — they’re interchangeable
<details><summary>Answer</summary>A — the engine executes a service task’s handler and
moves on immediately. A user task parks the token until complete_user_task is
called.</details>
Challenge. Add a script node kind that evaluates a Python expression against the
variables and stores the result — you’ve just rebuilt BPMN’s script task. Then make
advance raise a clear error when a node has zero outgoing flows and isn’t an end
event — you’ve just rebuilt model validation.
