Skip to content

Commit d9cf3e3

Browse files
committed
fsm: add fsm module
This commit adds a module for a finite state machine. The goal of the module is to provide a simple, easy to use, and easy to understand finite state machine. The module is designed to be used in future loop subsystems. Additionally a state visualizer is provided to help with understanding the state machine.
1 parent 1f48b2c commit d9cf3e3

File tree

11 files changed

+960
-1
lines changed

11 files changed

+960
-1
lines changed

.golangci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,5 +133,10 @@ issues:
133133

134134
# Allow fmt.Printf() in loop
135135
- path: cmd/loop/*
136+
linters:
137+
- forbidigo
138+
139+
# Allow fmt.Printf() in stateparser
140+
- path: fsm/stateparser/*
136141
linters:
137142
- forbidigo

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,7 @@ sqlc-check: sqlc
134134
@$(call print, "Verifying sql code generation.")
135135
if test -n "$$(git status --porcelain '*.go')"; then echo "SQL models not properly generated!"; git status --porcelain '*.go'; exit 1; fi
136136

137-
137+
fsm:
138+
@$(call print, "Generating state machine docs")
139+
./scripts/fsm-generate.sh;
140+
.PHONY: fsm

fsm/example_fsm.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package fsm
2+
3+
import (
4+
"fmt"
5+
)
6+
7+
// ExampleService is an example service that we want to wait for in the FSM.
8+
type ExampleService interface {
9+
WaitForStuffHappening() (<-chan bool, error)
10+
}
11+
12+
// ExampleStore is an example store that we want to use in our exitFunc.
13+
type ExampleStore interface {
14+
StoreStuff() error
15+
}
16+
17+
// ExampleFSM implements the FSM and uses the ExampleService and ExampleStore
18+
// to implement the actions.
19+
type ExampleFSM struct {
20+
*StateMachine
21+
22+
service ExampleService
23+
store ExampleStore
24+
}
25+
26+
// NewExampleFSMContext creates a new example FSM context.
27+
func NewExampleFSMContext(service ExampleService,
28+
store ExampleStore) *ExampleFSM {
29+
30+
exampleFSM := &ExampleFSM{
31+
service: service,
32+
store: store,
33+
}
34+
exampleFSM.StateMachine = &StateMachine{
35+
States: exampleFSM.GetStates(),
36+
}
37+
return exampleFSM
38+
}
39+
40+
// States.
41+
const (
42+
InitFSM = StateType("InitFSM")
43+
StuffSentOut = StateType("StuffSentOut")
44+
WaitingForStuff = StateType("WaitingForStuff")
45+
StuffFailed = StateType("StuffFailed")
46+
StuffSuccess = StateType("StuffSuccess")
47+
)
48+
49+
// Events.
50+
var (
51+
OnRequestStuff = EventType("OnRequestStuff")
52+
OnStuffSentOut = EventType("OnStuffSentOut")
53+
OnStuffSuccess = EventType("OnStuffSuccess")
54+
)
55+
56+
// GetStates returns the states for the example FSM.
57+
func (e *ExampleFSM) GetStates() States {
58+
return States{
59+
Default: State{
60+
Transitions: Transitions{
61+
OnRequestStuff: InitFSM,
62+
},
63+
},
64+
InitFSM: State{
65+
Action: e.initFSM,
66+
Transitions: Transitions{
67+
OnStuffSentOut: StuffSentOut,
68+
OnError: StuffFailed,
69+
},
70+
},
71+
StuffSentOut: State{
72+
Action: e.waitForStuff,
73+
Transitions: Transitions{
74+
OnStuffSuccess: StuffSuccess,
75+
OnError: StuffFailed,
76+
},
77+
},
78+
79+
StuffFailed: State{
80+
Action: NoOpAction,
81+
},
82+
83+
StuffSuccess: State{
84+
Action: NoOpAction,
85+
},
86+
}
87+
}
88+
89+
type InitStuffRequest struct {
90+
Stuff string
91+
respondChan chan<- string
92+
}
93+
94+
func (e *ExampleFSM) initFSM(eventCtx EventContext) EventType {
95+
req, ok := eventCtx.(*InitStuffRequest)
96+
if !ok {
97+
return e.HandleError(
98+
fmt.Errorf("invalid event context type: %T", eventCtx),
99+
)
100+
}
101+
102+
err := e.store.StoreStuff()
103+
if err != nil {
104+
return e.HandleError(err)
105+
}
106+
107+
req.respondChan <- req.Stuff
108+
109+
return OnStuffSentOut
110+
}
111+
112+
func (e *ExampleFSM) waitForStuff(eventCtx EventContext) EventType {
113+
waitChan, err := e.service.WaitForStuffHappening()
114+
if err != nil {
115+
return e.HandleError(err)
116+
}
117+
118+
go func() {
119+
<-waitChan
120+
err := e.SendEvent(OnStuffSuccess, nil)
121+
if err != nil {
122+
log.Errorf("unable to send event: %v", err)
123+
}
124+
}()
125+
126+
return NoOp
127+
}

fsm/example_fsm.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
```mermaid
2+
stateDiagram-v2
3+
[*] --> InitFSM: OnRequestStuff
4+
InitFSM
5+
InitFSM --> StuffFailed: OnError
6+
InitFSM --> StuffSentOut: OnStuffSentOut
7+
StuffFailed
8+
StuffSentOut
9+
StuffSentOut --> StuffFailed: OnError
10+
StuffSentOut --> StuffSuccess: OnStuffSuccess
11+
StuffSuccess
12+
```

fsm/example_fsm_test.go

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package fsm
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
type mockStore struct {
14+
storeErr error
15+
}
16+
17+
func (m *mockStore) StoreStuff() error {
18+
return m.storeErr
19+
}
20+
21+
type mockService struct {
22+
respondChan chan bool
23+
respondErr error
24+
}
25+
26+
func (m *mockService) WaitForStuffHappening() (<-chan bool, error) {
27+
return m.respondChan, m.respondErr
28+
}
29+
30+
func newInitStuffRequest() *InitStuffRequest {
31+
return &InitStuffRequest{
32+
Stuff: "stuff",
33+
respondChan: make(chan<- string, 1),
34+
}
35+
}
36+
37+
func TestExampleFSM(t *testing.T) {
38+
testCases := []struct {
39+
name string
40+
expectedState StateType
41+
eventCtx EventContext
42+
expectedLastActionError error
43+
44+
sendEvent EventType
45+
sendEventErr error
46+
47+
serviceErr error
48+
storeErr error
49+
}{
50+
{
51+
name: "success",
52+
expectedState: StuffSuccess,
53+
eventCtx: newInitStuffRequest(),
54+
sendEvent: OnRequestStuff,
55+
},
56+
{
57+
name: "service error",
58+
expectedState: StuffFailed,
59+
eventCtx: newInitStuffRequest(),
60+
sendEvent: OnRequestStuff,
61+
serviceErr: fmt.Errorf("service error"),
62+
expectedLastActionError: errors.New("service error"),
63+
},
64+
{
65+
name: "store error",
66+
expectedLastActionError: errors.New("store error"),
67+
storeErr: errors.New("store error"),
68+
sendEvent: OnRequestStuff,
69+
expectedState: StuffFailed,
70+
eventCtx: newInitStuffRequest(),
71+
},
72+
}
73+
74+
for _, tc := range testCases {
75+
t.Run(tc.name, func(t *testing.T) {
76+
respondChan := make(chan string, 1)
77+
if req, ok := tc.eventCtx.(*InitStuffRequest); ok {
78+
req.respondChan = respondChan
79+
}
80+
81+
serviceRespondChan := make(chan bool, 1)
82+
serviceRespondChan <- true
83+
84+
service := &mockService{
85+
respondChan: serviceRespondChan,
86+
respondErr: tc.serviceErr,
87+
}
88+
89+
store := &mockStore{
90+
storeErr: tc.storeErr,
91+
}
92+
93+
exampleContext := NewExampleFSMContext(service, store)
94+
95+
err := exampleContext.SendEvent(tc.sendEvent, tc.eventCtx)
96+
require.Equal(t, tc.sendEventErr, err)
97+
require.Equal(t, tc.expectedLastActionError, exampleContext.LastActionError)
98+
err = exampleContext.WaitForState(context.Background(), time.Second, tc.expectedState)
99+
require.NoError(t, err)
100+
})
101+
}
102+
}
103+
104+
func getTestContext() *ExampleFSM {
105+
service := &mockService{
106+
respondChan: make(chan bool, 1),
107+
}
108+
service.respondChan <- true
109+
110+
store := &mockStore{}
111+
112+
exampleContext := NewExampleFSMContext(service, store)
113+
114+
return exampleContext
115+
}
116+
func TestExampleFSMFlow(t *testing.T) {
117+
testCases := []struct {
118+
name string
119+
expectedStateFlow []StateType
120+
expectedEventFlow []EventType
121+
storeError error
122+
serviceError error
123+
}{
124+
{
125+
name: "success",
126+
expectedStateFlow: []StateType{
127+
InitFSM,
128+
StuffSentOut,
129+
StuffSuccess,
130+
},
131+
expectedEventFlow: []EventType{
132+
OnRequestStuff,
133+
OnStuffSentOut,
134+
OnStuffSuccess,
135+
},
136+
},
137+
{
138+
name: "failure on store",
139+
expectedStateFlow: []StateType{
140+
InitFSM,
141+
StuffFailed,
142+
},
143+
expectedEventFlow: []EventType{
144+
OnRequestStuff,
145+
OnError,
146+
},
147+
storeError: errors.New("store error"),
148+
},
149+
{
150+
name: "failure on service",
151+
expectedStateFlow: []StateType{
152+
InitFSM,
153+
StuffSentOut,
154+
StuffFailed,
155+
},
156+
expectedEventFlow: []EventType{
157+
OnRequestStuff,
158+
OnStuffSentOut,
159+
OnError,
160+
},
161+
serviceError: errors.New("service error"),
162+
},
163+
}
164+
165+
for _, tc := range testCases {
166+
t.Run(tc.name, func(t *testing.T) {
167+
exampleContext := getTestContext()
168+
exampleContext.NotificationChan = make(chan Notification)
169+
170+
if tc.storeError != nil {
171+
exampleContext.store.(*mockStore).storeErr = tc.storeError
172+
}
173+
174+
if tc.serviceError != nil {
175+
exampleContext.service.(*mockService).respondErr = tc.serviceError
176+
}
177+
178+
go func() {
179+
err := exampleContext.SendEvent(OnRequestStuff, newInitStuffRequest())
180+
require.NoError(t, err)
181+
}()
182+
183+
for index, expectedState := range tc.expectedStateFlow {
184+
notification := <-exampleContext.NotificationChan
185+
if index == 0 {
186+
require.Equal(t, Default, notification.PreviousState)
187+
} else {
188+
require.Equal(
189+
t, tc.expectedStateFlow[index-1], notification.PreviousState,
190+
)
191+
}
192+
require.Equal(t, expectedState, notification.NextState)
193+
require.Equal(t, tc.expectedEventFlow[index], notification.Event)
194+
}
195+
})
196+
}
197+
}

0 commit comments

Comments
 (0)