Skip to content

fsm-rust ​

English

fsm의 rust 소비자(docs/plans/fsm-v1.md, C-2)다. crate는 비공개이며 test bed이고 공개하지 않는다. fsm 정의로부터 생성한 rust 코드를 어떻게 쓰는지 보여주고, 그 코드를 conformance vector로 검사한다. 생성된 library는 표준 라이브러리만 쓰며, orm crate는 영속화 예제에만 필요하다.

프로젝트에 생성 ​

library를 crate의 디렉터리에 생성한다. 생성된 lib.rs는 기계마다 module 하나와 fsm을 선언하는 crate root이므로, Cargo.toml이 그 파일을 library 경로로 지정한다:

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"

정의를 바꿀 때마다 명령을 다시 실행한다. vector는 fsm-gen vectors --kind rust --out <file>로 자신의 test 파일에 쓴다(test bed는 tests/conformance/vectors.rs에 쓴다).

생성 파일 ​

파일내용
src/lib.rscrate root: 기계마다 한 줄씩 pub mod order;와 pub mod fsm;
src/fsm.rsmodule fsm: enum Code(Conflict, InvalidTransition, UnknownCode, ReservedCode; as_str()이 E_CONFLICT 등을 준다)와 struct Error
src/order.rs기계마다 하나인 module: enum State(#[repr(i32)], 값이 code), Event(위치, 1부터), struct Step, 함수

각 파일은 // Code generated by fsm-gen from <machine>; DO NOT EDIT.로 시작한다.

API ​

module order의 함수(src/order.rs):

함수결과
initial() -> State초기 상태
code(state: State) -> i32상태의 code
key(state: State) -> &'static str상태의 key, 예: draft
from_code(value: i64) -> Result<State, Error>code의 상태. 상태가 아닌 값은 E_UNKNOWN_CODE, reserved code는 E_RESERVED_CODE
is_final(state: State) -> bool상태가 최종 상태인지
events_from(state: State) -> &'static [Event]상태에서 edge가 있는 event, 선언 순서
target(expected: State, event: Event) -> Result<State, Error>event가 이르는 상태. edge가 없으면 E_INVALID_TRANSITION
apply(expected: State, event: Event, actual: State) -> Result<Step, Error>한 번 시도의 결과(루트 README의 전이 절 참고)

Step은 to: State와 changed: bool 필드를 가진다. from_code는 i64를 받으므로, database가 돌려준 값을 줄이지 않고 그대로 넘긴다.

완전한 apply 호출은 packages/rust/tests/conformance/vectors.rs에서 그대로 옮겼다. apply feature의 첫 case이며, 결과를 검사하는 test 함수다:

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 }));
}

test bed 밖에서 위의 구조대로 만든 crate(use app::{fsm::Error, order};)가 이 checkout의 생성 파일을 쓰고 실행했을 때의 출력은 다음 두 줄과 같다. 첫 줄은 apply(Draft, Next, Draft), 둘째 줄은 apply(Draft, Next, Shipped)의 충돌이다:

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

오류 ​

함수는 Result<_, fsm::Error>를 돌려준다. Error(src/fsm.rs)는 다음 필드를 가진 struct다:

필드타입뜻
codeCode오류 code. code.as_str()가 "E_CONFLICT" 등을 준다
messageString기계 이름을 담은 메시지
expectedOption<i32>기대한 상태 code, 또는 None
actualOption<i32>저장된 상태 code, 또는 None
eventOption<i32>event 위치(1부터), 또는 None

Error는 Display(E_CONFLICT: the stored state ...)와 std::error::Error를 구현한다.

속성이 어떤 code에서 설정되는지는 명세 B-6(docs/spec/fsm.md)의 규칙이며, tests/conformance/vectors.rs의 vector가 이를 단언한다:

Codeexpectedactualevent
E_CONFLICTSomeSomeSome
E_INVALID_TRANSITIONSomeNoneSome
E_UNKNOWN_CODE, E_RESERVED_CODENoneNoneNone

영속화 ​

examples/persist.rs는 SQLite, PostgreSQL, MySQL과 polyspec orm 위에서 docs/spec/fsm.md, B-4의 패턴이다. 저장된 상태는 row의 code다. transition은 한 전이의 transaction을 실행한다. row를 잠금(for_update) 아래 읽고, 저장된 code를 호출자의 decide closure에 넘기며, closure가 변화를 알릴 때만 row를 쓴다. deadlock이면 transaction을 다시 실행한다(orm transaction, 기본 세 번 재시도. closure는 순수하므로 재실행해도 안전하다). machine 오류는 쓰기 없이 돌려주고, deadlock이 아닌 데이터베이스 오류는 한 번 시도한 뒤 돌려준다:

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)
}

호출자의 closure가 apply 사용이다. packages/rust/tests/conformance/vectors.rs에서 옮겼다(draft 상태에서 next event의 영속화 case):

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),
)

examples/persist.rs의 open, seed, fsm_of는 table을 만들고, row를 쓰고, 읽는다. 모델은 build.rs가 contracts/schema/persist.dbs에서 OUT_DIR에 쓴다. 이 패턴을 쓰는 소비자는 이 package의 build script와 polyspec-orm crate가 필요하다.

테스트 ​

checkout 루트에서 실행한다:

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)

실행 결과는 89개 case 중 89개 통과이며, crate 컴파일을 포함해 약 100초가 걸린다. tests/conformance/vectors.rs는 vector case마다 #[test] 하나를 가지며 이름은 case 이름 <feature>__<id>다(vectors::<feature>__<id>). 각 test는 tests/conformance/main.rs의 검사 함수를 부른다. 영속화 case는 #[tokio::test]다. toolchain은 루트의 rust-toolchain.toml(Rust 1.99.0)이다. 루트의 make gen-check는 생성 파일이 fsm-gen이 지금 쓰는 것과 같은지 검사한다.