Skip to main content

Crate fasm

Crate fasm 

Source
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 state parameter. 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

  1. Atomicity: If STF returns Err, state must be unchanged (enforced by transaction or by careful ordering of operations).

  2. Determinism: Same state + same input = same output. No randomness, system time, or external I/O in the STF. All external data comes through input.

  3. No Side Effects: STF only mutates state and emits action descriptions. Actual side effects (HTTP calls, sending emails) happen after commit.

  4. Tracked Actions in State: Before emitting a tracked action, store enough data in state that restore() can recreate it after a crash.

  5. 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 bugs

Modules§

actions
Actions emitted by state transition functions.

Enums§

Input
Input to the state transition function.

Traits§

StateMachine
A deterministic, recoverable async state machine.