Accident-level scenarios: “Can the engine explain what happened?”
These scenarios answer: After something goes wrong, can the engine show exactly what happened? Each is runnable and verifiable via the Trace API and Replay.
Accident-driven example: payment timeout → worker restart → idempotent complete
Scenario: The payment task times out (or the worker crashes) before completing; after restart, the same task is fetched again and completed once. We show that history is correct and invariants hold.
What happened (timeline):
| Step | Who | Action |
|---|---|---|
| 1 | Client | POST /process-instances — start payment-flow |
| 2 | Worker A | POST .../external-tasks/fetch-and-lock — locks payment task |
| 3 | — | Worker A is killed (e.g. kill -9) or times out; does not call complete/fail |
| 4 | Engine | Lease expires (or reclaim); task becomes available again |
| 5 | Worker B (or A restarted) | POST .../external-tasks/fetch-and-lock — locks same task |
| 6 | Worker B | POST .../external-tasks/:id/complete — completes once (idempotent handler) |
What history looks like:
GET /api/v1/process-instances/:id/history returns events in order, for example:
ProcessStartedTokenArrived(at payment node)ExternalTaskLocked(worker A)- (after expiry)
ExternalTaskLocked(worker B) — same task, new lease ExternalTaskCompleted— exactly onceTokenCompleted, thenProcessCompleted
There is no duplicate ExternalTaskCompleted and no duplicate TokenCompleted for the same token.
Why invariants hold:
- Token completed once: The engine records completion only when the current lease holder calls complete; a second complete from a different worker returns 4xx with
X-Invariant-Violation: ExternalTaskLeaseConflict. - External task single owner: Only the worker that holds the lease can complete; history reflects one Locked (B) and one Completed for that task.
This single-page story is the core “accident → history → invariant” narrative. For more scenarios, see below.
1. Payment + timeout + retry + completion
Question: Payment task times out or fails; we retry and then complete. Can we see the full lifecycle?
Steps:
- Start the engine:
cargo run -p bpm-engine-server-rest - Start a process instance (payment-flow):
curl -X POST http://127.0.0.1:3000/api/v1/process-instances -H "Content-Type: application/json" -d '{"process_def_id":"payment-flow","variables":{"amount":"100"}}'
Noteinstance_id. - Run the payment worker:
cargo run -p bpm-engine-worker-sdk --example payment
The worker fetches the task, completes it; process finishes. - With retries: Deploy a process that has an external task with retries (e.g. 3). Start an instance. Call
POST .../external-tasks/:task_id/failwith the same worker_id twice (retry); then fetch-and-lock again and complete. The trace will showExternalTaskFailedtwice andExternalTaskCompletedonce.
Verify:
GET /api/v1/process-instances/:id/trace—token_timelinesshow token movement;external_task_historyshows Locked → Failed → Locked → Failed → Locked → Completed (or similar).POST /api/v1/process-instances/:id/replaythenGET /api/v1/replay/:session_id/snapshotand step through — replay reproduces the same final state.
Expected: Trace and replay give a full, ordered view of payment task lock, failures, retries, and completion.
2. Parallel fork: one branch fails before join
Question: We have a parallel fork and join; one branch fails before the join. Does the engine keep join semantics and can we see which branch failed?
Steps:
- Deploy a process with ParallelFork → two branches → ParallelJoin (e.g. one branch has an external task that you fail, the other completes).
- Start an instance. Complete one branch; fail the other branch’s external task (e.g.
POST .../external-tasks/:task_id/failuntil final fail). - The join never fires (all branches did not complete). Instance remains in a state where one token is at the join (waiting) and the failed branch has no token at the join.
Verify:
GET /api/v1/process-instances/:id—tokensshow which nodes still have tokens; status reflects the failed branch.GET /api/v1/process-instances/:id/trace—token_timelinesshow each branch’s events; you see one branch withTokenFailed/ExternalTaskFailedand the other with completion.- Replay: replay the instance; snapshot at the end matches the current state (join not completed, one branch failed).
Expected: No partial join; trace and replay show exactly which branch failed and where tokens are.
3. Worker crash → lock expiry → reclaim → another worker takes the task
Question: Worker A locks a task and crashes; lock expires; worker B fetches the same task. Can we see Locked (A) → expiry/reclaim → Locked (B) → Completed?
Steps:
- Start the engine. Start an instance with an external task (e.g. payment-flow).
- Start worker A:
cargo run -p bpm-engine-worker-sdk --example duplicate_workers(or setWORKER_ID=worker-A). It fetches and locks the task. - Kill worker A before it completes (e.g. Ctrl+C or kill the process). Do not call fail; the task stays LOCKED with worker A’s lease.
- Wait for lock expiry (e.g. 30 seconds with default lock_duration).
- Optionally call
POST .../external-tasks/fetch-and-lockfrom the engine to trigger reclaim (or rely on the next fetch). Start worker B: same example withWORKER_ID=worker-B. Worker B will fetch the task (after reclaim or expiry). - Worker B completes the task.
Verify:
GET /api/v1/process-instances/:id/trace—external_task_historyfor that task showsExternalTaskLocked(worker A), then after expiry no further Locked until worker B locks and completes; you seeExternalTaskLocked(worker B) andExternalTaskCompleted.- History events are ordered by
occurred_at; you can confirm lock ownership and that only one worker completed.
Expected: Trace shows the task’s full lifecycle: first lock, expiry/reclaim, second lock, completion. No double completion.
Summary
| Scenario | What to run / do | How to verify |
|---|---|---|
| Payment + retry | Instance + worker fail twice then complete | Trace external_task_history; replay |
| Fork one branch fails | Instance + complete one branch, fail other | Trace token_timelines; instance state |
| Worker crash + reclaim | Two workers; kill first, wait, second completes | Trace external_task_history |
Use GET /api/v1/process-instances/:id/trace and replay (POST .../replay, GET .../snapshot, step) as the main tools to answer “what happened?” after each scenario.