Online edition for BLOGE
0.9.8-RC1· facts verified 2026-09-15 · 中文
Appendix D — State Machines
Chapter 15 teaches state machines as a narrative. Chapter 16 shows how they compose with sessions. This appendix is the lookup reference — the operator table, configuration knobs, listener callbacks, and checkpoint shape you keep coming back to when authoring a non-trivial machine.
When You Need This Appendix
| Question | Go to |
|---|---|
| "How do I model a lifecycle with named states?" | Chapter 15 |
| "How do state machines and sessions nest?" | Chapter 16 |
"What operators are legal in a when guard?" | This appendix — Guards |
| "What does the engine persist when a checkpoint fires?" | This appendix — Checkpoint shape |
| "Which listener callback gives me state-enter vs state-exit timing?" | This appendix — Listener SPI |
| "Should this workflow be an SM, a session, or a plain graph?" | This appendix — Decision table |
If you have not yet read Chapter 15, start there. This appendix assumes
you already know what [initial], on EVENT -> target, and [terminal]
mean.
DSL Keyword Reference
Every keyword Chapter 15 introduces, in one table. Each appears inside a
state machine block. Names are case-sensitive.
| Keyword | Where it goes | What it does |
|---|---|---|
[initial] | State header marker | Exactly one state per machine carries this marker; execution begins there. |
[terminal] | State header marker | Any number of states may be terminal; reaching one completes the machine with status COMPLETED. |
on EVENT -> target | Inside a state | Wait for a signal whose event name matches; transition to target on receipt. |
on EVENT when GUARD -> target | Inside a state | Same, but only fire when the guard expression evaluates truthy. Multiple when clauses are tried in declaration order. |
on * | Inside a state | Automatic transition — fires the moment the state is entered, before the executor parks. Use for fan-out into a sub-graph that always runs. |
timeout = <duration> | Inside a state | State-local timeout. Combined with on_timeout to declare where to go when it fires. |
on_timeout -> target | Inside a state | Target for the state-local timeout. |
global_timeout = <duration> | Top of machine | Whole-machine deadline. When it fires without a matching global_transition, status becomes FAILED. |
global_transitions { … } | Top of machine | Transitions that apply to every non-terminal state. Useful for CANCEL / ERROR events that can arrive in any state. |
max_transitions = N | Top of machine | Hard cap on transition count. Default 100. Exceeding it sets status FAILED. |
max_state_visits = N | Top of machine | Hard cap on how many times any single state may be entered. Default 10. |
Duration syntax matches the rest of BLOGE:
30s,5m,2h,24h,PT15M(ISO-8601). Use the form your DSL lint allows.
Guard Expression Catalog
Guard expressions are parsed by the same evaluator the DSL uses for when
clauses and conditional edges. The grammar is verified against
ExpressionEvaluator in bloge-dsl.
Operators
| Category | Operator | Notes |
|---|---|---|
| Arithmetic | + - * / % | Numeric; + does not concatenate strings. Use string interpolation instead. |
| Comparison | == != < <= > >= | == / != work on any comparable types; numeric promotion follows Java widening rules. |
| Logical | && || ! | Short-circuit. ! is the only logical unary. |
| Unary | - (negate) | Numeric only. |
| Null-coalesce | ?? | a ?? b returns a unless a is null, in which case b. |
| Ternary | cond ? a : b | Right-associative. |
| Path lookup | . and […] | Dotted paths resolve against the context map; index access works for lists. |
Worked examples
# Numeric guard against a node output
on submit when ctx.scoreNode.output.score >= 80 -> approved
# String comparison
on classify when ctx.input.tier == "platinum" -> fastTrack
# Null-coalesce + ternary
on review when (ctx.audit.output.flags ?? []) == [] ? true : false -> noManualReview
# Composite logical
on dispatch when ctx.geo.region == "EU" && ctx.user.consent.marketing -> sendEmail
Resolution rules
- Missing path — a path that does not resolve evaluates to
null. A guard whose top-level expression isnullis treated as falsy, so the transition is skipped. - Type mismatch — comparing a number with a string is
false; it does not raise. The lint catches obvious mismatches at compile time. - Boolean coercion —
null,false,0,"", and empty collections are falsy. Everything else is truthy. - Evaluation order — when multiple
on EVENT when …clauses exist for the same event, they are tried top-to-bottom; the first guard that evaluates truthy wins. Put the most specific guard first.
Configuration Knobs
| Knob | Default | Raise when | Lower when |
|---|---|---|---|
max_transitions | 100 | The machine has many automatic transitions or a long-running retry loop with bounded back-off. | The machine should be short; a runaway loop indicates a missing terminal state. |
max_state_visits | 10 | A state legitimately re-enters (e.g. retry budget) and you have an external counter for the wider attempt. | A state is supposed to be visited at most once; lower it to fail loud. |
global_timeout | unset | The machine has no natural deadline and you want crash-safe expiry. | Each state already has its own timeout and a global cap would only mask state-level bugs. |
State timeout | unset | The state can wait indefinitely for an external event and you need a fallback. | The state should complete promptly from on * — a state timeout adds noise. |
What happens when a cap is hit: the executor transitions to status
FAILED, firesonStateMachineCompletewithFAILED, and writes a final checkpoint. Downstream signals are rejected.
Failure and Rejection Outcomes
The state-machine API exposes typed exceptions rather than one universal error-code enum. Preserve the type at service boundaries; do not parse messages.
| Outcome | Typed signal | Meaning |
|---|---|---|
| Event has no legal transition | UnhandledEventException | Current state cannot consume that event |
| Signal arrives after completion or at an illegal point | StateMachineSignalRejectedException | Instance state rejects the signal |
| State graph fails | StateGraphExecutionException | Work inside the selected state failed |
| Transition or visit budget is exhausted | StateMachineTransitionLimitExceededException / StateVisitLimitExceededException | Safety cap stopped a runaway machine |
| Deadline expires | StateMachineTimeoutExceededException | State or global timeout ended execution |
| Durable definition changed | StateMachineVersionMismatchException | Checkpoint hash does not match current definition |
| Migration is invalid | StateMachineMigrationException | Declared state mapping cannot be applied |
If the state machine is backed by a generic durable store, storage failures additionally carry DurableErrorCode; that enum describes store semantics, not state-machine business transitions.
Listener SPI Cheat Sheet
StateMachineListener is the single hook for audit, metrics, and
debugging. All methods are default-no-op; override only what you need.
| Callback | Fires when | Typical use |
|---|---|---|
onStateMachineStart(StateMachineStartEvent) | execute(def, ctx) is invoked, before the initial state is entered. | Audit log row, span open. |
onStateEnter(StateEnterEvent) | After a transition lands, before the state's graph runs. | State-duration timer start, MDC enrichment. |
onStateExit(StateExitEvent) | After the state's graph finishes, before the chosen transition fires. | State-duration timer stop. |
onTransition(TransitionEvent) | A transition is selected — covers on EVENT, on *, on_timeout, and global transitions. | Transition counter, structured log. |
onWaitingForEvent(WaitingForEventEvent) | Executor parks because no automatic transition matched. | "Awaiting signal" gauge, dashboard hint. |
onSignalReceived(SignalReceivedEvent) | A signal(event, payload) call lands while the machine waits. | Signal-rate counter, payload audit. |
onStateTimeout(StateTimeoutEvent) | A state-local timeout fires before any matching event arrives. | Per-state timeout metric. |
onGlobalTimeout(GlobalTimeoutEvent) | The machine-wide global_timeout fires. | Alert / page. |
onCheckpointSaved(CheckpointSavedEvent) | A checkpoint has been written to the configured store. | Replication watermark, lag check. |
onCheckpointRestored(CheckpointRestoredEvent) | resumeFromCheckpoint() rehydrated an instance. | "Recovered after crash" log line. |
onStateMachineComplete(StateMachineCompleteEvent) | Terminal state reached or machine entered FAILED. | Audit close, span close. |
Register listeners via StateMachineExecutor.Builder.listeners(...) or, for
the durable path, via DurableStateMachineManager.Builder.listeners(...).
Checkpoint Shape
StateMachineCheckpoint is what the persistence SPI stores. Knowing the
shape helps when you debug a partial recovery or design a custom
ExecutionCheckpointStateMachineStore.
| Field | Type | Notes |
|---|---|---|
instanceId | String | Stable identity assigned by execute(def, ctx, instanceId) or generated. |
stateMachineName | String | The name declared on the state machine block. |
currentStateId | String | The state the machine is in or the last terminal state if completed. |
status | StateMachineStatus | RUNNING / WAITING_EVENT / COMPLETED / FAILED. |
totalTransitions | int | Increments on every transition; bounded by max_transitions. |
stateVisitCount | Map<String, Integer> | Per-state entry count; bounded by max_state_visits. |
stateOutputs | Map<String, Map<String, Object>> | The ctx.stateName.output.nodeId tree, namespaced per state. |
sharedContext | Map<String, Object> | Cross-state context the operators write to. |
history | List<StateExecutionRecord> | One record per entered-and-exited state, oldest first. |
startedAt | Instant | Wall-clock at execute(...). |
lastTransitionAt | Instant | Wall-clock at the most recent transition. |
checkpointedAt | Instant | Wall-clock at the most recent write. |
stateTimeoutDeadline | Instant? | Set when the current state has a timeout; null otherwise. |
globalTimeoutDeadline | Instant? | Set when the machine has a global_timeout. |
Both deadline fields persist so that after a crash, the recovery loop can reschedule timers against the original wall-clock deadlines — not a new "now + timeout". This is what makes durable state machines correct across restarts.
SM / Session / Plain Graph
Use this table to pick the right primitive before you write any DSL.
| Question | If yes → use |
|---|---|
| Is there a finite set of named lifecycle phases that the workload sits in for variable amounts of time, possibly looping? | State machine (Chapter 15) |
| Does the workload have a back-and-forth dialogue shape with the outside world, where each turn is processed by similar logic? | Session (Chapter 14) |
| Does the workload have both — phases that contain conversations? | Composition (Chapter 16). Pick the outer owner by signal origin (see Ch15). |
| Is the dependency shape a pure DAG that runs to completion in one pass? | Plain graph (Chapter 2 onward) |
| Is there one long-running operator that produces incremental output? | Streaming (Appendix C) |
A state machine is the right tool when what changes is the phase, not the data shape. If the schema of the work item is what drives branching, a DAG is simpler.
Real Examples
The repository ships verified fixtures you can read end-to-end.
| File | What it shows |
|---|---|
order-lifecycle-state-machine.bloge | 4 states with a backward transition, 24h global timeout. The walk-through in Chapter 15. |
ticket-state-machine.bloge | 6 states, escalation paths, multiple event flows. |
order-session-with-state-machine.bloge | Pattern A — session-outermost. |
review-state-machine-with-session.bloge | Pattern B — state-machine-outermost (with the signal-deadlock constraint). |
Common Mistakes
| Mistake | Why it happens | Fix |
|---|---|---|
Two [initial] markers in one machine. | Copy-pasted a draft and forgot to clear the marker. | The compiler will reject this. Run bloge lint before commit. |
max_transitions exceeded in a retry loop. | Retry state visits itself with no exit condition. | Either lower the visit cap (max_state_visits) on the retry state, or add an on * when ctx.retry.output.count > 3 -> giveUp transition. |
signal(event, payload) returns "no transition matched" silently. | The event name matched, but every when guard evaluated falsy. | Add a final on EVENT -> fallback after all guarded variants, or audit the guard's path with MockOperator.invocations() in a test. |
| Nested session in Pattern B tries to wait for an external signal. | Treating an inner session like an outer one. | The executor throws SM_NESTED_SESSION_AWAITS_SIGNAL. Restructure so the session's external touch-points live in the outer machine (Pattern A) — see Chapter 16. |
State timeout fires but on_timeout is missing. | The state was added without a fallback. | The machine transitions to FAILED. Always pair timeout with on_timeout unless FAILED is the desired outcome. |
| Checkpoint restore lands in the wrong state. | Custom ExecutionCheckpointStateMachineStore returned a stale row. | The store contract requires read-after-write for instanceId. Verify with DurableStateMachineManager's built-in MyBatis store as a baseline. |
Relationship to the Main Chapters
State machines do not live in one chapter; this appendix collects what is scattered:
- Authoring — Chapter 15 introduces the DSL.
- Composition — Chapter 16 covers the two nesting patterns and the signal-deadlock rule.
- Durability — Chapter 13 explains the store SPIs; this appendix names the checkpoint fields they carry.
- Testing — Chapter 18 covers deterministic time control and snapshot assertions.
- Operations — Chapter 19 shows the listener-driven metrics; this appendix gives you the callback catalog.