Skip to main content

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

QuestionGo 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.

KeywordWhere it goesWhat it does
[initial]State header markerExactly one state per machine carries this marker; execution begins there.
[terminal]State header markerAny number of states may be terminal; reaching one completes the machine with status COMPLETED.
on EVENT -> targetInside a stateWait for a signal whose event name matches; transition to target on receipt.
on EVENT when GUARD -> targetInside a stateSame, but only fire when the guard expression evaluates truthy. Multiple when clauses are tried in declaration order.
on *Inside a stateAutomatic 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 stateState-local timeout. Combined with on_timeout to declare where to go when it fires.
on_timeout -> targetInside a stateTarget for the state-local timeout.
global_timeout = <duration>Top of machineWhole-machine deadline. When it fires without a matching global_transition, status becomes FAILED.
global_transitions { … }Top of machineTransitions that apply to every non-terminal state. Useful for CANCEL / ERROR events that can arrive in any state.
max_transitions = NTop of machineHard cap on transition count. Default 100. Exceeding it sets status FAILED.
max_state_visits = NTop of machineHard 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

CategoryOperatorNotes
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.
Ternarycond ? a : bRight-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 is null is 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 coercionnull, 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

KnobDefaultRaise whenLower when
max_transitions100The 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_visits10A 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_timeoutunsetThe 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 timeoutunsetThe 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, fires onStateMachineComplete with FAILED, 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.

OutcomeTyped signalMeaning
Event has no legal transitionUnhandledEventExceptionCurrent state cannot consume that event
Signal arrives after completion or at an illegal pointStateMachineSignalRejectedExceptionInstance state rejects the signal
State graph failsStateGraphExecutionExceptionWork inside the selected state failed
Transition or visit budget is exhaustedStateMachineTransitionLimitExceededException / StateVisitLimitExceededExceptionSafety cap stopped a runaway machine
Deadline expiresStateMachineTimeoutExceededExceptionState or global timeout ended execution
Durable definition changedStateMachineVersionMismatchExceptionCheckpoint hash does not match current definition
Migration is invalidStateMachineMigrationExceptionDeclared 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.

CallbackFires whenTypical 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.

FieldTypeNotes
instanceIdStringStable identity assigned by execute(def, ctx, instanceId) or generated.
stateMachineNameStringThe name declared on the state machine block.
currentStateIdStringThe state the machine is in or the last terminal state if completed.
statusStateMachineStatusRUNNING / WAITING_EVENT / COMPLETED / FAILED.
totalTransitionsintIncrements on every transition; bounded by max_transitions.
stateVisitCountMap<String, Integer>Per-state entry count; bounded by max_state_visits.
stateOutputsMap<String, Map<String, Object>>The ctx.stateName.output.nodeId tree, namespaced per state.
sharedContextMap<String, Object>Cross-state context the operators write to.
historyList<StateExecutionRecord>One record per entered-and-exited state, oldest first.
startedAtInstantWall-clock at execute(...).
lastTransitionAtInstantWall-clock at the most recent transition.
checkpointedAtInstantWall-clock at the most recent write.
stateTimeoutDeadlineInstant?Set when the current state has a timeout; null otherwise.
globalTimeoutDeadlineInstant?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.

Diagram: appendix-d-state-machines figure 1


SM / Session / Plain Graph

Use this table to pick the right primitive before you write any DSL.

QuestionIf 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.

FileWhat it shows
order-lifecycle-state-machine.bloge4 states with a backward transition, 24h global timeout. The walk-through in Chapter 15.
ticket-state-machine.bloge6 states, escalation paths, multiple event flows.
order-session-with-state-machine.blogePattern A — session-outermost.
review-state-machine-with-session.blogePattern B — state-machine-outermost (with the signal-deadlock constraint).

Common Mistakes

MistakeWhy it happensFix
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:

  • AuthoringChapter 15 introduces the DSL.
  • CompositionChapter 16 covers the two nesting patterns and the signal-deadlock rule.
  • DurabilityChapter 13 explains the store SPIs; this appendix names the checkpoint fields they carry.
  • TestingChapter 18 covers deterministic time control and snapshot assertions.
  • OperationsChapter 19 shows the listener-driven metrics; this appendix gives you the callback catalog.