Skip to content

fsm-rust ​

한국어

The rust consumer of fsm (docs/plans/fsm-v1.md, C-2). The crate is private and is a test bed: it is not published. It shows how the generated rust code of a fsm definition is used, and it checks that code against the conformance vectors. The generated library uses the standard library only; the orm crates are needed by the persistence example alone.

Generate into your project ​

Generate the library into a directory of your crate. The generated lib.rs is the crate root that declares one module per machine and fsm, so your Cargo.toml names it as the library path:

sh
fsm-gen gen --project /path/to/order-machines --kind rust --out ./src
text
[fsm-gen] gen: ok
toml
[package]
name = "app"
version = "0.1.0"
edition = "2021"

[lib]
path = "src/lib.rs"

Run the command again after every change of the definition. The vectors come from fsm-gen vectors --kind rust --out <file>, written into a test file of your own (the test bed writes tests/conformance/vectors.rs).

Generated files ​

FileContent
src/lib.rsthe crate root: pub mod order; and pub mod fsm; (one line per machine)
src/fsm.rsthe module fsm: the enum Code (Conflict, InvalidTransition, UnknownCode, ReservedCode, with as_str() giving E_CONFLICT and so on) and the struct Error
src/order.rsone module per machine: the enums State (#[repr(i32)], the value is the code) and Event (the position from 1), the struct Step, and the functions

Each file starts with // Code generated by fsm-gen from <machine>; DO NOT EDIT..

API ​

The functions of the module order (src/order.rs):

FunctionResult
initial() -> Statethe initial state
code(state: State) -> i32the code of a state
key(state: State) -> &'static strthe key of a state, such as draft
from_code(value: i64) -> Result<State, Error>the state of a code; E_UNKNOWN_CODE for a value that is no state, E_RESERVED_CODE for a reserved code
is_final(state: State) -> boolwhether a state is final
events_from(state: State) -> &'static [Event]the events that have an edge from a state, in declaration order
target(expected: State, event: Event) -> Result<State, Error>the state that the event leads to; E_INVALID_TRANSITION when it has no edge
apply(expected: State, event: Event, actual: State) -> Result<Step, Error>the result of one attempt (see the root README, Transitions)

Step has the fields to: State and changed: bool. from_code takes an i64, so a value that a database returns is passed without narrowing it.

A complete apply call, copied from packages/rust/tests/conformance/vectors.rs (the first case of the apply feature, which is a test function that checks the result):

rust
#[test]
fn test_apply__order_apply_s0_e0_s0() {
    crate::check_result(order::apply(order::State::Draft, order::Event::Next, order::State::Draft), Ok(order::Step { to: order::State::Paid, changed: true }));
}

The same program outside the test bed, in a crate of the layout above with use app::{fsm::Error, order};, printed these lines when it was run against the generated files of this checkout (the first line is apply(Draft, Next, Draft), the second is the conflict of apply(Draft, Next, Shipped)):

text
2 true
E_CONFLICT Some(1) Some(3) Some(1)

Errors ​

The functions return Result<_, fsm::Error>. Error (src/fsm.rs) is a struct with these fields:

FieldTypeMeaning
codeCodethe code of the error; code.as_str() gives "E_CONFLICT" and so on
messageStringthe message, which names the machine
expectedOption<i32>the expected state code, or None
actualOption<i32>the stored state code, or None
eventOption<i32>the event position (from 1), or None

Error implements Display (E_CONFLICT: the stored state ...) and std::error::Error.

Which fields a code sets is the rule of spec B-6 (docs/spec/fsm.md), and the vectors of tests/conformance/vectors.rs assert it:

Codeexpectedactualevent
E_CONFLICTSomeSomeSome
E_INVALID_TRANSITIONSomeNoneSome
E_UNKNOWN_CODE, E_RESERVED_CODENoneNoneNone

Persistence ​

examples/persist.rs is the pattern of docs/spec/fsm.md, B-4 with polyspec orm on SQLite, PostgreSQL and MySQL (the DSN given to open selects the dialect). The stored state is a code in a row. transition runs the transaction of one transition: it reads the row under its lock (for_update), passes the stored code to the decide closure of the caller, and writes the row only when the closure reports a change. A deadlock runs the transaction again (orm transaction, three retries by default; the closure is pure, so a rerun is safe). A machine error is returned without a write, and a database error other than a deadlock is returned after one attempt:

rust
// packages/rust/examples/persist.rs, transition_with_fault (transition calls it with no fault)
pub async fn transition_with_fault(
    db: &Db,
    id: i64,
    decide: impl Fn(i64) -> Result<(i32, bool), Error>,
    fault: impl Fn() -> polyspec_orm::Result<()>,
) -> Result<(i32, bool), Failure> {
    let outcome = db
        .transaction(async || {
            fault()?;
            let row = Item::new().connect(db).id(id).for_update().get().await?;
            let step = decide(i64::from(row.get_status()?));
            if let Ok((to, true)) = step {
                row.set_status(to).update(false).await?;
            }
            Ok(step)
        })
        .await
        .map_err(Failure::Database)?;
    outcome.map_err(Failure::Machine)
}

The caller's closure is the apply use. It is copied from packages/rust/tests/conformance/vectors.rs (the persist case of the draft state with the next event):

rust
crate::check_persist(
    order::code(order::State::Draft),
    |stored| {
        let actual = order::from_code(stored)?;
        let step = order::apply(order::State::Draft, order::Event::Next, actual)?;
        Ok((order::code(step.to), step.changed))
    },
    Ok((order::code(order::State::Paid), true)),
    order::code(order::State::Paid),
)

open, seed and fsm_of in examples/persist.rs create the tables, write a row and read it. The models come from contracts/schema/persist.dbs through build.rs, which writes them into OUT_DIR; a consumer that uses this pattern needs the build script of this package and the polyspec-orm crates.

Tests ​

From the checkout root:

sh
make -C packages/rust install   # cargo fetch --locked: the crates of Cargo.lock (the orm release v0.0.18 by its git tag)
make -C packages/rust test      # cargo test through kit's run-tests
make test-rust                      # the same from the root
make rust-persist-test-postgres   # the persist cases on PostgreSQL (FSM_TEST_PG_DSN; a missing DSN exits 2)
make rust-persist-test-mysql      # the persist cases on MySQL (FSM_TEST_MYSQL_DSN)

The run passes 89 of 89 cases in about 100 seconds, which includes the compilation of the crate. tests/conformance/vectors.rs has one #[test] per vector case, named by its case name <feature>__<id> (vectors::<feature>__<id>), and each calls a check function of tests/conformance/main.rs; the persist cases are #[tokio::test]. The toolchain is rust-toolchain.toml at the root (Rust 1.99.0). The root make gen-check checks that the generated files are the ones fsm-gen writes today.