-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Easy FSM Add-on: Makes making Finite State Machines with go-statemach…
…ine easier (#4) * feat(fsm): add finite state machine module add module for constructing statehandlers as simple finite state machines, to support readable definitions, type checking, and testing * feat(fsm): add synchronous event handling Add the ability ot dispatch a synchronous event to the state machine and wait for a result * feat(fsm): more docs and no-transition state handlers * refactor(fsm): switch to transition map Use a full transition map for events to make them more flexible * feat(fsm): support fallback transitions support a transition that applies to any source state * feat(fsm): add identifier getter Add ability to get identifier for this instance of the state machine * feat(fsm): add world building by identifier Allow each instance of fsm to get a unique instance of outside dependencies * refactor(fsm): switch to notifier remove universal events, add notifications, add params struct, make event name an interface * Revert "feat(fsm): add identifier getter" This reverts commit 206d210. * refactor(fsm): revert to single environment remove world by indentifier for simplicity, rename world to environment * feat(fsm): more informative statehandler errors add more data about why state handlers fail * feat(fsm): add begin function to group add begin function from base group to fsm group * refactor(fsm): builder interfaces simplify builders for state, extract state machine from event machine * feat(fsm): add test context util adds a utility class for testing state machines by allowing contexts to actually be replayed with a given event machine * fix(fsm): doc updates and minor refactors * rerun ci * ci(circle): update golangci-lint * refactor(fsm): rename applyTransition to action Rename applyTransition to action to match actual FSM concept it represents * refactor(fsm): a few more renames rename event -> trigger with context rename eventMachine -> eventProcessor rename eventProcessor.Event -> eventProcessor.Generate rename stateHandler -> stateEntryFunc * feat(fsm): add err tracking to eventbuilder track various DSL errors in event builder so it fails when used
- Loading branch information
1 parent
d47ae06
commit e5b11df
Showing
11 changed files
with
1,249 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
package fsm | ||
|
||
import "golang.org/x/xerrors" | ||
|
||
type transitionToBuilder struct { | ||
name EventName | ||
action ActionFunc | ||
transitionsSoFar map[StateKey]StateKey | ||
nextFrom []StateKey | ||
} | ||
|
||
// To means the transition ends in the given state | ||
func (t transitionToBuilder) To(to StateKey) EventBuilder { | ||
transitions := t.transitionsSoFar | ||
for _, from := range t.nextFrom { | ||
transitions[from] = to | ||
} | ||
return eventBuilder{t.name, t.action, transitions} | ||
} | ||
|
||
// ToNoChange means a transition ends in the same state it started in (just retriggers state cb) | ||
func (t transitionToBuilder) ToNoChange() EventBuilder { | ||
transitions := t.transitionsSoFar | ||
for _, from := range t.nextFrom { | ||
transitions[from] = nil | ||
} | ||
return eventBuilder{t.name, t.action, transitions} | ||
} | ||
|
||
type eventBuilder struct { | ||
name EventName | ||
action ActionFunc | ||
transitionsSoFar map[StateKey]StateKey | ||
} | ||
|
||
// From begins describing a transition from a specific state | ||
func (t eventBuilder) From(s StateKey) TransitionToBuilder { | ||
_, ok := t.transitionsSoFar[s] | ||
if ok { | ||
return errBuilder{t.name, xerrors.Errorf("duplicate transition source `%v` for event `%v`", s, t.name)} | ||
} | ||
return transitionToBuilder{ | ||
t.name, | ||
t.action, | ||
t.transitionsSoFar, | ||
[]StateKey{s}, | ||
} | ||
} | ||
|
||
// FromAny begins describing a transition from any state | ||
func (t eventBuilder) FromAny() TransitionToBuilder { | ||
_, ok := t.transitionsSoFar[nil] | ||
if ok { | ||
return errBuilder{t.name, xerrors.Errorf("duplicate all-sources destination for event `%v`", t.name)} | ||
} | ||
return transitionToBuilder{ | ||
t.name, | ||
t.action, | ||
t.transitionsSoFar, | ||
[]StateKey{nil}, | ||
} | ||
} | ||
|
||
// FromMany begins describing a transition from many states | ||
func (t eventBuilder) FromMany(sources ...StateKey) TransitionToBuilder { | ||
for _, source := range sources { | ||
_, ok := t.transitionsSoFar[source] | ||
if ok { | ||
return errBuilder{t.name, xerrors.Errorf("duplicate transition source `%v` for event `%v`", source, t.name)} | ||
} | ||
} | ||
return transitionToBuilder{ | ||
t.name, | ||
t.action, | ||
t.transitionsSoFar, | ||
sources, | ||
} | ||
} | ||
|
||
// Action describes actions taken on the state for this event | ||
func (t eventBuilder) Action(action ActionFunc) EventBuilder { | ||
if t.action != nil { | ||
return errBuilder{t.name, xerrors.Errorf("duplicate action for event `%v`", t.name)} | ||
} | ||
return eventBuilder{ | ||
t.name, | ||
action, | ||
t.transitionsSoFar, | ||
} | ||
} | ||
|
||
type errBuilder struct { | ||
name EventName | ||
err error | ||
} | ||
|
||
// From passes on the error | ||
func (e errBuilder) From(s StateKey) TransitionToBuilder { | ||
return e | ||
} | ||
|
||
// FromAny passes on the error | ||
func (e errBuilder) FromAny() TransitionToBuilder { | ||
return e | ||
} | ||
|
||
// FromMany passes on the error | ||
func (e errBuilder) FromMany(sources ...StateKey) TransitionToBuilder { | ||
return e | ||
} | ||
|
||
// Action passes on the error | ||
func (e errBuilder) Action(action ActionFunc) EventBuilder { | ||
return e | ||
} | ||
|
||
// To passes on the error | ||
func (e errBuilder) To(_ StateKey) EventBuilder { | ||
return e | ||
} | ||
|
||
// ToNoChange passes on the error | ||
func (e errBuilder) ToNoChange() EventBuilder { | ||
return e | ||
} | ||
|
||
// Event starts building a new event | ||
func Event(name EventName) EventBuilder { | ||
return eventBuilder{name, nil, map[StateKey]StateKey{}} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,198 @@ | ||
package fsm | ||
|
||
import ( | ||
"context" | ||
"reflect" | ||
|
||
"github.com/filecoin-project/go-statemachine" | ||
"golang.org/x/xerrors" | ||
) | ||
|
||
// EventProcessor creates and applies events for go-statemachine based on the given event list | ||
type EventProcessor interface { | ||
// Event generates an event that can be dispatched to go-statemachine from the given event name and context args | ||
Generate(ctx context.Context, event EventName, returnChannel chan error, args ...interface{}) (interface{}, error) | ||
// Apply applies the given event from go-statemachine to the given state, based on transition rules | ||
Apply(evt statemachine.Event, user interface{}) (EventName, error) | ||
} | ||
|
||
type eventProcessor struct { | ||
stateType reflect.Type | ||
stateKeyField StateKeyField | ||
callbacks map[EventName]callback | ||
transitions map[eKey]StateKey | ||
} | ||
|
||
// eKey is a struct key used for storing the transition map. | ||
type eKey struct { | ||
// event is the name of the event that the keys refers to. | ||
event EventName | ||
|
||
// src is the source from where the event can transition. | ||
src interface{} | ||
} | ||
|
||
// callback stores a transition function and its argument types | ||
type callback struct { | ||
argumentTypes []reflect.Type | ||
action ActionFunc | ||
} | ||
|
||
// fsmEvent is the internal event type | ||
type fsmEvent struct { | ||
name EventName | ||
args []interface{} | ||
ctx context.Context | ||
returnChannel chan error | ||
} | ||
|
||
// NewEventProcessor returns a new event machine for the given state and event list | ||
func NewEventProcessor(state StateType, stateKeyField StateKeyField, events []EventBuilder) (EventProcessor, error) { | ||
stateType := reflect.TypeOf(state) | ||
stateFieldType, ok := stateType.FieldByName(string(stateKeyField)) | ||
if !ok { | ||
return nil, xerrors.Errorf("state type has no field `%s`", stateKeyField) | ||
} | ||
if !stateFieldType.Type.Comparable() { | ||
return nil, xerrors.Errorf("state field `%s` is not comparable", stateKeyField) | ||
} | ||
|
||
em := eventProcessor{ | ||
stateType: stateType, | ||
stateKeyField: stateKeyField, | ||
callbacks: make(map[EventName]callback), | ||
transitions: make(map[eKey]StateKey), | ||
} | ||
|
||
// Build transition map and store sets of all events and states. | ||
for _, evtIface := range events { | ||
evt, ok := evtIface.(eventBuilder) | ||
if !ok { | ||
errEvt := evtIface.(errBuilder) | ||
return nil, errEvt.err | ||
} | ||
|
||
name := evt.name | ||
|
||
_, exists := em.callbacks[name] | ||
if exists { | ||
return nil, xerrors.Errorf("Duplicate event name `%+v`", name) | ||
} | ||
|
||
argumentTypes, err := inspectActionFunc(name, evt.action, stateType) | ||
if err != nil { | ||
return nil, err | ||
} | ||
em.callbacks[name] = callback{ | ||
argumentTypes: argumentTypes, | ||
action: evt.action, | ||
} | ||
for src, dst := range evt.transitionsSoFar { | ||
if dst != nil && !reflect.TypeOf(dst).AssignableTo(stateFieldType.Type) { | ||
return nil, xerrors.Errorf("event `%+v` destination type is not assignable to: %s", name, stateFieldType.Type.Name()) | ||
} | ||
if src != nil && !reflect.TypeOf(src).AssignableTo(stateFieldType.Type) { | ||
return nil, xerrors.Errorf("event `%+v` source type is not assignable to: %s", name, stateFieldType.Type.Name()) | ||
} | ||
em.transitions[eKey{name, src}] = dst | ||
} | ||
} | ||
return em, nil | ||
} | ||
|
||
// Event generates an event that can be dispatched to go-statemachine from the given event name and context args | ||
func (em eventProcessor) Generate(ctx context.Context, event EventName, returnChannel chan error, args ...interface{}) (interface{}, error) { | ||
cb, ok := em.callbacks[event] | ||
if !ok { | ||
return fsmEvent{}, xerrors.Errorf("Unknown event `%+v`", event) | ||
} | ||
if len(args) != len(cb.argumentTypes) { | ||
return fsmEvent{}, xerrors.Errorf("Wrong number of arguments for event `%+v`", event) | ||
} | ||
for i, arg := range args { | ||
if !reflect.TypeOf(arg).AssignableTo(cb.argumentTypes[i]) { | ||
return fsmEvent{}, xerrors.Errorf("Incorrect argument type at index `%d` for event `%+v`", i, event) | ||
} | ||
} | ||
return fsmEvent{event, args, ctx, returnChannel}, nil | ||
} | ||
|
||
func (em eventProcessor) Apply(evt statemachine.Event, user interface{}) (EventName, error) { | ||
userValue := reflect.ValueOf(user) | ||
currentState := userValue.Elem().FieldByName(string(em.stateKeyField)).Interface() | ||
e, ok := evt.User.(fsmEvent) | ||
if !ok { | ||
return nil, xerrors.New("Not an fsm event") | ||
} | ||
destination, ok := em.transitions[eKey{e.name, currentState}] | ||
// check for fallback transition for any source state | ||
if !ok { | ||
destination, ok = em.transitions[eKey{e.name, nil}] | ||
} | ||
if !ok { | ||
return nil, completeEvent(e, xerrors.Errorf("Invalid transition in queue, state `%+v`, event `%+v`", currentState, e.name)) | ||
} | ||
cb := em.callbacks[e.name] | ||
err := applyAction(userValue, e, cb) | ||
if err != nil { | ||
return nil, completeEvent(e, err) | ||
} | ||
if destination != nil { | ||
userValue.Elem().FieldByName(string(em.stateKeyField)).Set(reflect.ValueOf(destination)) | ||
} | ||
|
||
return e.name, completeEvent(e, nil) | ||
} | ||
|
||
// Apply applies the given event from go-statemachine to the given state, based on transition rules | ||
func applyAction(userValue reflect.Value, e fsmEvent, cb callback) error { | ||
if cb.action == nil { | ||
return nil | ||
} | ||
values := make([]reflect.Value, 0, len(e.args)+1) | ||
values = append(values, userValue) | ||
for _, arg := range e.args { | ||
values = append(values, reflect.ValueOf(arg)) | ||
} | ||
res := reflect.ValueOf(cb.action).Call(values) | ||
|
||
if res[0].Interface() != nil { | ||
return xerrors.Errorf("Error applying event transition `%+v`: %w", e.name, res[0].Interface().(error)) | ||
} | ||
return nil | ||
} | ||
|
||
func completeEvent(event fsmEvent, err error) error { | ||
if event.returnChannel != nil { | ||
select { | ||
case <-event.ctx.Done(): | ||
case event.returnChannel <- err: | ||
} | ||
} | ||
return err | ||
} | ||
|
||
func inspectActionFunc(name EventName, action ActionFunc, stateType reflect.Type) ([]reflect.Type, error) { | ||
if action == nil { | ||
return nil, nil | ||
} | ||
|
||
atType := reflect.TypeOf(action) | ||
if atType.Kind() != reflect.Func { | ||
return nil, xerrors.Errorf("event `%+v` has a callback that is not a function", name) | ||
} | ||
if atType.NumIn() < 1 { | ||
return nil, xerrors.Errorf("event `%+v` has a callback that does not take the state", name) | ||
} | ||
if !reflect.PtrTo(stateType).AssignableTo(atType.In(0)) { | ||
return nil, xerrors.Errorf("event `%+v` has a callback that does not take the state", name) | ||
} | ||
if atType.NumOut() != 1 || atType.Out(0).AssignableTo(reflect.TypeOf(new(error))) { | ||
return nil, xerrors.Errorf("event `%+v` callback should return exactly one param that is an error", name) | ||
} | ||
argumentTypes := make([]reflect.Type, atType.NumIn()-1) | ||
for i := range argumentTypes { | ||
argumentTypes[i] = atType.In(i + 1) | ||
} | ||
return argumentTypes, nil | ||
} |
Oops, something went wrong.