diff --git a/.circleci/config.yml b/.circleci/config.yml index 5518125..d569308 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -130,7 +130,7 @@ jobs: default: golang golangci-lint-version: type: string - default: 1.17.1 + default: 1.23.6 executor: << parameters.executor >> steps: - install-deps diff --git a/fsm/eventbuilder.go b/fsm/eventbuilder.go new file mode 100644 index 0000000..7d7a703 --- /dev/null +++ b/fsm/eventbuilder.go @@ -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{}} +} diff --git a/fsm/eventprocessor.go b/fsm/eventprocessor.go new file mode 100644 index 0000000..ead1684 --- /dev/null +++ b/fsm/eventprocessor.go @@ -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 +} diff --git a/fsm/fsm.go b/fsm/fsm.go new file mode 100644 index 0000000..a4cf560 --- /dev/null +++ b/fsm/fsm.go @@ -0,0 +1,143 @@ +package fsm + +import ( + "context" + "reflect" + + "github.com/filecoin-project/go-statemachine" + logging "github.com/ipfs/go-log" + "golang.org/x/xerrors" +) + +var log = logging.Logger("fsm") + +type fsmHandler struct { + stateType reflect.Type + stateKeyField StateKeyField + notifier Notifier + eventProcessor EventProcessor + stateEntryFuncs StateEntryFuncs + environment Environment +} + +// NewFSMHandler defines an StateHandler for go-statemachine that implements +// a traditional Finite State Machine model -- transitions, start states, +// end states, and callbacks +func NewFSMHandler(parameters Parameters) (statemachine.StateHandler, error) { + environmentType := reflect.TypeOf(parameters.Environment) + stateType := reflect.TypeOf(parameters.StateType) + stateFieldType, ok := stateType.FieldByName(string(parameters.StateKeyField)) + if !ok { + return nil, xerrors.Errorf("state type has no field `%s`", parameters.StateKeyField) + } + if !stateFieldType.Type.Comparable() { + return nil, xerrors.Errorf("state field `%s` is not comparable", parameters.StateKeyField) + } + + eventProcessor, err := NewEventProcessor(parameters.StateType, parameters.StateKeyField, parameters.Events) + if err != nil { + return nil, err + } + d := fsmHandler{ + environment: parameters.Environment, + stateType: stateType, + stateKeyField: parameters.StateKeyField, + eventProcessor: eventProcessor, + stateEntryFuncs: make(StateEntryFuncs), + notifier: parameters.Notifier, + } + + // type check state handlers + for state, stateEntryFunc := range parameters.StateEntryFunc { + if !reflect.TypeOf(state).AssignableTo(stateFieldType.Type) { + return nil, xerrors.Errorf("state key is not assignable to: %s", stateFieldType.Type.Name()) + } + err := inspectStateEntryFunc(stateEntryFunc, environmentType, d.stateType) + if err != nil { + return nil, err + } + d.stateEntryFuncs[state] = stateEntryFunc + } + + return d, nil +} + +// Plan executes events according to finite state machine logic +// It checks to see if the events can applied based on the current state, +// then applies the transition, updating the keyed state in the process +// It only applies one event per planning, to preserve predictable behavior +// for the statemachine -- given a set of events, received in a given order +// the exact same updates will occur, and the exact same state handlers will get +// called +// At the end it executes the specified handler for the final state, +// if specified +func (d fsmHandler) Plan(events []statemachine.Event, user interface{}) (interface{}, uint64, error) { + eventName, err := d.eventProcessor.Apply(events[0], user) + if err != nil { + log.Errorf("Executing event planner failed: %+v", err) + return nil, 1, nil + } + userValue := reflect.ValueOf(user) + currentState := userValue.Elem().FieldByName(string(d.stateKeyField)).Interface() + if d.notifier != nil { + d.notifier(eventName, userValue.Elem().Interface()) + } + return d.handler(d.stateEntryFuncs[currentState]), 1, nil +} + +// handler makes a state next step function from the given callback +func (d fsmHandler) handler(cb interface{}) interface{} { + if cb == nil { + return nil + } + handlerType := reflect.FuncOf([]reflect.Type{reflect.TypeOf(statemachine.Context{}), d.stateType}, []reflect.Type{reflect.TypeOf(new(error)).Elem()}, false) + return reflect.MakeFunc(handlerType, func(args []reflect.Value) (results []reflect.Value) { + ctx := args[0].Interface().(statemachine.Context) + state := args[1].Interface() + dContext := fsmContext{state, ctx, d} + return reflect.ValueOf(cb).Call([]reflect.Value{reflect.ValueOf(dContext), reflect.ValueOf(d.environment), args[1]}) + }).Interface() +} + +type fsmContext struct { + state interface{} + ctx statemachine.Context + d fsmHandler +} + +func (dc fsmContext) Context() context.Context { + return dc.ctx.Context() +} + +func (dc fsmContext) Trigger(event EventName, args ...interface{}) error { + evt, err := dc.d.eventProcessor.Generate(dc.ctx.Context(), event, nil, args...) + if err != nil { + return err + } + return dc.ctx.Send(evt) +} + +var _ Context = fsmContext{} + +func inspectStateEntryFunc(stateEntryFunc interface{}, environmentType reflect.Type, stateType reflect.Type) error { + stateEntryFuncType := reflect.TypeOf(stateEntryFunc) + if stateEntryFuncType.Kind() != reflect.Func { + return xerrors.Errorf("handler for state is not a function") + } + if stateEntryFuncType.NumIn() != 3 { + return xerrors.Errorf("handler for state does not take correct number of arguments") + } + if !reflect.TypeOf((*Context)(nil)).Elem().AssignableTo(stateEntryFuncType.In(0)) { + return xerrors.Errorf("handler for state does not match context parameter") + } + if !environmentType.AssignableTo(stateEntryFuncType.In(1)) { + return xerrors.Errorf("handler for state does not match environment parameter") + } + if !stateType.AssignableTo(stateEntryFuncType.In(2)) { + return xerrors.Errorf("handler for state does not match state parameter") + } + if stateEntryFuncType.NumOut() != 1 || !stateEntryFuncType.Out(0).AssignableTo(reflect.TypeOf(new(error)).Elem()) { + return xerrors.Errorf("handler for state does not return an error") + } + return nil +} diff --git a/fsm/fsm_group.go b/fsm/fsm_group.go new file mode 100644 index 0000000..90c2b12 --- /dev/null +++ b/fsm/fsm_group.go @@ -0,0 +1,62 @@ +package fsm + +import ( + "context" + + "github.com/filecoin-project/go-statemachine" + "github.com/ipfs/go-datastore" + "golang.org/x/xerrors" +) + +// StateGroup manages a group of states with finite state machine logic +type stateGroup struct { + *statemachine.StateGroup + d fsmHandler +} + +// New generates a new state group that operates like a finite state machine, +// based on the given parameters +// ds: data store where state comes from +// parameters: finite state machine parameters +func New(ds datastore.Datastore, parameters Parameters) (Group, error) { + handler, err := NewFSMHandler(parameters) + if err != nil { + return nil, err + } + d := handler.(fsmHandler) + return &stateGroup{StateGroup: statemachine.New(ds, handler, parameters.StateType), d: d}, nil +} + +// Send sends the given event name and parameters to the state specified by id +// it will error if there are underlying state store errors or if the parameters +// do not match what is expected for the event name +func (s *stateGroup) Send(id interface{}, name EventName, args ...interface{}) (err error) { + evt, err := s.d.eventProcessor.Generate(context.TODO(), name, nil, args...) + if err != nil { + return err + } + return s.StateGroup.Send(id, evt) +} + +// SendSync sends the given event name and parameters to the state specified by id +// it will error if there are underlying state store errors or if the parameters +// do not match what is expected for the event name +func (s *stateGroup) SendSync(ctx context.Context, id interface{}, name EventName, args ...interface{}) (err error) { + returnChannel := make(chan error, 1) + evt, err := s.d.eventProcessor.Generate(ctx, name, returnChannel, args...) + if err != nil { + return err + } + + err = s.StateGroup.Send(id, evt) + if err != nil { + return err + } + + select { + case <-ctx.Done(): + return xerrors.New("Context cancelled") + case err := <-returnChannel: + return err + } +} diff --git a/fsm/fsm_test.go b/fsm/fsm_test.go new file mode 100644 index 0000000..0bf61cb --- /dev/null +++ b/fsm/fsm_test.go @@ -0,0 +1,506 @@ +package fsm_test + +import ( + "context" + "testing" + + "github.com/ipfs/go-datastore" + logging "github.com/ipfs/go-log" + "github.com/stretchr/testify/require" + "gotest.tools/assert" + + "github.com/filecoin-project/go-statemachine" + "github.com/filecoin-project/go-statemachine/fsm" +) + +func init() { + logging.SetLogLevel("*", "INFO") // nolint: errcheck +} + +type testEnvironment struct { + universalCalls uint64 + t *testing.T + proceed chan struct{} + done chan struct{} +} + +var events = fsm.Events{ + fsm.Event("start").From(uint64(0)).To(uint64(1)), + fsm.Event("restart").FromMany(uint64(1), uint64(2)).To(uint64(1)), + fsm.Event("b").From(uint64(1)).To(uint64(2)).Action( + func(state *statemachine.TestState, val uint64) error { + state.B = val + return nil + }, + ), + fsm.Event("resume").FromMany(uint64(1), uint64(2)).ToNoChange(), + fsm.Event("any").FromAny().To(uint64(1)), +} + +var stateEntryFuncs = fsm.StateEntryFuncs{ + + uint64(1): func(ctx fsm.Context, te *testEnvironment, ts statemachine.TestState) error { + err := ctx.Trigger("b", uint64(55)) + assert.NilError(te.t, err) + <-te.proceed + return nil + }, + uint64(2): func(ctx fsm.Context, te *testEnvironment, ts statemachine.TestState) error { + + assert.Equal(te.t, uint64(2), ts.A) + close(te.done) + return nil + }, +} + +func TestTypeCheckingOnSetup(t *testing.T) { + ds := datastore.NewMapDatastore() + te := &testEnvironment{t: t, done: make(chan struct{}), proceed: make(chan struct{})} + t.Run("Bad state field", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "Jesus", + Events: events, + StateEntryFunc: stateEntryFuncs, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "state type has no field `Jesus`") + }) + t.Run("State field not comparable", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "C", + Events: events, + StateEntryFunc: stateEntryFuncs, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "state field `C` is not comparable") + }) + t.Run("Event description has bad source type", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: fsm.Events{fsm.Event("start").From("happy").To(uint64(1))}, + StateEntryFunc: stateEntryFuncs, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "event `start` source type is not assignable to: uint64") + }) + t.Run("Event description has bad destination type", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: fsm.Events{fsm.Event("start").From(uint64(1)).To("happy")}, + StateEntryFunc: stateEntryFuncs, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "event `start` destination type is not assignable to: uint64") + }) + t.Run("Event description has callback that is not a function", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: fsm.Events{fsm.Event("b").From(uint64(1)).To(uint64(2)).Action("applesuace")}, + StateEntryFunc: stateEntryFuncs, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "event `b` has a callback that is not a function") + }) + t.Run("Event description has callback with no parameters", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: fsm.Events{fsm.Event("b").From(uint64(1)).To(uint64(2)).Action(func() {})}, + StateEntryFunc: stateEntryFuncs, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "event `b` has a callback that does not take the state") + }) + t.Run("Event description has callback with wrong first parameter", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: fsm.Events{ + fsm.Event("b").From(uint64(1)).To(uint64(2)).Action(func(uint64) error { return nil }), + }, + StateEntryFunc: stateEntryFuncs, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "event `b` has a callback that does not take the state") + }) + t.Run("Event description has callback that doesn't return an error", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: fsm.Events{ + fsm.Event("b").From(uint64(1)).To(uint64(2)).Action(func(*statemachine.TestState) {}), + }, + StateEntryFunc: stateEntryFuncs, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "event `b` callback should return exactly one param that is an error") + }) + t.Run("Event description has transition source twice", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: fsm.Events{ + fsm.Event("b").From(uint64(1)).To(uint64(2)).From(uint64(1)).To(uint64(0)), + }, + StateEntryFunc: stateEntryFuncs, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "duplicate transition source `1` for event `b`") + }) + t.Run("Event description has overlapping transition source twice", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: fsm.Events{ + fsm.Event("b").FromMany(uint64(0), uint64(1)).To(uint64(2)).FromMany(uint64(2), uint64(1)).To(uint64(0)), + }, + StateEntryFunc: stateEntryFuncs, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "duplicate transition source `1` for event `b`") + }) + t.Run("Event description has from any source twice", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: fsm.Events{ + fsm.Event("b").FromAny().To(uint64(2)).FromAny().To(uint64(0)), + }, + StateEntryFunc: stateEntryFuncs, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "duplicate all-sources destination for event `b`") + }) + t.Run("Event description has callback defined twice", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: fsm.Events{ + fsm.Event("b").From(uint64(1)).To(uint64(2)).Action(func(*statemachine.TestState) error { + return nil + }).Action(func(*statemachine.TestState) error { + return nil + }), + }, + StateEntryFunc: stateEntryFuncs, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "duplicate action for event `b`") + }) + t.Run("State Handler with bad stateKey", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: events, + StateEntryFunc: fsm.StateEntryFuncs{ + "apples": func(ctx fsm.Context, te *testEnvironment, ts statemachine.TestState) error { + err := ctx.Trigger("b", uint64(55)) + assert.NilError(te.t, err) + <-te.proceed + return nil + }, + }, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "state key is not assignable to: uint64") + }) + t.Run("State Handler is not a function", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: events, + StateEntryFunc: fsm.StateEntryFuncs{ + uint64(1): "cheese", + }, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "handler for state is not a function") + }) + t.Run("State Handler has wrong parameter count", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: events, + StateEntryFunc: fsm.StateEntryFuncs{ + uint64(1): func() error { + return nil + }, + }, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "handler for state does not take correct number of arguments") + }) + t.Run("State Handler has no context parameter", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: events, + StateEntryFunc: fsm.StateEntryFuncs{ + uint64(1): func(ctx uint64, te *testEnvironment, ts statemachine.TestState) error { + return nil + }, + }, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "handler for state does not match context parameter") + }) + t.Run("State Handler has wrong environment parameter", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: events, + StateEntryFunc: fsm.StateEntryFuncs{ + uint64(1): func(ctx fsm.Context, te uint64, ts statemachine.TestState) error { + return nil + }, + }, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "handler for state does not match environment parameter") + }) + t.Run("State Handler has wrong state parameter", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: events, + StateEntryFunc: fsm.StateEntryFuncs{ + uint64(1): func(ctx fsm.Context, te *testEnvironment, ts uint64) error { + return nil + }, + }, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "handler for state does not match state parameter") + }) + + t.Run("State Handler has wrong return", func(t *testing.T) { + smm, err := fsm.New(ds, fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: events, + StateEntryFunc: fsm.StateEntryFuncs{ + uint64(1): func(ctx fsm.Context, te *testEnvironment, ts statemachine.TestState) { + }, + }, + Notifier: nil, + }) + require.Nil(t, smm) + require.EqualError(t, err, "handler for state does not return an error") + }) +} + +func newFsm(ds datastore.Datastore, te *testEnvironment) (fsm.Group, error) { + defaultFsmParams := fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: events, + StateEntryFunc: stateEntryFuncs, + Notifier: nil, + } + return fsm.New(ds, defaultFsmParams) +} + +func TestArgumentChecks(t *testing.T) { + ds := datastore.NewMapDatastore() + te := &testEnvironment{t: t, done: make(chan struct{}), proceed: make(chan struct{})} + smm, err := newFsm(ds, te) + close(te.proceed) + require.NoError(t, err) + + // should take B with correct arguments + err = smm.Send(uint64(2), "b", uint64(55)) + require.NoError(t, err) + + // should not take b with incorrect argument count + err = smm.Send(uint64(2), "b", uint64(55), "applesuace") + require.Regexp(t, "^Wrong number of arguments for event `b`", err.Error()) + + // should not take b with incorrect argument type + err = smm.Send(uint64(2), "b", "applesuace") + require.Regexp(t, "^Incorrect argument type at index `0`", err.Error()) + +} + +func TestBasic(t *testing.T) { + for i := 0; i < 1000; i++ { // run a few times to expose any races + ds := datastore.NewMapDatastore() + + te := &testEnvironment{t: t, done: make(chan struct{}), proceed: make(chan struct{})} + close(te.proceed) + smm, err := newFsm(ds, te) + require.NoError(t, err) + + err = smm.Send(uint64(2), "start") + require.NoError(t, err) + + <-te.done + + } +} + +func TestPersist(t *testing.T) { + for i := 0; i < 1000; i++ { // run a few times to expose any races + ds := datastore.NewMapDatastore() + + te := &testEnvironment{t: t, done: make(chan struct{}), proceed: make(chan struct{})} + smm, err := newFsm(ds, te) + require.NoError(t, err) + + err = smm.Send(uint64(2), "start") + require.NoError(t, err) + + if err := smm.Stop(context.Background()); err != nil { + t.Fatal(err) + return + } + + smm, err = newFsm(ds, te) + require.NoError(t, err) + err = smm.Send(uint64(2), "restart") + require.NoError(t, err) + + close(te.proceed) + + <-te.done + } +} + +func TestSyncEventHandling(t *testing.T) { + ctx := context.Background() + ds := datastore.NewMapDatastore() + + te := &testEnvironment{t: t, done: make(chan struct{}), proceed: make(chan struct{})} + smm, err := newFsm(ds, te) + close(te.proceed) + require.NoError(t, err) + + // events that should fail based on state, only picked up with SendSync + + err = smm.Send(uint64(2), "b", uint64(55)) + require.NoError(t, err) + + err = smm.SendSync(ctx, uint64(2), "b", uint64(55)) + require.Error(t, err) + require.EqualError(t, err, "Invalid transition in queue, state `0`, event `b`") + + err = smm.Send(uint64(2), "restart") + require.NoError(t, err) + + err = smm.SendSync(ctx, uint64(2), "restart") + require.Error(t, err) + require.EqualError(t, err, "Invalid transition in queue, state `0`, event `restart`") + +} + +func TestNotification(t *testing.T) { + notifications := 0 + + var notifier fsm.Notifier = func(eventName fsm.EventName, state fsm.StateType) { + notifications++ + } + + ds := datastore.NewMapDatastore() + + te := &testEnvironment{t: t, done: make(chan struct{}), proceed: make(chan struct{}), universalCalls: 0} + close(te.proceed) + params := fsm.Parameters{ + Environment: te, + StateType: statemachine.TestState{}, + StateKeyField: "A", + Events: events, + StateEntryFunc: stateEntryFuncs, + Notifier: notifier, + } + smm, err := fsm.New(ds, params) + require.NoError(t, err) + + err = smm.Send(uint64(2), "start") + require.NoError(t, err) + <-te.done + + require.Equal(t, notifications, 2) +} + +func TestNoChangeHandler(t *testing.T) { + ds := datastore.NewMapDatastore() + + te := &testEnvironment{t: t, done: make(chan struct{}), proceed: make(chan struct{}), universalCalls: 0} + close(te.proceed) + smm, err := newFsm(ds, te) + require.NoError(t, err) + + err = smm.Send(uint64(2), "start") + require.NoError(t, err) + <-te.done + + te.done = make(chan struct{}) + // call resume to retrigger step2 + err = smm.Send(uint64(2), "resume") + require.NoError(t, err) + <-te.done +} + +func TestAllStateEvent(t *testing.T) { + ds := datastore.NewMapDatastore() + + te := &testEnvironment{t: t, done: make(chan struct{}), proceed: make(chan struct{}), universalCalls: 0} + close(te.proceed) + smm, err := newFsm(ds, te) + require.NoError(t, err) + + // any can run from any state and function like start + err = smm.Send(uint64(2), "any") + require.NoError(t, err) + <-te.done + + te.done = make(chan struct{}) + // here any can function like a restart handler + err = smm.Send(uint64(2), "any") + require.NoError(t, err) + <-te.done +} diff --git a/fsm/testutil/testcontext.go b/fsm/testutil/testcontext.go new file mode 100644 index 0000000..6a39cf5 --- /dev/null +++ b/fsm/testutil/testcontext.go @@ -0,0 +1,54 @@ +package fsmtestutil + +import ( + "context" + "testing" + + "github.com/filecoin-project/go-statemachine" + "github.com/filecoin-project/go-statemachine/fsm" + "github.com/stretchr/testify/require" +) + +// TestContext is a context you can wired up directly to an event machine so that you can test +// state entry functions and how they affect state +type TestContext struct { + ctx context.Context + eventProcessor fsm.EventProcessor + dispatchedEvents []statemachine.Event +} + +// NewTestContext returns a new test context for the given event machien +func NewTestContext(ctx context.Context, eventProcessor fsm.EventProcessor) *TestContext { + return &TestContext{ctx, eventProcessor, nil} +} + +// Context returns the golang context for this context +func (tc *TestContext) Context() context.Context { return tc.ctx } + +// Event initiates a state transition with the named event. +// +// The call takes a variable number of arguments that will be passed to the +// callback, if defined. +// +// It will return nil if the event is one of these errors: +// +// - event X does not exist +// +// - arguments don't match expected transition +func (tc *TestContext) Event(event fsm.EventName, args ...interface{}) error { + evt, err := tc.eventProcessor.Generate(tc.ctx, event, nil, args...) + if err != nil { + return err + } + tc.dispatchedEvents = append(tc.dispatchedEvents, statemachine.Event{User: evt}) + return nil +} + +// ReplayEvents will use the eventmachine to attempt to perform the dispatched +// transitions on the given state object. it will fail the test if any of them fail +func (tc *TestContext) ReplayEvents(t *testing.T, user interface{}) { + for _, evt := range tc.dispatchedEvents { + _, err := tc.eventProcessor.Apply(evt, user) + require.NoError(t, err) + } +} diff --git a/fsm/types.go b/fsm/types.go new file mode 100644 index 0000000..d76d4d3 --- /dev/null +++ b/fsm/types.go @@ -0,0 +1,151 @@ +package fsm + +import ( + "context" + + "github.com/filecoin-project/go-statestore" +) + +// EventName is the name of an event +type EventName interface{} + +// Context provides access to the statemachine inside of a state handler +type Context interface { + // Context returns the golang context for this context + Context() context.Context + + // Trigger initiates a state transition with the named event. + // + // The call takes a variable number of arguments that will be passed to the + // callback, if defined. + // + // It will return nil if the event is one of these errors: + // + // - event X does not exist + // + // - arguments don't match expected transition + Trigger(event EventName, args ...interface{}) error +} + +// ActionFunc modifies the state further in addition +// to modifying the state key. It the signature +// func action(s stateType, args ...T) +// and then an event can be dispatched on context or group +// with the form .Event(Name, args ...T) +type ActionFunc interface{} + +// StateKeyField is the name of a field in a state struct that serves as the key +// by which the current state is identified +type StateKeyField string + +// StateKey is a value for the field in the state that represents its key +// that uniquely identifies the state +// in practice it must have the same type as the field in the state struct that +// is designated the state key and must be comparable +type StateKey interface{} + +// TransitionMap is a map from src state to destination state +type TransitionMap map[StateKey]StateKey + +// TransitionToBuilder sets the destination of a transition +type TransitionToBuilder interface { + // To means the transition ends in the given state + To(StateKey) EventBuilder + // ToNoChange means a transition ends in the same state it started in (just retriggers state cb) + ToNoChange() EventBuilder +} + +// EventBuilder is an interface for describing events in an fsm and +// their associated transitions +type EventBuilder interface { + // From begins describing a transition from a specific state + From(s StateKey) TransitionToBuilder + // FromAny begins describing a transition from any state + FromAny() TransitionToBuilder + // FromMany begins describing a transition from many states + FromMany(sources ...StateKey) TransitionToBuilder + // Action describes actions taken on the state for this event + Action(action ActionFunc) EventBuilder +} + +// Events is a list of the different events that can happen in a state machine, +// described by EventBuilders +type Events []EventBuilder + +// StateType is a type for a state, represented by an empty concrete value for a state +type StateType interface{} + +// Environment are externals dependencies will be needed by this particular state machine +type Environment interface{} + +// StateEntryFunc is called upon entering a state after +// all events are processed. It should have the signature +// func stateEntryFunc(ctx Context, environment Environment, state StateType) error +type StateEntryFunc interface{} + +// StateEntryFuncs is a map between states and their handlers +type StateEntryFuncs map[StateKey]StateEntryFunc + +// Notifier should be a function that takes two parameters, +// a native event type and a statetype +// -- nil means no notification +// it is called after every successful state transition +// with the even that triggered it +type Notifier func(eventName EventName, state StateType) + +// Group is a manager of a group of states that follows finite state machine logic +type Group interface { + + // Begin initiates tracking with a specific value for a given identifier + Begin(id interface{}, userState interface{}) error + + // Send sends the given event name and parameters to the state specified by id + // it will error if there are underlying state store errors or if the parameters + // do not match what is expected for the event name + Send(id interface{}, name EventName, args ...interface{}) (err error) + + // SendSync will block until the given event is actually processed, and + // will return an error if the transition was not possible given the current + // state + SendSync(ctx context.Context, id interface{}, name EventName, args ...interface{}) (err error) + + // Get gets state for a single state machine + Get(id interface{}) *statestore.StoredState + + // List outputs states of all state machines in this group + // out: *[]StateT + List(out interface{}) error + + // Stop stops all state machines in this group + Stop(ctx context.Context) error +} + +// Parameters are the parameters that define a finite state machine +type Parameters struct { + // required + + // Environment is the environment in which the state handlers operate -- + // used to connect to outside dependencies + Environment Environment + + // StateType is the type of state being tracked. Should be a zero value of the state struct, in + // non-pointer form + StateType StateType + + // StateKeyField is the field in the state struct that will be used to uniquely identify the current state + StateKeyField StateKeyField + + // Events is the list of events that that can be dispatched to the state machine to initiate transitions. + // See EventDesc for event properties + Events []EventBuilder + + // StateEntryFuncs - functions that will get called each time the machine enters a particular + // state. this is a map of state key -> handler. + StateEntryFunc StateEntryFuncs + + // optional + + // Notifier is a function that gets called on every successful event processing + // with the event name and the new state + Notifier Notifier +} diff --git a/go.mod b/go.mod index 0fb1a9a..61d9eae 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/filecoin-project/go-statestore v0.1.0 github.com/ipfs/go-datastore v0.1.1 github.com/ipfs/go-log v1.0.1 + github.com/stretchr/testify v1.4.0 github.com/whyrusleeping/cbor-gen v0.0.0-20200123233031-1cdf64d27158 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 gotest.tools v2.2.0+incompatible diff --git a/go.sum b/go.sum index ef999a4..9687348 100644 --- a/go.sum +++ b/go.sum @@ -124,7 +124,9 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190219092855-153ac476189d/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/testing.go b/testing.go index 8a2c7e8..b3ba63a 100644 --- a/testing.go +++ b/testing.go @@ -3,6 +3,7 @@ package statemachine type TestState struct { A uint64 B uint64 + C []uint64 } type TestEvent struct {