-
Notifications
You must be signed in to change notification settings - Fork 16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Easy FSM Add-on: Makes making Finite State Machines with go-statemachine easier #4
Merged
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
5ac8748
feat(fsm): add finite state machine module
hannahhoward da5aba1
feat(fsm): add synchronous event handling
hannahhoward b253c92
feat(fsm): more docs and no-transition state handlers
hannahhoward b1ed3ec
refactor(fsm): switch to transition map
hannahhoward 971ff7d
feat(fsm): support fallback transitions
hannahhoward 4f04d27
feat(fsm): add identifier getter
hannahhoward 859924d
feat(fsm): add world building by identifier
hannahhoward 050f1af
refactor(fsm): switch to notifier
hannahhoward 88e6fbf
Revert "feat(fsm): add identifier getter"
hannahhoward 532cb72
refactor(fsm): revert to single environment
hannahhoward 6733fe9
feat(fsm): more informative statehandler errors
hannahhoward 4545331
feat(fsm): add begin function to group
hannahhoward 85461c4
refactor(fsm): builder interfaces
hannahhoward a9094aa
feat(fsm): add test context util
hannahhoward 8c5b822
fix(fsm): doc updates and minor refactors
hannahhoward d855d86
rerun ci
hannahhoward 054b5d1
ci(circle): update golangci-lint
hannahhoward c91c30c
refactor(fsm): rename applyTransition to action
hannahhoward a2059e0
refactor(fsm): a few more renames
hannahhoward fe83f89
feat(fsm): add err tracking to eventbuilder
hannahhoward File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In general, what do you think about emitting a warning or error even if we are clobbering previously-set callbacks or transitions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good point!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I hate to say it thought I'd rather not mess up the fluidity of the interface I might store the error and fail when you try to construct the event machine which is delayed but preserves the DSL.