Expand description
§FASM - Fallible Async State Machines
A framework for building deterministic, testable, and crash-recoverable state machines with async operations and fallible state access.
§Core Concept
FASM separates state machine logic from external side effects:
- State Transition Function (STF): A deterministic function that reads state and input, validates transitions, mutates state, and emits action descriptions.
- Actions: Descriptions of side effects (HTTP calls, notifications, analytics) executed after the STF completes successfully and state is committed.
- State: Can be in-memory structs, database transactions, or any storage accessed through
the
stateparameter. State mutations are part of the transaction, not side effects. - Restore: Rebuilds pending actions from persisted state after crashes.
§Execution Model
The runtime executes an STF like this:
let mut txn = db.begin_transaction().await?;
let mut actions = Vec::new();
match Machine::stf(&mut txn, input, &mut actions).await {
Ok(()) => {
txn.commit().await?; // Commit state changes
execute_actions(actions).await; // Then execute side effects
}
Err(e) => {
txn.abort().await; // Rollback state
actions.clear(); // Discard actions
return Err(e);
}
}Key insight: One STF call = one atomic state transaction. All state operations within the STF are part of that transaction. Actions are only executed after successful commit.
§Atomicity
FASM provides atomicity through two mechanisms:
§Transactional State (Database-backed)
If your state is a database transaction, atomicity is provided by the storage layer:
async fn stf(txn: &mut DbTransaction, input: Input, actions: &mut Actions) -> Result<()> {
let user = txn.get("user:123").await?; // Part of transaction
txn.set("balance", new_balance).await?; // Part of transaction
txn.set("pending", request).await?; // Part of transaction
actions.add(Action::Tracked(...))?; // Buffered, executed after commit
Ok(())
// If any operation fails, transaction aborts and all changes are rolled back
}§In-Memory State
For in-memory state not covered by a transaction, you must ensure atomicity manually by performing fallible operations before mutating state:
async fn stf(state: &mut InMemoryState, input: Input, actions: &mut Actions) -> Result<()> {
// 1. Validation (can fail)
if state.balance < amount {
return Err(InsufficientFunds);
}
// 2. Prepare values (no mutation yet)
let id = state.next_id;
// 3. State mutation (after all validation)
state.next_id += 1;
state.pending.insert(id, request);
// 4. Emit actions
actions.add(Action::Tracked(...))?;
Ok(())
}§Critical Invariants
-
Atomicity: If STF returns
Err, state must be unchanged (enforced by transaction or by careful ordering of operations). -
Determinism: Same state + same input = same output. No randomness, system time, or external I/O in the STF. All external data comes through
input. -
No Side Effects: STF only mutates state and emits action descriptions. Actual side effects (HTTP calls, sending emails) happen after commit.
-
Tracked Actions in State: Before emitting a tracked action, store enough data in state that
restore()can recreate it after a crash. -
Restore is Pure:
restore()only reads from the state parameter. It cannot make external queries.
§Example
use fasm::{StateMachine, Input, actions::{Action, TrackedAction, TrackedActionTypes}};
struct MySystem {
balance: u64,
pending: HashMap<u64, Request>,
next_id: u64,
}
struct MyTracked;
impl TrackedActionTypes for MyTracked {
type Id = u64;
type Action = PaymentRequest;
type Result = PaymentResult;
}
impl StateMachine for MySystem {
type State = Self;
type Input = UserRequest;
type TrackedAction = MyTracked;
type UntrackedAction = Notification;
type Actions = Vec<Action<Self::UntrackedAction, Self::TrackedAction>>;
type TransitionError = MyError;
type RestoreError = ();
async fn stf<'s, 'a>(
state: &'s mut Self::State,
input: Input<Self::TrackedAction, Self::Input>,
actions: &'a mut Self::Actions,
) -> Result<(), Self::TransitionError> {
match input {
Input::Normal(request) => {
// Handle user request...
}
Input::TrackedActionCompleted { id, result } => {
// Handle action completion...
}
}
Ok(())
}
async fn restore<'s, 'a>(
state: &'s Self::State,
actions: &'a mut Self::Actions,
) -> Result<(), Self::RestoreError> {
for (&id, pending) in &state.pending {
actions.add(Action::Tracked(TrackedAction::new(id, ...)))?;
}
Ok(())
}
}§Testing
FASM enables deterministic simulation testing:
let mut rng = ChaCha8Rng::seed_from_u64(12345);
for _ in 0..10000 {
let input = generate_random_input(&mut rng);
Machine::stf(&mut state, input, &mut actions).await?;
state.check_invariants()?;
}
// Same seed = same execution = reproducible bugsModules§
- actions
- Actions emitted by state transition functions.
Enums§
- Input
- Input to the state transition function.
Traits§
- State
Machine - A deterministic, recoverable async state machine.