Appearance
github.com/polyspec/fsm/packages/go
The go consumer of fsm (docs/plans/fsm-v1.md, C-2). The module is private and is a test bed: it is not published. It shows how the generated go code of a fsm definition is used, and it checks that code against the conformance vectors. The generated code of src/ imports only the standard library; the orm packages are needed by the persistence example alone.
Generate into your project
Run the generator in the module of your project, which has a go.mod such as module example.com/app. --out is the src directory of that module, because each machine imports <module>/src/fsm for the shared Error type:
sh
cd /path/to/app
fsm-gen gen --project /path/to/order-machines --kind go --go-module example.com/app --out ./srctext
[fsm-gen] gen: okThe generated files are not edited by hand. Run the command again after every change of the definition.
The vectors are written by fsm-gen vectors --kind go, which takes the module path of the package too (--go-module is required, as for gen):
sh
fsm-gen vectors --project /path/to/order-machines --kind go --go-module example.com/app --out ./tests/vectors_test.gotext
[fsm-gen] vectors: okThe file tests/vectors_test.go is in the package tests. It holds the key tables of every machine, one vectorCase literal per case, and one adapter per machine that maps the keys of a case to the constants of the machine. Its only imports are the machine packages <module>/src/<machine>, named by your module path, so the file builds in your module as it is. The persistence example is the test bed's own: decide returns the function type that Decide names, so the file needs no examples import (plan C-2, S-26-1-1).
Generated files
| File | Content |
|---|---|
src/fsm/fsm.go | the package fsm: the Error type, and the error codes CodeConflict, CodeInvalidTransition, CodeUnknownCode and CodeReservedCode (the values E_CONFLICT and so on) |
src/order/order.go | the package order, one per machine: the types State (int32, the code), Event (int32, the position from 1) and Step, the constants StateDraft… and EventNext…, and the functions of the machine |
Each file starts with // Code generated by fsm-gen from <machine>; DO NOT EDIT., so fsm-gen gen can tell them from your own files.
API
The package order of the example machine has these functions (src/order/order.go):
| Function | Result |
|---|---|
Initial() State | the initial state |
Code(state State) (int32, error) | the code of a state |
Key(state State) (string, error) | the key of a state |
FromCode(value int64) (State, error) | the state of a code; the argument is int64, so a value read from a database is passed as it is |
IsFinal(state State) (bool, error) | whether a state is final |
EventsFrom(state State) ([]Event, error) | the events that have an edge from a state, in declaration order |
Target(expected State, event Event) (State, error) | the state that the event leads to; E_INVALID_TRANSITION when it has no edge |
Apply(expected State, event Event, actual State) (Step, error) | the result of one attempt (see the root README, Transitions) |
Step has the fields To State and Changed bool. Each function returns its result and an error; the error is nil or a *fsm.Error.
The persistence example packages/go/examples/persist.go contains the complete Apply use in a transaction, and it is the pattern to copy (see Persistence). The pure call is the same for a value that is not in a database:
go
step, err := order.Apply(order.StateDraft, order.EventNext, order.StateDraft)
// step == order.Step{To: order.StatePaid, Changed: true}, err == nilThis snippet was run in a temporary module with the generated files of this checkout; its output is:
text
{2 true} <nil>
{2 false} <nil>
E_CONFLICT 1 3 1The three lines are Apply(draft, next, draft), Apply(draft, next, paid) (the state already reached: Unchanged) and Apply(draft, next, shipped) (a conflict, printed from the *fsm.Error fields Code, Expected, Actual, Event).
Errors
Every error is error; the concrete value is *fsm.Error (src/fsm/fsm.go):
| Field | Type | Meaning |
|---|---|---|
Code | string | E_CONFLICT, E_INVALID_TRANSITION, E_UNKNOWN_CODE or E_RESERVED_CODE |
Message | string | the message, which names the machine; Error() returns it |
Expected | int32 | the expected state code, or 0 when the error has none |
Actual | int32 | the stored state code, or 0 when the error has none |
Event | int32 | the event position (from 1), or 0 when the error has none |
Use errors.As to read the fields. Code values are constants of fsm: fsm.CodeConflict and so on. A zero field means unset, because a state code is at least 1 and an event position starts at 1.
Which fields a code sets is the rule of spec B-6 (docs/spec/fsm.md), and the vectors of tests/vectors_test.go assert it:
| Code | Expected | Actual | Event |
|---|---|---|---|
E_CONFLICT | set | set | set |
E_INVALID_TRANSITION | set | 0 (unset) | set |
E_UNKNOWN_CODE, E_RESERVED_CODE | 0 | 0 | 0 |
The message of every code names the machine, and the value of E_UNKNOWN_CODE or E_RESERVED_CODE is in the message (order: 99 is not a state of order). Go has no null, so the zero value is the absent field.
Persistence
examples/persist.go 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 one attempt in one transaction: it reads the row under its lock, passes the stored code to decide, and writes the row only when decide reports a change. Decide is func(stored int64) (to int32, changed bool, err error), so the file serves every machine: a machine's decide is the Apply of the observed state and the event on the FromCode of the stored code. The decide of each machine is written in tests/vectors_test.go; the one of the order machine is:
go
// packages/go/tests/vectors_test.go, orderMachine.decide
func (m orderMachine) decide(expected, event string) func(stored int64) (int32, bool, error) {
return func(stored int64) (int32, bool, error) {
from, err := lookup(orderStates, "state", expected)
if err != nil {
return 0, false, err
}
value, err := lookup(orderEvents, "event", event)
if err != nil {
return 0, false, err
}
observed, err := order.FromCode(stored)
if err != nil {
return 0, false, err
}
step, err := order.Apply(from, value, observed)
if err != nil {
return 0, false, err
}
code, err := order.Code(step.To)
return code, step.Changed, err
}
}The transaction of the persistence example is this function:
go
// packages/go/examples/persist.go, Transition
// Transition is one attempt on the row id: the row is read under its lock, decide runs on the stored code, and the row is updated
// only when the stored state changed. It returns the code of the target and whether the stored state changed. An error of decide
// (E_CONFLICT, E_INVALID_TRANSITION or E_UNKNOWN_CODE) is returned, and the transaction rolls back, so the stored state stays as it
// was.
func Transition(db *orm.DB, id int64, decide Decide) (int32, bool, error) {
var to int32
var changed bool
err := db.Transaction(func() error {
row, err := model.Item().Connect(db).ForUpdate().GetById(id)
if err != nil {
return err
}
to, changed, err = decide(int64(row.GetStatus()))
if err != nil {
return err
}
if !changed {
return nil
}
_, err = row.SetStatus(to).Update()
return err
})
if err != nil {
return 0, false, err
}
return to, changed, nil
}An error of decide (an error of Apply) returns from the callback, so the transaction rolls back and the stored state stays as it was. A retry with the same expected returns Changed false and writes nothing. The model examples/model is generated from contracts/schema/persist.dbs by go tool orm-gen; Open, Seed and StatusOf in the same file create the tables and read a row. The excerpt above and the file use the model package of the test bed, github.com/polyspec/fsm/packages/go/examples/model. A consumer that copies examples/persist.go imports the model package of its own module, generated by orm-gen from its own schema, in the same place.
Tests
From the checkout (the modules must be downloaded once, online):
sh
make -C packages/go install # go mod download
make -C packages/go test # GOPROXY=off, the conformance tests and the persistence example on SQLite
make test-go # the same from the root
make go-persist-test-postgres # the persist cases on PostgreSQL (FSM_TEST_PG_DSN; a missing DSN exits 2)
make go-persist-test-mysql # the persist cases on MySQL (FSM_TEST_MYSQL_DSN)The run passes 89 of 89 cases: tests/conformance_test.go runs one subtest per vector case of tests/vectors_test.go, named by its case name <feature>__<id>. The root make gen-check checks that the generated files are the ones fsm-gen writes today. The go release is the go line of packages/go/go.mod (go 1.27.2, the Go pin of kit).