Wait states & persistence: why the engine sleeps
Motto — A process engine’s superpower isn’t running things. It’s stopping — durably, cheaply, for months — and picking up exactly where it left off.
Part of Phase 02 — The engine: state & transactions. Concept reading: Principle 3 — a state machine with a database.
The Problem
Phase 1’s engine sleeps politely at user tasks — but only in RAM. Restart the Python process and every in-flight leave request evaporates. Real processes wait for days (document upload), weeks (manager on leave), or months (mortgage). No thread, no in-memory object, no single server can be trusted to live that long. If the waiting state isn’t in a database, you don’t have a process engine — you have a demo.
The Concept
The fix is a discipline, not a feature: between wait states, the instance exists only in the database.
<style> .dgm-wsp{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-wsp-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-wsp h4{margin:0 0 4px;font-size:11px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:var(–color-text-secondary)} .dgm-wsp-sub{font-size:12px;color:var(–color-text-secondary);margin:0 0 16px} .dgm-wsp-actors{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:10px} .dgm-wsp-actor{border-radius:9px;padding:7px 6px;text-align:center;font-size:9.5px;font-weight:700} .dgm-wsp-actor.a{background:var(–color-background-info-surface);border:1px solid var(–color-border-info-surface);color:var(–color-text-info)} .dgm-wsp-actor.b{background:var(–color-background-danger-surface);border:1px solid var(–color-border-danger-surface);color:var(–color-text-danger)} .dgm-wsp-actor.c{background:var(–color-background-success-surface);border:1px solid var(–color-border-success-surface);color:var(–color-text-success)} .dgm-wsp-msgs{display:flex;flex-direction:column;gap:5px} .dgm-wsp-msg{display:grid;grid-template-columns:20px 1fr;gap:8px;align-items:start;font-size:10.5px; color:var(–color-text);line-height:1.35;padding:7px 10px;border-radius:8px;background:var(–color-surface-tertiary)} .dgm-wsp-msg .tag{font-size:9px;font-weight:700;color:var(–color-text-tertiary)} .dgm-wsp-msg.note{display:block;background:var(–color-background-danger-surface);color:var(–color-text-danger);font-style:italic;text-align:center} @media (max-width:640px){.dgm-wsp{padding:20px 16px 18px}.dgm-wsp-actors{grid-template-columns:1fr 1fr}} </style> <div class=“dgm-wsp”> <span class=“dgm-wsp-chip”>Wait states are rows, not threads</span> <h4>Nothing is ‘running’ between requests — the database is the only thing that remembers</h4> <p class=“dgm-wsp-sub”>A wait state survives a restart because it was never held in memory.</p> <div class=“dgm-wsp-actors”> <div class=“dgm-wsp-actor a”>Client</div> <div class=“dgm-wsp-actor b”>Engine (any node)</div> <div class=“dgm-wsp-actor c”>Database</div> </div> <div class=“dgm-wsp-msgs”> <div class=“dgm-wsp-msg”><span class=“tag”>1</span><span>Client → Engine: start instance</span></div> <div class=“dgm-wsp-msg”><span class=“tag”>2</span><span>Engine advances tokens until wait state</span></div> <div class=“dgm-wsp-msg”><span class=“tag”>3</span><span>Engine → DB: INSERT execution rows (tokens + variables)</span></div> <div class=“dgm-wsp-msg”><span class=“tag”>4</span><span>Engine → Client: return (nothing is running now)</span></div> <div class=“dgm-wsp-msg note”>Hours or days pass — the engine may restart, scale, or redeploy</div> <div class=“dgm-wsp-msg”><span class=“tag”>5</span><span>Client → Engine: complete task</span></div> <div class=“dgm-wsp-msg”><span class=“tag”>6</span><span>Engine → DB: SELECT execution rows (rehydrate)</span></div> <div class=“dgm-wsp-msg”><span class=“tag”>7</span><span>Engine advances tokens until the next wait state</span></div> <div class=“dgm-wsp-msg”><span class=“tag”>8</span><span>Engine → DB: UPDATE / DELETE rows</span></div> <div class=“dgm-wsp-msg”><span class=“tag”>9</span><span>Engine → Client: return</span></div> </div> </div>
This discipline has consequences, and you’ll rely on all of them later:
- Restart-safety for free. Nothing in memory matters between calls, so crashes and deploys lose nothing.
- Any node can serve any instance. State lives in shared tables, so a cluster is just several engines pointing at one schema. (The job executor’s locking — lesson 04 — stops two nodes from grabbing the same timer job.)
- A million in-flight instances ≈ a million rows, not a million threads. Waiting is the cheapest thing the engine does.
- Completed instances vanish from runtime tables. Live state stays small and fast; the audit trail lives in separate history tables (Phase 9).
In Flowable these rows are the ACT_RU_* tables (RU = runtime): ACT_RU_EXECUTION
(our tokens), ACT_RU_TASK (open user tasks), ACT_RU_VARIABLE (variables),
ACT_RU_JOB (lesson 04’s jobs).
Build It
code/persistent_engine.py adds a SQLite store to
the Phase-1 engine. The whole idea lives in the save/load pair plus one rule —
save on every sleep, delete on completion:
def _save(self, inst_id, key, tokens, variables):
if tokens:
self.conn.execute(
"INSERT OR REPLACE INTO instances VALUES (?,?,?,?)",
(inst_id, key, json.dumps(tokens), json.dumps(variables)))
else: # complete: runtime rows are deleted (history is Phase 9)
self.conn.execute("DELETE FROM instances WHERE id=?", (inst_id,))
self.conn.commit()
And complete_task is rehydrate → advance → sleep again:
def complete_task(self, inst_id, task, extra_vars=None):
key, tokens, variables = self._load(inst_id) # rehydrate
...
tokens, variables = self._advance(process, tokens, variables)
self._save(inst_id, key, tokens, variables) # sleep again (or finish)
The demo stages a server death between start and approval:
$ python3 persistent_engine.py
started 5b9c63b3 - sleeping at ['approve']
← engine_a deleted here: the "crash"
after approval: instance complete ← engine_b, a fresh object, resumed it
runtime rows remaining: 0 ← completed instances leave no runtime state
engine_b never saw the start call, and it didn’t need to. The database is
the instance.
Use It
Flowable’s version of the demo, no code required: start the Docker engine, start a
loanTriage instance with a low score (it parks at manual review), then
docker restart the container. The task is still there:
docker restart <container>
curl -u rest-admin:test \
"http://localhost:8080/flowable-rest/service/runtime/tasks"
# → Manual credit review, exactly where it was
If you point the container at a real PostgreSQL instead of its default in-container DB, you can even destroy the container entirely and start a new one. Instances survive, because instances are rows.
Ship It
This lesson ships the persistent engine as a module:
code/persistent_engine.py. Lesson 04’s job
executor builds directly on its store.
Check Yourself
Q1. Between a token arriving at a user task and someone completing it, what is the engine doing for that instance?
- A) holding a lightweight virtual thread
- B) polling the task table
- C) nothing — the instance exists only as database rows
- D) heartbeating to keep the instance alive
<details><summary>Answer</summary>C — this is the whole point. Waiting consumes
zero compute. The instance is just rows in ACT_RU_* until the next
stimulus.</details>
Q2. Two engine nodes share one database. Node A started an instance; node B gets the complete-task call. What happens?
- A) node B proxies the call to node A
- B) node B rehydrates the instance from the shared tables and advances it — normal operation
- C) an error: instances are pinned to their starting node
- D) the instance is migrated first
<details><summary>Answer</summary>B — state in shared tables means no node affinity. This is why “clustering Flowable” mostly means “point engines at the same schema”.</details>
Q3. Why does completing the last task delete the runtime row instead of flagging it done?
- A) to save disk
- B) runtime tables must stay small and hot — finished instances belong to history tables
- C) regulatory requirement
- D) it doesn’t; rows are kept forever
<details><summary>Answer</summary>B — the runtime/history split keeps the tables the engine touches on every step small. Audit questions go to history (Phase 9).</details>
Challenge. Add optimistic locking: a revision column that increments on
every save, with UPDATE ... WHERE id=? AND revision=? failing if another
transaction got there first. Simulate two racing complete_task calls and watch
one lose. You’ve just rebuilt Flowable’s ACT_RU_EXECUTION.REV_ column and its
FlowableOptimisticLockingException.
Related
- Next: Process variables & scope
- Concept: Principle 3
