Skip to content

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 ./src
text
[fsm-gen] gen: ok

The 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.go
text
[fsm-gen] vectors: ok

The 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 ​

FileContent
src/fsm/fsm.gothe package fsm: the Error type, and the error codes CodeConflict, CodeInvalidTransition, CodeUnknownCode and CodeReservedCode (the values E_CONFLICT and so on)
src/order/order.gothe 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):

FunctionResult
Initial() Statethe 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 == nil

This 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 1

The 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):

FieldTypeMeaning
CodestringE_CONFLICT, E_INVALID_TRANSITION, E_UNKNOWN_CODE or E_RESERVED_CODE
Messagestringthe message, which names the machine; Error() returns it
Expectedint32the expected state code, or 0 when the error has none
Actualint32the stored state code, or 0 when the error has none
Eventint32the 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:

CodeExpectedActualEvent
E_CONFLICTsetsetset
E_INVALID_TRANSITIONset0 (unset)set
E_UNKNOWN_CODE, E_RESERVED_CODE000

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