-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlifecyclemodel_test.go
More file actions
313 lines (289 loc) · 10.4 KB
/
Copy pathlifecyclemodel_test.go
File metadata and controls
313 lines (289 loc) · 10.4 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
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
package di_test
// A model of the instance lifecycle, checked against the sequential machine.
//
// machine_test.go predicts nothing, because a model of the whole container
// could be wrong in the same way as the code, and property_test.go models
// registration alone. What happens to an instance once it exists is
// different: given that a constructor ran, in a scope, for a binding with a
// known set of hooks, the rest is a small state machine the package documents
// completely, and counting hooks against each other cannot see one that
// should have run and did not. So the model takes builds as given, from the
// constructors themselves, and predicts everything downstream:
//
// M1 No hook of an instance runs twice.
// M2 For one instance the order is OnStart, then OnDrain, then OnStop.
// M3 An instance owes a stop step when its start step succeeded, or when
// it was built and has no OnStart to pair with -- either the binding
// declares none, or the scope was never started. Every instance that
// owes one gets one; one that does not, does not.
// M4 An instance that owes a drain gets one, under the same predicate.
// The concurrent driver cannot check this: an instance built during
// the phase may legitimately miss it. Sequentially the phase has a
// boundary, and drain hooks here build nothing.
// M5 Instances stop innermost scope first, and in reverse build order
// within a scope.
// M6 An instance built into a running scope runs its start step as part
// of being built.
//
// Whether the start step ran is observed rather than predicted, because a
// rollback stops what had started at the moment it failed and predicting that
// would mean predicting the failure. What the observation feeds is still a
// prediction: M3 and M4 are the package's own rule for what is owed.
import (
"context"
"fmt"
"slices"
"strings"
)
// The machine starts each scope with a context naming that scope, so which
// Start governs a scope can be read back off Scope.Context. The model
// observes this rather than predicting it: whether a rejected Start had
// already recorded its context depends on which panic came first, which the
// package does not promise, and the answer changes while a Start is running,
// which is the window an eager build happens in.
type machineStartKey struct{}
func machineStartCtx(scope int) context.Context {
return context.WithValue(context.Background(), machineStartKey{}, scope)
}
// governedBy names the scope whose Start this one answers to: the nearest
// scope, itself included, that Start has been called on.
func (l *lifecycle) governedBy(scope int) (int, bool) {
v := l.m.scopes[scope].Context().Value(machineStartKey{})
if v == nil {
return 0, false
}
return v.(int), true
}
// refusedAsSecondStart reports the one Start failure that changes nothing:
// the scope had already been started.
func refusedAsSecondStart(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "Start called twice")
}
type hookSet struct{ start, drain, stop bool }
// hooksOfShape says which hooks each registration shape of the sequential
// machine declares. It is the one place the model has to agree with regShape.
func hooksOfShape(reg uint8) hookSet {
switch reg {
case 0, 3:
return hookSet{start: true, stop: true}
case 1, 2, 4, 9, 10:
return hookSet{stop: true}
case 7:
return hookSet{drain: true, stop: true}
case 8:
return hookSet{start: true, drain: true, stop: true}
}
return hookSet{}
}
type scopePhase int
const (
scopeNew scopePhase = iota
scopeStarted
scopeStopped
)
// modelInstance is what the model believes about one built value.
type modelInstance struct {
value any
scope int // the scope holding it, which is the one that stops it
hooks hookSet
built int // global order, so M5 can read reverse build order off it
ran map[string]int
at map[string]int // hook -> when it ran
live bool // the model expects this instance to be startable
expect map[string]bool
}
type lifecycle struct {
m *machine
seq int
instances []*modelInstance
byValue map[any]*modelInstance
phase [numScopes]scopePhase
// startUnknown marks the scopes whose start outcome the model could not
// determine; see ranAndStopped.
startUnknown [numScopes]bool
fails []string
}
func newLifecycle(m *machine) *lifecycle {
return &lifecycle{m: m, byValue: map[any]*modelInstance{}}
}
func (l *lifecycle) failf(format string, args ...any) {
l.fails = append(l.fails, fmt.Sprintf(format, args...))
}
// under reports whether scope a is at or below b.
func under(a, b int) bool {
for s := a; s >= 0; s = parentOf[s] {
if s == b {
return true
}
}
return false
}
// everStarted reports whether Start was called on this scope or an ancestor,
// which is what makes an OnStop a paired teardown rather than a plain
// destructor.
func (l *lifecycle) everStarted(scope int) bool {
_, ok := l.governedBy(scope)
return ok
}
// running reports whether the Start that governs this scope has passed its
// hook phase, which is when an instance built later starts as part of being
// built. The governing scope becomes the scope itself the moment its own
// Start records its context, before it builds its eager bindings, so an
// eager build during that window does not start; it starts in the phase that
// follows, if the call gets there. Walking to the nearest finished Start
// instead answered for a running ancestor and predicted a start step for an
// instance whose own scope's Start went on to fail.
func (l *lifecycle) running(scope int) bool {
s, ok := l.governedBy(scope)
return ok && l.phase[s] == scopeStarted
}
// built is called by every constructor the machine registers, with the scope
// the constructor ran in, which is the scope that holds the instance.
func (l *lifecycle) built(scope int, reg uint8, v any) {
if _, seen := l.byValue[v]; seen {
l.failf("the same value was built twice")
return
}
l.seq++
in := &modelInstance{
value: v, scope: scope, hooks: hooksOfShape(reg), built: l.seq,
ran: map[string]int{}, at: map[string]int{}, expect: map[string]bool{},
live: l.phase[scope] != scopeStopped,
}
// M6. A constructor that runs while its own scope is stopping is undone
// instead, which the machine cannot reach sequentially.
if in.live && in.hooks.start && l.running(scope) {
in.expect["OnStart"] = true
}
l.instances = append(l.instances, in)
l.byValue[v] = in
}
// hookRan is called by every hook the machine registers.
func (l *lifecycle) hookRan(v any, hook string) {
in := l.byValue[v]
if in == nil {
l.failf("%s ran for a value no constructor reported", hook)
return
}
l.seq++
in.ran[hook]++
if in.ran[hook] > 1 { // M1
l.failf("%s ran %d times for one instance in %s", hook, in.ran[hook], l.m.names[in.scope])
}
if _, seen := in.at[hook]; !seen {
in.at[hook] = l.seq
}
}
// started records the outcome of a Start. A failed one rolls back, which the
// model hears about as a stop; a refused second Start changes nothing.
func (l *lifecycle) started(scope int, err error) {
if err != nil {
if !refusedAsSecondStart(err) {
l.stopping(scope)
}
return
}
if l.phase[scope] == scopeNew {
l.phase[scope] = scopeStarted
}
// Everything alive in the subtree has now had its start step run,
// whether it was built before or during the call.
for _, in := range l.instances {
if in.live && in.hooks.start && under(in.scope, scope) {
in.expect["OnStart"] = true
}
}
}
// ranAndStopped records a Scope.Run: the scope was started and then stopped,
// and the model cannot tell from outside whether the hook phase was reached
// before the start failed. So the OnStart prediction is dropped for that
// subtree and only that; M3, M4 and M5 rest on whether the start step ran,
// which is observed, so they stay exact through a Run.
func (l *lifecycle) ranAndStopped(scope int, err error) {
if refusedAsSecondStart(err) {
return // refused before anything happened
}
for s := range numScopes {
if under(s, scope) {
l.startUnknown[s] = true
}
}
l.stopping(scope)
}
// stopping records a Stop of a scope, and settles what its subtree owed.
func (l *lifecycle) stopping(scope int) {
if l.phase[scope] == scopeStopped {
return
}
for s := range numScopes {
if under(s, scope) {
l.phase[s] = scopeStopped
}
}
for _, in := range l.instances {
if !in.live || !under(in.scope, scope) {
continue
}
in.live = false
// M3 and M4: owed when the start step succeeded, or when OnStop has
// nothing to pair with and is a plain destructor.
paired := in.hooks.start && l.everStarted(in.scope)
owed := in.ran["OnStart"] > 0 || !paired
in.expect["OnStop"] = in.hooks.stop && owed
in.expect["OnDrain"] = in.hooks.drain && owed
}
}
// check reports what the model expected and the container did not do, or did
// and should not have.
func (l *lifecycle) check() []string {
for _, in := range l.instances {
where := l.m.names[in.scope]
for _, hook := range []string{"OnStart", "OnDrain", "OnStop"} {
if hook == "OnStart" && l.startUnknown[in.scope] {
continue
}
want, got := in.expect[hook], in.ran[hook] > 0
switch {
case want && !got: // M3, M4, M6
l.failf("an instance in %s owed %s and never got one", where, hook)
case !want && got:
l.failf("an instance in %s ran %s when none was owed", where, hook)
}
}
// M2
for _, pair := range [][2]string{{"OnStart", "OnDrain"}, {"OnDrain", "OnStop"}, {"OnStart", "OnStop"}} {
a, b := in.at[pair[0]], in.at[pair[1]]
if a > 0 && b > 0 && a > b {
l.failf("an instance in %s ran %s before %s", where, pair[1], pair[0])
}
}
}
// M5
stopped := make([]*modelInstance, 0, len(l.instances))
for _, in := range l.instances {
if in.at["OnStop"] > 0 {
stopped = append(stopped, in)
}
}
slices.SortFunc(stopped, func(a, b *modelInstance) int { return a.at["OnStop"] - b.at["OnStop"] })
for i, in := range stopped {
for _, later := range stopped[i+1:] {
if in.scope == later.scope && in.built < later.built {
l.failf("%s stopped two instances in build order, not in reverse", l.m.names[in.scope])
}
if in.scope != later.scope && under(later.scope, in.scope) {
l.failf("%s stopped before %s, which is under it",
l.m.names[in.scope], l.m.names[later.scope])
}
}
}
return l.fails
}
func (l *lifecycle) report() {
if msgs := l.check(); len(msgs) > 0 {
l.m.fail("the lifecycle model disagrees:\n %s", strings.Join(msgs, "\n "))
}
}