Architecture and design¶
This guide explains PyStator’s design goals, core flow, and how the main components fit together. It is intended for integrators and contributors who want to understand why the library is structured this way.
Design goals¶
-
Stateless engine — The FSM does not hold runtime state. You pass in the current state and event; you get back the next state and actions to run. State lives in your store (database, cache, etc.), which makes the engine easy to scale, test, and reason about.
-
Configuration-driven — Behavior is defined in YAML or JSON (states, transitions, guards, actions). Code only implements guards and actions; the graph and flow are data. This keeps business rules auditable and allows non-developers to adjust workflows.
-
Clear separation of concerns — The engine only computes what should happen. Persistence and side effects are the caller’s responsibility. This “sandwich” pattern (load → decide → persist → act) avoids hidden state and makes failures easier to handle.
-
Pluggable guards and actions — Guards and actions are registered by name and bound to the machine. You can swap implementations, test with mocks, and reuse the same FSM across services with different backends.
Core flow¶
The main entry point is:
- Input: Current state (string), trigger (event name), and a context dict (event payload, entity data, etc.).
- Lookup: The engine finds transitions whose source matches the current state (or an ancestor, in hierarchical FSMs) and whose trigger matches. For parallel states, region-scoped transitions are considered per region.
- Guards: For each candidate transition, guards are evaluated (in order). The first transition with all guards passing is chosen.
- Result: A
TransitionResultis returned with: success,source_state,target_state,triggerall_actions: ordered list of action names (exit actions of source, transition actions, enter actions of target)errorif no transition matched or a guard failed
The engine does not persist state or execute actions. The caller must:
- Load current state from the store (if not already in memory).
- Call
process(current_state, trigger, context). - If
result.success, persist the new state (result.target_state) atomically. - Then run actions (e.g. via
ActionExecutor.execute(result, context)).
This is the sandwich pattern: Load → Decide → Commit → Act. Committing before acting ensures that if actions fail, you can retry them without re-running transition logic.
Components¶
StateMachine and EntitySession¶
StateMachine is the FSM definition (from YAML or dict). It holds:
- States and transitions — Built from config (YAML/dict) and validated at construction.
- State hierarchy — Used to resolve the active leaf state and to compute exit/enter action order (LCA semantics).
- Parallel state manager — Tracks which region is in which state;
process_parallelhandles region-scoped transitions. - Guard evaluator — Uses a bound
GuardRegistryto evaluate guards; supports sync and async guards when usingaprocess.
The machine is immutable after construction (except for binding guards). No per-entity state is stored inside it.
EntitySession is the stateful object for one entity. You create it with machine.create(context=..., initial_state=...). It holds current state and context and exposes send(trigger, **payload) (and asend for async guards), plus auto-generated methods like instance.confirm() and properties like instance.is_confirmed. Use it when you run a stateful flow in memory for a single entity; call instance.snapshot() to persist and EntitySession.from_snapshot(machine, data) to restore. For persistence by entity ID across requests, use the Orchestrator instead.
GuardRegistry and ActionRegistry¶
- GuardRegistry — Maps guard names to callables
(context) -> bool. Registered with the machine viabind_guards(guards). Guards are pure: same context → same result; no side effects. - ActionRegistry — Maps action names to callables
(context) -> None. Not stored on the machine; used byActionExecutorto run the actions listed inTransitionResult.all_actions.
Guards and actions receive the same context dict your application built for process(). For parameterized actions, YAML can pass params, which are typically merged into context (e.g. under _action_params).
ActionExecutor¶
Takes a TransitionResult and context and runs the actions in result.all_actions using an ActionRegistry. Execution modes:
- Sequential — One after another.
- Parallel — All at once (async).
- Phased — Exit actions, then transition actions, then enter actions (preserves order between phases).
You create the executor with your registry and call execute(result, context) (or async variants) after you have persisted the new state.
Orchestrator¶
For applications that want a single “process this event for this entity” API, the Orchestrator wraps the full loop:
- Load entity state from a StateStore (and optionally context).
- If no state and
use_initial_state_when_missing, use the machine’s initial state. - Call
machine.process(state, trigger, context)(oraprocess). - If successful, persist the new state (and optionally context) to the store.
- Schedule any delayed transitions (
after) via an optional SchedulerAdapter. - Run actions via ActionExecutor.
So the orchestrator owns “load → decide → persist → schedule → act.” You implement a state store adapter and, if needed, a scheduler adapter; the rest is library code.
State stores¶
A StateStore (sync) or AsyncStateStore (async) provides:
- Get/set state (and optionally context) per entity ID.
Implementations include InMemoryStateStore (testing) and, in optional packages, SQLAlchemy and Redis-backed stores. The orchestrator does not care where state lives as long as the adapter implements the protocol.
Scheduler adapters (delayed transitions)¶
Transitions can specify after (e.g. 5 seconds). At runtime, when such a transition is chosen, the orchestrator (or your code) schedules a delayed “re-application” of the transition. Scheduler adapters abstract how that delay is implemented:
- AsyncioScheduler — In-memory, no extra infra; good for development and single-process use.
- RedisScheduler — Uses Redis for durability and cross-process scheduling; requires Redis and
await scheduler.start()to poll. - CeleryScheduler — Uses Celery to enqueue a task that runs after the delay; for production task queues.
In all cases, when the delay fires, the code loads the current state from the store and applies the transition again (idempotency and concurrency are your responsibility if multiple workers are used).
Hierarchical states (compound states)¶
States can have a parent and an initial_child. The machine resolves to a single active leaf state. When you call process(current_state, trigger, context):
- The current state is interpreted as the active leaf.
- A transition whose source is a parent state matches if the current state is that parent or any descendant.
- Exit and enter actions follow statechart semantics: exit from the current leaf up to the lowest common ancestor (LCA) of current and target, then enter from the LCA down to the target leaf.
So compound states let you model nested behavior and shared transitions (e.g. “from any substate of active, on cancel, go to canceled”) without duplicating transition definitions.
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 in the transition), so only that region’s state is considered for the transition.
Usage pattern:
- Enter the parallel state:
config = machine.enter_parallel_state("parallel_state_name")→ you get aParallelStateConfigwithregion_states: { region_name: state_name, ... }. - Process events:
config, results = machine.process_parallel(config, trigger, context)— only transitions for the given trigger in each region are applied;configis updated with new region states. - Exit the parallel state when a transition targets a state outside the parallel state; use
get_parallel_exit_actions(config)if you need to run exit actions for all regions.
Parallel states are useful for concurrent concerns (e.g. “trading workflow” + “risk monitor” + “data feed”) that evolve independently but in the same process.
Persistence and scaling¶
Because the engine is stateless:
- APIs: Each request can load state from the DB, call
process(), persist the new state, run actions. No shared in-memory state. - Workers: Multiple workers can process events for different entities; the state store (DB, Redis, etc.) is the source of truth. Use optimistic locking or versioning if multiple workers might process the same entity.
- Testing: Unit tests can call
process()with different (state, trigger, context) without starting a server or a store.
The recommended pattern is always: load state → compute transition → persist → execute actions. The Orchestrator encapsulates this when you use it; otherwise you implement the same loop in your application.
Error policy and timeouts¶
- Error policy (in config): Optional
default_fallbackstate andretry_attempts. On unhandled errors (e.g. no matching transition, or guard raised), the engine can transition to the fallback state if configured. - Timeouts: A state can define a timeout (e.g. 30 seconds to another state). The engine does not run a timer itself; a TimeoutManager or your own scheduler checks elapsed time and calls
process(current_state, timeout_trigger, context)when the timeout fires. So timeouts are “event-driven” from your side.
Where the API and UI fit¶
The REST API and web UI are an optional layer on top of the same core:
- Validate — Validates FSM config (schema + semantic) and returns errors.
- Process — Accepts config (or machine ID), current state, trigger, context; returns the transition result (and optionally runs actions if the API is configured to do so).
- Machines — CRUD for stored machine definitions (when a database is configured).
The engine, guards, actions, and orchestrator are unchanged; the API and UI simply expose them over HTTP and provide a visual editor for the same YAML/JSON config described in the FSM configuration reference.
See also¶
- Concepts — States, transitions, guards, actions in more detail.
- FSM configuration reference — Full YAML/JSON schema.
- API reference — Public classes and methods.