State Machines¶
A state-machine is a small guarded transition model: a current state, an
initial state, an ordered list of state-transitions, a bounded history of
past transitions, and free-form metadata. step-state-machine is the reducer
at its core — feed it an event, get back the machine and a transition record —
and everything else on this page (introspection, analysis, serialization,
pipeline embedding) is built on top of that one operation.
Defining a machine¶
make-transition builds a single from event-type to edge, with optional
:guard, :action, and :metadata:
(cl-dataflow:make-transition "idle" "start" "running"
:action (lambda (machine event context)
(declare (ignore machine event context))
(values "running" '(:note "entered running"))))
An action receives (machine event context) and may return two values: an
overriding next-state (used instead of the transition's :to when non-nil)
and an arbitrary result value that ends up in the transition record's
:action-result. A guard has the same argument list and must return true for
the transition to be selected; when several transitions share a (state,
event-type) pair, the first one whose guard passes wins, in definition
order.
make-state-machine assembles transitions into a machine. You must supply
:state, :initial-state, or both — if you give only :state it is also
used as the initial state, and vice versa:
(defparameter *machine*
(cl-dataflow:make-state-machine
:state "idle"
:transitions
(list
(cl-dataflow:make-transition
"idle" "start" "running"
:action (lambda (machine event context)
(declare (ignore machine event context))
(values "running" '(:note "entered running"))))
(cl-dataflow:make-transition "running" "complete" "completed"))))
(Adapted from examples/state-machine.lisp.)
For a more declarative flavor, define-state-machine expands each (from
event to &key guard action metadata) clause into a make-transition call
and wraps the whole thing in make-state-machine:
(defparameter *review-machine*
(cl-dataflow:define-state-machine (:state "draft")
("draft" "submit" "review")
("review" "approve" "shipped")
("review" "reject" "cancelled")))
The definition-level options are :state, :initial-state, :history,
:history-limit, and :metadata; each transition clause accepts :guard,
:action, and :metadata. Both forms produce a plain state-machine value
— there is no special macro-only representation to unwrap. Anything else in
either position is rejected at macroexpansion time with invalid-input-error.
Readers and predicates¶
state-machine-p and state-transition-p are the type predicates for the two
classes state-machine and state-transition. A transition exposes
transition-from, transition-event-type, transition-to,
transition-guard, transition-action, and transition-metadata; a machine
exposes state-machine-state, state-machine-initial-state,
state-machine-transitions, state-machine-history,
state-machine-history-limit, and state-machine-metadata.
state-machine-transitions hands back copies, so mutating what it returns
does not change the machine — use add-transition/remove-transition, or
(setf state-machine-transitions), which re-copies the list and rebuilds the
internal lookup index in lock-step.
State and event names are normalized the same way node names are: strings pass
through unchanged and symbols become their (upcased) symbol name. Transition
lookup then compares case-insensitively, so both "submit" and 'submit
select a transition declared as "submit" — though a symbol event is recorded
in the transition record as :event-type "SUBMIT".
Stepping¶
step-state-machine is the reducer: given a machine, an event (a string,
symbol, or full event object), and an optional :context, it finds the
matching transition, runs its action, updates the machine's state in place,
and returns (values machine transition-record):
(cl-dataflow:step-state-machine
(cl-dataflow:make-state-machine
:state "idle"
:transitions (list (cl-dataflow:make-transition "idle" "start" "running")))
"start")
;; => #<STATE-MACHINE ...>, (:FROM "idle" :EVENT-TYPE "start" :TO "running"
;; :STATE-BEFORE "idle" :GUARD-PASSED T
;; :ACTION-RESULT NIL)
When you pass :context, stepping also updates context-state and appends a
copy of the transition record to context-trace — this is what "a state
machine behaves like a reducer inside pipeline and workflow code" (see
Core Concepts) means in practice. run-state-machine
drives a sequence of events through step-state-machine, returning
(values machine transition-records). run-state-machine-with-context adds
context management: omit :context and it seeds a fresh one from the
machine's current state; either way it returns (values machine
transition-records context) with context-state synchronized to the final
state:
(defparameter *context*
(cl-dataflow:make-context :state (cl-dataflow:state-machine-state *machine*)))
(multiple-value-bind (updated-machine transition-records updated-context)
(cl-dataflow:run-state-machine-with-context
*machine* '("start" "complete") :context *context*)
(declare (ignore updated-machine))
(cl-dataflow:context-state updated-context))
;; => "completed"
(This is examples/state-machine.lisp end to end; *machine* itself is now
sitting in state "completed", since step-state-machine mutates its
argument.) If a transition fails — no matching (state, event-type) pair, or
every candidate's guard rejects the event — step-state-machine signals
invalid-transition-error or guard-failed-error rather than silently
no-opping.
Introspecting the control surface¶
state-machine-available-transitions lists every transition out of a state
— the current state by default, or any state via :state:
(cl-dataflow:state-machine-available-transitions *review-machine*)
;; => (#<STATE-TRANSITION draft --submit--> review>)
(cl-dataflow:state-machine-available-transitions *review-machine* :state "review")
;; => (#<STATE-TRANSITION review --approve--> shipped>
;; #<STATE-TRANSITION review --reject--> cancelled>)
state-machine-can-step-p preflights a single event without mutating
anything, and accepts :context so guards that inspect context data see the
same runtime state they would see during a real step:
(cl-dataflow:state-machine-can-step-p *review-machine* "submit")
;; => T
(cl-dataflow:state-machine-can-step-p *review-machine* "bogus")
;; => NIL
Lifecycle: copying, resetting, and history¶
reset-state-machine snaps a machine's current state back to its initial
state, in place — it only touches state-machine-state, so accumulated
state-machine-history survives a reset untouched:
(cl-dataflow:reset-state-machine *machine*)
(cl-dataflow:state-machine-state *machine*)
;; => "idle"
(length (cl-dataflow:state-machine-history *machine*))
;; => 2 ; the "start" and "complete" records from run-state-machine-with-context
copy-state-machine clones everything a machine carries — current state,
initial state, transitions, history, history limit, and metadata — into an
independent value, so you can fork a machine and let each copy evolve on its
own without touching the original:
(defparameter *scratch* (cl-dataflow:copy-state-machine *machine*))
(cl-dataflow:step-state-machine *scratch* "start")
(cl-dataflow:state-machine-state *scratch*)
;; => "running"
(cl-dataflow:state-machine-state *machine*)
;; => "idle" ; unaffected
state-machine-history returns the ordered list of transition records (most
recent first), bounded by state-machine-history-limit (nil means
unbounded; 0 disables history entirely; any other value must be a
non-negative integer, or make-state-machine signals invalid-input-error).
state-machine-last-transition is a convenience reader for the most recent
record, or nil if the machine has never stepped:
(cl-dataflow:state-machine-last-transition *machine*)
;; => (:FROM "running" :EVENT-TYPE "complete" :TO "completed"
;; :STATE-BEFORE "running" :GUARD-PASSED T :ACTION-RESULT NIL)
Embedding in pipelines¶
make-state-machine-node turns a state machine into an ordinary pipeline
stage (a node), so it can sit inside a define-pipeline/define-workflow
graph alongside any other stage. :event-fn computes the event to step with
from (input context) — it may return an event designator (string/symbol) or
a full event object; when omitted, the stage's input is used directly as
the event. :result-fn computes the stage's output from
(updated-machine event input context); when omitted, the stage's output is
the machine's new state. The resulting node has a single "value" output port
and defaults to the name "state-machine"; :metadata is attached to the node
unchanged. The stage passes its runtime context down into step-state-machine
only when it really is a context, so guards and actions see the same context
the surrounding pipeline is threading:
(cl-dataflow:make-state-machine-node
*machine*
:name "order-transition"
:event-fn (lambda (input context)
(declare (ignore context))
(getf input :event))
:result-fn (lambda (updated-machine event input context)
(declare (ignore event input context))
(cl-dataflow:state-machine-state updated-machine)))
examples/event-workflow.lisp shows the complementary hand-rolled pattern —
a plain node handler that calls emit-event and step-state-machine
directly — which is exactly what make-state-machine-node packages up as a
reusable stage. See Pipelines and Workflows for
define-workflow, which unifies graph edges, transitions, and machine nodes
in one macro expansion.
See also¶
- State Machine Analysis — analyzing a machine's reachability and structure, replaying event sequences, serializing and mutating its transitions, and bridging it into the graph toolkit.
- Pipelines and Workflows —
define-workflow, which unifies graph edges, transitions, and machine nodes in one macro expansion. - Events and Effects — emitting events alongside a workflow's state transitions.
- Public API Reference — the full reader/predicate list
for
state-machineandstate-transition.