1 unstable release
Uses new Rust 2024
| new 0.3.0 | Jan 8, 2026 |
|---|
#851 in Database interfaces
34KB
89 lines
FASM - Fallible Async State Machines
A Rust framework for building deterministic, testable, and crash-recoverable state machines with async operations and fallible state access.
Why FASM?
Traditional state machines break down in productionβrace conditions, crashes mid-operation, and bugs that only appear under load. FASM solves this by making correctness verifiable:
- π― Deterministic execution β Same inputs always produce same outputs
- π Crash recovery β Resume from any failure point automatically
- π§ͺ Simulation testing β Verify correctness across millions of operations in seconds
- π Atomicity β Transactions succeed completely or leave state unchanged
- πΎ Flexible state β In-memory, database transactions, or hybrid
Quick Example
use fasm::{Input, StateMachine, actions::{Action, ActionsContainer, TrackedAction, TrackedActionTypes}};
struct PaymentSystem {
balance: u64,
pending: HashMap<u64, Payment>,
next_id: u64,
}
struct PaymentTracked;
impl TrackedActionTypes for PaymentTracked {
type Id = u64;
type Action = PaymentRequest;
type Result = PaymentResult;
}
impl StateMachine for PaymentSystem {
type State = Self;
type Input = PaymentInput;
type TrackedAction = PaymentTracked;
type UntrackedAction = Notification;
type Actions = Vec<Action<Self::UntrackedAction, Self::TrackedAction>>;
type TransitionError = PaymentError;
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(PaymentInput::Process { amount, user }) => {
// 1. Validate
if state.balance < amount {
return Err(PaymentError::InsufficientFunds);
}
// 2. Prepare (no mutation yet)
let id = state.next_id;
// 3. Fallible operations first
actions.add(Action::Tracked(TrackedAction::new(
id,
PaymentRequest::Charge { amount },
)))?;
// 4. Mutate state (point of no return)
state.next_id += 1;
state.pending.insert(id, Payment { amount, user, status: Pending });
Ok(())
}
Input::TrackedActionCompleted { id, result } => {
let payment = state.pending.get_mut(&id)
.ok_or(PaymentError::NotFound)?;
match result {
PaymentResult::Success => {
state.balance -= payment.amount;
payment.status = Confirmed;
}
PaymentResult::Failed { reason } => {
payment.status = Failed;
}
}
Ok(())
}
}
}
async fn restore<'s, 'a>(
state: &'s Self::State,
actions: &'a mut Self::Actions,
) -> Result<(), Self::RestoreError> {
for (&id, payment) in &state.pending {
if payment.status == Pending {
actions.add(Action::Tracked(TrackedAction::new(
id,
PaymentRequest::CheckStatus { id },
))).map_err(|_| ())?;
}
}
Ok(())
}
}
How It Works
Input (user request, external data)
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β State Transition Function (STF) β
β βββββββββββββββββββββββββββββββββ β
β β’ Validates inputs β
β β’ Mutates state atomically β
β β’ Emits action descriptions β
βββββββββββββββββββββββββββββββββββββββ
β
ββββΊ State committed
β
ββββΊ Tracked Actions βββΊ External Systems βββΊ Results feed back as Input
β
ββββΊ Untracked Actions (fire-and-forget)
After crash: restore(state) β re-emit pending tracked actions
Core Concepts
State Transition Function (STF)
A deterministic function: (State, Input) β (State', Actions)
- Validates inputs and mutates state
- Emits action descriptions (not executions)
- Must be atomic: if it returns
Err, state is unchanged
Actions
Tracked Actions: Results feed back into the STF
- Payment processing, external API calls, background jobs
- Stored in state for crash recovery
- Use when the outcome affects system correctness
Untracked Actions: Fire-and-forget
- Logs, metrics, notifications, UI updates
- Not recovered after crashes
- Use when you don't need confirmation
Restore
After a crash, restore() rebuilds pending tracked actions from state:
- Pure function of state (no external queries)
- Runtime clears actions container before calling
- Enables automatic crash recovery
Atomicity
Transactional State (Database)
If state is a database transaction, atomicity is automatic:
async fn stf(txn: &mut DbTransaction, input: Input, actions: &mut Actions) -> Result<()> {
let user = txn.get("user:123").await?;
txn.set("balance", new_balance).await?;
actions.add(Action::Tracked(...))?;
Ok(())
// If any operation fails, entire transaction aborts
}
In-Memory State
For in-memory state, order operations carefully:
async fn stf(state: &mut State, input: Input, actions: &mut Actions) -> Result<()> {
// 1. Validate (can fail)
if state.balance < amount {
return Err(InsufficientFunds);
}
// 2. Prepare values (no mutation)
let id = state.next_id;
// 3. Fallible operations first
actions.add(Action::Tracked(...))?;
// 4. Mutate state last
state.next_id += 1;
state.pending.insert(id, ...);
Ok(())
}
Key Rules
β Must Do
- Validate before mutating β Check conditions before changing state
- Deterministic IDs β Generate from state counters, not random/time
- Store tracked actions in state β Before emitting, so restore can recreate them
- Fallible ops before mutations β For in-memory state atomicity
β Must Not Do
- No side effects in STF β No HTTP calls, no new connections
- No randomness β No
rand::random(), no unseeded RNGs - No system time β Pass timestamps via Input
- No external reads β Except through
stateparameter
Testing
Deterministic simulation testingβthe killer feature:
#[test]
async fn test_correctness() {
let mut rng = ChaCha8Rng::seed_from_u64(12345);
let mut state = MySystem::new();
let mut actions = Vec::new();
for i in 0..100_000 {
let input = generate_random_input(&mut rng);
let _ = MySystem::stf(&mut state, input, &mut actions).await;
actions.clear();
state.check_invariants()
.expect(&format!("Invariant violated at iteration {}", i));
}
// Same seed = same execution = reproducible bugs
}
Examples
# Simple counter
cargo run --example csm
# Coffee shop loyalty app with tracked/untracked actions
cargo run --example coffee_shop
# Full booking system with simulation tests
cargo test --package dentist_booking
When to Use FASM
β Great For
- Payment processing
- Reservation systems
- Workflow engines
- Distributed systems requiring correctness
β Overkill For
- Simple CRUD apps
- Stateless services
- Prototypes (unless correctness matters)
Version 0.3
- Simplified trait β Uses
async fndirectly (Rust 2024 edition) - Cleaner API β No more manual
Futureimplementations required - Renamed field β
Input::TrackedActionCompleted { id, result }(wasres)
Documentation
License
MIT OR Apache-2.0