Appearance
fsm-rust
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 ./srctext
[fsm-gen] gen: oktoml
[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.rs | crate root: 기계마다 한 줄씩 pub mod order;와 pub mod fsm; |
src/fsm.rs | module 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다:
| 필드 | 타입 | 뜻 |
|---|---|---|
code | Code | 오류 code. code.as_str()가 "E_CONFLICT" 등을 준다 |
message | String | 기계 이름을 담은 메시지 |
expected | Option<i32> | 기대한 상태 code, 또는 None |
actual | Option<i32> | 저장된 상태 code, 또는 None |
event | Option<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가 이를 단언한다:
| Code | expected | actual | event |
|---|---|---|---|
E_CONFLICT | Some | Some | Some |
E_INVALID_TRANSITION | Some | None | Some |
E_UNKNOWN_CODE, E_RESERVED_CODE | None | None | None |
영속화
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이 지금 쓰는 것과 같은지 검사한다.