Skip to content

Commit 94b31fa

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 94b31fa

File tree

12 files changed

+1098
-1
lines changed

12 files changed

+1098
-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: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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 = NewStateMachine(exampleFSM.GetStates())
35+
36+
return exampleFSM
37+
}
38+
39+
// States.
40+
const (
41+
InitFSM = StateType("InitFSM")
42+
StuffSentOut = StateType("StuffSentOut")
43+
WaitingForStuff = StateType("WaitingForStuff")
44+
StuffFailed = StateType("StuffFailed")
45+
StuffSuccess = StateType("StuffSuccess")
46+
)
47+
48+
// Events.
49+
var (
50+
OnRequestStuff = EventType("OnRequestStuff")
51+
OnStuffSentOut = EventType("OnStuffSentOut")
52+
OnStuffSuccess = EventType("OnStuffSuccess")
53+
)
54+
55+
// GetStates returns the states for the example FSM.
56+
func (e *ExampleFSM) GetStates() States {
57+
return States{
58+
Default: State{
59+
Transitions: Transitions{
60+
OnRequestStuff: InitFSM,
61+
},
62+
},
63+
InitFSM: State{
64+
Action: e.initFSM,
65+
Transitions: Transitions{
66+
OnStuffSentOut: StuffSentOut,
67+
OnError: StuffFailed,
68+
},
69+
},
70+
StuffSentOut: State{
71+
Action: e.waitForStuff,
72+
Transitions: Transitions{
73+
OnStuffSuccess: StuffSuccess,
74+
OnError: StuffFailed,
75+
},
76+
},
77+
78+
StuffFailed: State{
79+
Action: NoOpAction,
80+
},
81+
82+
StuffSuccess: State{
83+
Action: NoOpAction,
84+
},
85+
}
86+
}
87+
88+
// InitStuffRequest is the event context for the InitFSM state.
89+
type InitStuffRequest struct {
90+
Stuff string
91+
respondChan chan<- string
92+
}
93+
94+
// initFSM is the action for the InitFSM state.
95+
func (e *ExampleFSM) initFSM(eventCtx EventContext) EventType {
96+
req, ok := eventCtx.(*InitStuffRequest)
97+
if !ok {
98+
return e.HandleError(
99+
fmt.Errorf("invalid event context type: %T", eventCtx),
100+
)
101+
}
102+
103+
err := e.store.StoreStuff()
104+
if err != nil {
105+
return e.HandleError(err)
106+
}
107+
108+
req.respondChan <- req.Stuff
109+
110+
return OnStuffSentOut
111+
}
112+
113+
// waitForStuff is an action that waits for stuff to happen.
114+
func (e *ExampleFSM) waitForStuff(eventCtx EventContext) EventType {
115+
waitChan, err := e.service.WaitForStuffHappening()
116+
if err != nil {
117+
return e.HandleError(err)
118+
}
119+
120+
go func() {
121+
<-waitChan
122+
err := e.SendEvent(OnStuffSuccess, nil)
123+
if err != nil {
124+
log.Errorf("unable to send event: %v", err)
125+
}
126+
}()
127+
128+
return NoOp
129+
}

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: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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+
tc := tc
76+
77+
t.Run(tc.name, func(t *testing.T) {
78+
respondChan := make(chan string, 1)
79+
if req, ok := tc.eventCtx.(*InitStuffRequest); ok {
80+
req.respondChan = respondChan
81+
}
82+
83+
serviceRespondChan := make(chan bool, 1)
84+
serviceRespondChan <- true
85+
86+
service := &mockService{
87+
respondChan: serviceRespondChan,
88+
respondErr: tc.serviceErr,
89+
}
90+
91+
store := &mockStore{
92+
storeErr: tc.storeErr,
93+
}
94+
95+
exampleContext := NewExampleFSMContext(service, store)
96+
cachedObserver := NewCachedObserver()
97+
98+
exampleContext.RegisterObserver(cachedObserver)
99+
100+
err := exampleContext.SendEvent(tc.sendEvent, tc.eventCtx)
101+
require.Equal(t, tc.sendEventErr, err)
102+
103+
require.Equal(t, tc.expectedLastActionError,
104+
exampleContext.LastActionError)
105+
106+
err = cachedObserver.WaitForState(context.Background(),
107+
time.Second, tc.expectedState)
108+
require.NoError(t, err)
109+
})
110+
}
111+
}
112+
113+
// getTestContext returns a test context for the example FSM and a cached
114+
// observer that can be used to verify the state transitions.
115+
func getTestContext() (*ExampleFSM, *CachedObserver) {
116+
service := &mockService{
117+
respondChan: make(chan bool, 1),
118+
}
119+
service.respondChan <- true
120+
121+
store := &mockStore{}
122+
123+
exampleContext := NewExampleFSMContext(service, store)
124+
cachedObserver := NewCachedObserver()
125+
126+
exampleContext.RegisterObserver(cachedObserver)
127+
128+
return exampleContext, cachedObserver
129+
}
130+
131+
// TestExampleFSMFlow tests different flows that the example FSM can go through.
132+
func TestExampleFSMFlow(t *testing.T) {
133+
testCases := []struct {
134+
name string
135+
expectedStateFlow []StateType
136+
expectedEventFlow []EventType
137+
storeError error
138+
serviceError error
139+
}{
140+
{
141+
name: "success",
142+
expectedStateFlow: []StateType{
143+
InitFSM,
144+
StuffSentOut,
145+
StuffSuccess,
146+
},
147+
expectedEventFlow: []EventType{
148+
OnRequestStuff,
149+
OnStuffSentOut,
150+
OnStuffSuccess,
151+
},
152+
},
153+
{
154+
name: "failure on store",
155+
expectedStateFlow: []StateType{
156+
InitFSM,
157+
StuffFailed,
158+
},
159+
expectedEventFlow: []EventType{
160+
OnRequestStuff,
161+
OnError,
162+
},
163+
storeError: errors.New("store error"),
164+
},
165+
{
166+
name: "failure on service",
167+
expectedStateFlow: []StateType{
168+
InitFSM,
169+
StuffSentOut,
170+
StuffFailed,
171+
},
172+
expectedEventFlow: []EventType{
173+
OnRequestStuff,
174+
OnStuffSentOut,
175+
OnError,
176+
},
177+
serviceError: errors.New("service error"),
178+
},
179+
}
180+
181+
for _, tc := range testCases {
182+
tc := tc
183+
184+
t.Run(tc.name, func(t *testing.T) {
185+
exampleContext, cachedObserver := getTestContext()
186+
187+
if tc.storeError != nil {
188+
exampleContext.store.(*mockStore).storeErr = tc.storeError
189+
}
190+
191+
if tc.serviceError != nil {
192+
exampleContext.service.(*mockService).
193+
respondErr = tc.serviceError
194+
}
195+
196+
go func() {
197+
err := exampleContext.SendEvent(OnRequestStuff,
198+
newInitStuffRequest())
199+
200+
require.NoError(t, err)
201+
}()
202+
203+
// Wait for the final state.
204+
err := cachedObserver.WaitForState(context.Background(),
205+
time.Second, tc.expectedStateFlow[len(tc.expectedStateFlow)-1])
206+
require.NoError(t, err)
207+
208+
allNotifications := cachedObserver.GetCachedNotifications()
209+
210+
for index, notification := range allNotifications {
211+
require.Equal(t, tc.expectedStateFlow[index],
212+
notification.NextState)
213+
require.Equal(t, tc.expectedEventFlow[index],
214+
notification.Event)
215+
}
216+
})
217+
}
218+
}

0 commit comments

Comments
 (0)