Core Concepts¶
This guide explains the main concepts in PyStator: states, transitions, guards, actions, and how they fit together.
Terminology¶
We use two terms consistently:
| Term | Meaning |
|---|---|
| Entity | The domain object whose state you are modeling (e.g. order-123, user-456). Each entity has a current FSM state; that state may be stored in a state store or held in memory. |
| EntitySession | The in-process Python object that represents one entity running through a state machine. You create it with machine.create(context=...); it holds current state and context and exposes send(trigger, **payload) and auto-generated helpers like order.confirm() and order.is_confirmed. |
So: one entity (e.g. one order) is represented at runtime by one EntitySession when you run in memory, or by rows/cache entries in a StateStore when you use the Orchestrator. The same StateMachine (the FSM definition) is shared across all entities of that type.
Glossary¶
| Term | Meaning |
|---|---|
| Machine (StateMachine) | The FSM definition: states and transitions from config (YAML/dict), plus bound guards and actions. One per “type” (e.g. order management, user signup). Stateless. |
| Entity | One instance of that type with its own current state (e.g. order-123, user-456). State may be in memory (EntitySession) or in a state store (Orchestrator). |
| EntitySession | The primary stateful API: one object per entity. Tracks current state and context; use send() / asend() to process events. Use when you have the entity in memory (e.g. request-scoped or in-memory workflow). |
| Orchestrator | Wraps the full loop: load state from a StateStore by entity ID → process → persist → run actions (and optional delayed transitions). Use when state lives in a DB/cache and you identify entities by ID. |
| Replica | A copy of your service (e.g. API pod or worker). All replicas share the same state store and can process events for any entity. |
Running a machine: three ways¶
Choose based on where entity state lives and how you identify entities:
-
EntitySession (recommended for in-memory, per-entity)
Create oneEntitySessionper entity (e.g. per order in a request). It holds current state and context; callinstance.send(trigger, **payload)or the auto-generatedinstance.confirm()-style methods. Useinstance.snapshot()to persist andEntitySession.from_snapshot(machine, data)to restore. Best when you load the entity into memory and process one or a few events before saving. -
Stateless
process()
Callmachine.process(current_state, trigger, context)with the state you already have (e.g. from your DB). Pure computation: no side effects, no persistence. You persist the new state and run actions yourself. Use when you want full control over the sandwich loop or when you are not using a EntitySession. -
Orchestrator
Callorchestrator.process_event(entity_id, trigger, context)(or async). The orchestrator loads state from a StateStore byentity_id, runs the transition, persists the new state, and runs actions. Use when state is stored by entity ID (DB, Redis) and you want a single “process this event for this entity” API. Supports optional schedulers for delayed transitions (after: "5s").
Summary: use EntitySession as the default when you run a stateful machine for one entity in memory; use Orchestrator when you need persistence by entity ID and optional delayed transitions.
Execution model¶
PyStator is event-driven. Each time something happens (HTTP request, message, cron, or delayed callback), you send an event and get a transition result.
- With EntitySession:
instance.send(trigger, **payload)(orinstance.asend()for async guards). The instance updates its own state; you can callinstance.snapshot()to persist. - With Orchestrator:
orchestrator.process_event(entity_id, trigger, context). The orchestrator loads state, computes the transition, persists, and runs actions. - Stateless:
machine.process(current_state, trigger, context). You supply state, you persist and run actions.
No long-lived loop per entity: One request/callback typically does one (or a few) send/process call(s). You scale by adding replicas; there is no “one pod per FSM” or per entity.
- Schedulers: Delayed transitions (
after: "5s"in YAML) require a scheduler (Asyncio, Redis, or Celery). The Orchestrator can schedule them when you use it with a scheduler. See Schedulers and delayed transitions.
States¶
States are the nodes of your state machine. Each state has a name and a type:
| Type | Meaning |
|---|---|
initial |
Entry point; machine starts here. Exactly one initial state. |
stable |
Normal operating state; can transition to other states. |
terminal |
End state; no outgoing transitions. |
error |
Error state; used by error policy. |
parallel |
Container for orthogonal regions (see Parallel states). |
Optional per state:
on_enter/on_exit: Lists of action names to run when entering or leaving the state.timeout: After a duration, automatically transition to another state (e.g.destination: timed_out_ack). The destination must be a valid state name (lowercase identifiers).parent/initial_child: For hierarchical (compound) states.regions: For parallel states (orthogonal regions).metadata.context_keys: Optional list of context variable names for documentation (reference only; the engine does not use it). Use this to document which state variables apply to each state; see FSM config reference.
Transitions¶
A transition defines: from which state(s), on which trigger event, to which state, and optionally guards and actions.
- trigger: Event name (e.g.
exchange_ack,fill). Your application callsinstance.send(trigger, **payload)orprocess(current_state, trigger, context). - source: One state name or a list (e.g.
openor[open, partially_filled]). Names use lowercasea–z, digits, and underscores only. - dest: Target state name (or history pseudo-state like
H(parent)). - guards: List of guard names (or inline
expr: "..."). All must be true for the transition to fire. - actions: List of action names (or
{ name, params }) to run after the transition (after you persist the new state). - region: For parallel states, the region this transition applies to.
- after: Delay (e.g.
5000ms or"5s") for delayed transitions; requires a scheduler. - internal: If true, no exit/enter actions run (internal/self-transition).
- auto: If true, transition is eventless and fires when entering the source state when guards pass.
Only one transition is chosen per event: the first matching (source + trigger + guards) wins. Order of transition definitions matters when multiple could match.
Guards¶
Guards are conditions that gate a transition. They are pure: same context → same result; no side effects.
- Registered guards: Implemented in Python, registered by name (e.g. via
@machine.guard("name")orGuardRegistry), bound to the machine. Sync or async. - Inline guards: Defined in YAML as
expr: "fill_qty >= order_qty". Context variables are available in the expression. Requirespystator[recipes].
If any guard returns false (or raises), that transition is not taken; the engine may try another transition or return no transition.
Actions¶
Actions are side effects: notifications, DB updates, messaging. They run after you have persisted the new state (sandwich pattern: Load → Decide → Commit → Act).
- Registered in an
ActionRegistry(e.g. via@machine.action("name")) and executed viaActionExecutor.execute(result, context)(or async variants). - Can be attached to transitions (transition actions) and to states (
on_enter,on_exit). - Execution order for a transition: exit actions (current state) → transition actions → enter actions (target state). Use
ExecutionMode.PHASEDto run in these phases;SEQUENTIALorPARALLELfor other policies. - Action parameters: In YAML you can pass
paramsto an action; they appear in context under_action_params.
Actions can be sync or async; use async_execute_parallel or async_execute_phased for async actions.
Context¶
The context is a dictionary you pass into instance.send(trigger, **payload) (payload is merged into context) or process(current_state, trigger, context). It typically holds:
- The event payload (e.g.
fill_qty,order_qty) - Entity data (e.g. order id, user id)
- Any values guards or actions need
Guards and actions receive this same context. Build it in your application; for nested data, you can use flatten_context_for_guards from pystator.recipes to expose a flat view for inline expressions.
Hierarchical states (compound states)¶
States can have a parent and an initial_child. The machine resolves to a single active leaf state. A transition whose source is a parent applies when the current state is that parent or any descendant. Exit/enter actions follow statechart semantics (exit from leaf up to LCA, enter from LCA down to target). History: use H(parent) or H*(parent) as dest to re-enter the last active substate. See the main README section "Hierarchical States (Statecharts)".
Parallel states (orthogonal regions)¶
A parallel state contains multiple regions; each region has its own current state. All regions are active at once. Transitions can be region-scoped (region: region_name) so only that region's state is considered. Use enter_parallel_state, process_parallel, and get_parallel_exit_actions for the full lifecycle. See the main README section "Parallel States (Orthogonal Regions)".
Delayed transitions¶
Transitions can have an after field (e.g. 5000 or "5s"). They are scheduled by a scheduler adapter (asyncio, Redis, or Celery) and fire when the delay expires. Requires an Orchestrator with a scheduler; the state store must persist entity state so the scheduler can re-apply the transition later. See Schedulers and delayed transitions.
The sandwich pattern¶
PyStator is designed around:
- Load current state (from your DB or from a EntitySession).
- Decide by calling
machine.process(current_state, trigger, context)orinstance.send(trigger, **payload)(pure). - Commit by persisting the new state (and any audit) in your DB, or by the instance updating its own state (and optionally calling
instance.snapshot()). - Act by running
executor.execute(result, context)(or async equivalent).
Actions should not drive transition logic; they run only after a successful commit so that side effects match persisted state. When using EntitySession, you can run actions yourself after send(); when using the Orchestrator, it does steps 1–4 for you.