Repository navigation
Expand file tree
/
Copy pathexample_test.go
More file actions
291 lines (249 loc) · 9.17 KB
/
Copy pathexample_test.go
File metadata and controls
291 lines (249 loc) · 9.17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
package fsm_test
import (
"context"
"errors"
"fmt"
"time"
"github.com/floatdrop/fsm"
)
// recState is int32-shaped so it can be stored directly in a protobuf enum
// field and survive a snapshot/restore round trip.
type recState int32
const (
recActive recState = iota
recStopped
recFinished
recUploaded
)
func (s recState) String() string {
return [...]string{"active", "stopped", "finished", "uploaded"}[s]
}
type recording struct {
state recState
stoppedAt time.Time
inProgressChunks int
inProgressTracks int
}
// Events are prefixed ev so they never read as states. Without it the pair
// "stop" and "stopped" is one tense apart, which is not a difference worth
// relying on.
//
// The payload is the aggregate itself, so guards and actions read the same
// data the caller has, with no package-level state and no type assertions.
var (
evRecStop = fsm.Define[*recording]("stop")
evRecFinish = fsm.Define[*recording]("finish")
evRecUpload = fsm.Signal("uploaded")
)
// A guard rejects with a sentinel, so a caller can tell "not finishable yet,
// retry later" apart from "not finishable at all" without parsing strings.
var errUploadPending = errors.New("still uploading")
func markStopped(_ context.Context, r *recording) error {
r.stoppedAt = time.Unix(1700000000, 0).UTC()
return nil
}
func uploadsSettled(_ context.Context, r *recording) error {
if r.inProgressChunks != 0 || r.inProgressTracks != 0 {
return fmt.Errorf("%w: %d chunks, %d tracks",
errUploadPending, r.inProgressChunks, r.inProgressTracks)
}
return nil
}
// Gauges tracking how many recordings sit in each state. In hand-written form
// these are a += and a -= at every site that changes the state, and they drift
// as soon as one site is missed. As entry and exit hooks they cannot.
var gauge = map[recState]int{}
func counted(s recState) fsm.Rule[recState] {
return fsm.Rules(
fsm.OnEnter(s, func(context.Context, fsm.Transition[recState]) { gauge[s]++ }),
fsm.OnExit(s, func(context.Context, fsm.Transition[recState]) { gauge[s]-- }),
)
}
// A machine is a set of rules. Each transition names its source and target in
// separate calls, so the two states cannot be swapped the way two adjacent
// arguments can.
var recordingFSM = fsm.MustNew("recording",
fsm.Initial(recActive),
counted(recActive),
counted(recStopped),
counted(recFinished),
counted(recUploaded),
fsm.From(recActive).On(evRecStop).To(recStopped).Action(markStopped),
fsm.From(recStopped).On(evRecFinish).To(recFinished).
Guard("all chunks and tracks uploaded", uploadsSettled),
fsm.From(recFinished).On(evRecUpload).To(recUploaded),
)
// A recording moves active -> stopped -> finished -> uploaded. The order is
// declared once; every caller goes through Fire, so no call site can skip a
// step or forget a gauge.
func Example_recording() {
ctx := context.Background()
r := &recording{inProgressChunks: 2}
gauge[recActive]++ // the initial state is entered by construction
fmt.Println("state:", r.state)
if _, err := recordingFSM.Fire(ctx, &r.state, evRecStop, r); err != nil {
fmt.Println("unexpected:", err)
}
fmt.Println("after stop:", r.state, "at", r.stoppedAt.Format(time.RFC3339))
// Finishing early is refused by the guard, and the state does not move.
_, err := recordingFSM.Fire(ctx, &r.state, evRecFinish, r)
if ge, ok := errors.AsType[*fsm.GuardError[recState]](err); ok {
fmt.Printf("finish refused by %q: %v\n", ge.Guard, ge.Err)
fmt.Println("is upload pending?", errors.Is(err, errUploadPending), "| state still", r.state)
}
r.inProgressChunks = 0
if _, err := recordingFSM.Fire(ctx, &r.state, evRecFinish, r); err != nil {
fmt.Println("unexpected:", err)
}
if _, err := recordingFSM.Send(ctx, &r.state, evRecUpload); err != nil {
fmt.Println("unexpected:", err)
}
fmt.Println("final:", r.state)
fmt.Printf("gauges: active=%d stopped=%d finished=%d uploaded=%d\n",
gauge[recActive], gauge[recStopped], gauge[recFinished], gauge[recUploaded])
// Output:
// state: active
// after stop: stopped at 2023-11-14T22:13:20Z
// finish refused by "all chunks and tracks uploaded": still uploading: 2 chunks, 0 tracks
// is upload pending? true | state still stopped
// final: uploaded
// gauges: active=0 stopped=0 finished=0 uploaded=1
}
type pcpState int32
const (
pcpConnected pcpState = iota
pcpReconnecting
pcpDeleted
)
func (s pcpState) String() string {
return [...]string{"connected", "reconnecting", "deleted"}[s]
}
// A disconnect carries why it happened, so the reason cannot be lost between
// the caller and the action that logs it.
type disconnect struct {
intentional bool
reason string
}
var (
evPcpDrop = fsm.Define[disconnect]("disconnect")
evPcpReconnect = fsm.Signal("reconnect")
evPcpKick = fsm.Define[disconnect]("kick")
)
func unintentional(_ context.Context, d disconnect) error {
if d.intentional {
return fmt.Errorf("intentional disconnect (%s) must be a kick, not a drop", d.reason)
}
return nil
}
// A participant is kicked the same way whether it is connected or
// reconnecting. Naming the two as a group says that once: a third live state
// inherits the kick instead of needing the rule copied, and the group is still
// the place to look for what "live" means.
var pcpLive = fsm.NewGroup("live", pcpConnected, pcpReconnecting)
// Modelling a participant makes deletion an explicit terminal state rather
// than "absent from the map", so the last transition is expressible and can
// carry a reason.
var participantFSM = fsm.MustNew("participant",
fsm.Initial(pcpConnected),
fsm.From(pcpConnected).On(evPcpDrop).To(pcpReconnecting).
Guard("disconnect was not intentional", unintentional),
fsm.From(pcpReconnecting).On(evPcpReconnect).To(pcpConnected),
fsm.FromGroup(pcpLive).On(evPcpKick).To(pcpDeleted),
)
func Example_participant() {
ctx := context.Background()
st := pcpConnected
_, _ = participantFSM.Fire(ctx, &st, evPcpDrop, disconnect{reason: "ice failed"})
fmt.Println("after drop:", st)
_, _ = participantFSM.Send(ctx, &st, evPcpReconnect)
fmt.Println("after reconnect:", st)
_, _ = participantFSM.Fire(ctx, &st, evPcpKick, disconnect{intentional: true, reason: "left the call"})
fmt.Println("after kick:", st)
// Nothing leaves a terminal state, so a kicked participant cannot be
// resurrected by a late event arriving out of order.
_, err := participantFSM.Send(ctx, &st, evPcpReconnect)
fmt.Println("late reconnect:", err)
fmt.Println("terminals:", participantFSM.Terminals())
// A group is a build-time grouping, so the kick is two ordinary rows in
// the table — but each remembers where it came from.
for _, e := range participantFSM.Edges() {
if e.Group != "" {
fmt.Printf("%v --%s--> %v inherited from %q\n", e.From, e.Event(), e.To, e.Group)
}
}
fmt.Println("is reconnecting live?", pcpLive.Has(pcpReconnecting))
// Output:
// after drop: reconnecting
// after reconnect: connected
// after kick: deleted
// late reconnect: fsm participant: no transition from deleted on reconnect
// terminals: [deleted]
// connected --kick--> deleted inherited from "live"
// reconnecting --kick--> deleted inherited from "live"
// is reconnecting live? true
}
// A group is drawn as a cluster, and a transition every member inherited is
// drawn once from the cluster boundary rather than once per member — which is
// the whole visual point of grouping the states in the first place.
func ExampleMachine_DOT_group() {
fmt.Print(participantFSM.DOT())
// Output:
// digraph "participant" {
// rankdir=LR;
// compound=true;
// "__start" [shape=point];
// subgraph "cluster_live" {
// label="live";
// style=rounded;
// "connected" [shape=box];
// "reconnecting" [shape=box];
// }
// "deleted" [shape=doublecircle];
// "__start" -> "connected";
// "connected" -> "reconnecting" [label="disconnect\n[disconnect was not intentional]"];
// "reconnecting" -> "connected" [label="reconnect"];
// "connected" -> "deleted" [label="kick", ltail="cluster_live"];
// }
}
// DOT output is stable across runs, so it can be committed next to the code
// and reviewed as part of a diff when the machine changes.
func ExampleMachine_DOT() {
fmt.Print(recordingFSM.DOT())
// Output:
// digraph "recording" {
// rankdir=LR;
// "__start" [shape=point];
// "active" [shape=box];
// "stopped" [shape=box];
// "finished" [shape=box];
// "uploaded" [shape=doublecircle];
// "__start" -> "active";
// "active" -> "stopped" [label="stop"];
// "stopped" -> "finished" [label="finish\n[all chunks and tracks uploaded]"];
// "finished" -> "uploaded" [label="uploaded"];
// }
}
// Mermaid draws the same diagram for a mermaid block in Markdown, which
// GitHub renders: the group is a composite state, the kick leaves it once,
// and [*] marks where the machine starts and where it can end.
func ExampleMachine_Mermaid() {
fmt.Print(participantFSM.Mermaid())
// Output:
// ---
// title: "participant"
// ---
// stateDiagram-v2
// direction LR
// state "live" as g0 {
// s0
// s1
// }
// s0 : connected
// s1 : reconnecting
// s2 : deleted
// [*] --> s0
// s0 --> s1 : disconnect<br>[disconnect was not intentional]
// s1 --> s0 : reconnect
// g0 --> s2 : kick
// s2 --> [*]
}