rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1package idear_test
2
3import (
4 "errors"
5 "fmt"
6 "sync"
7 "testing"
8
9 "amadan.net/rastrillo/idear"
10 "amadan.net/rastrillo/idear/internal/ideartest"
11)
12
13// These four are the point of the store, and they are written as
14// ACTUAL races: goroutines released together off a start barrier, not
15// sequential calls arranged to look concurrent. Round 1 of the CARLOS
16// bake-off found a real ownership-transfer defect in a hand-rolled
17// membership layer, and found it only because someone ran it as a
18// race; a sequential rehearsal of the same calls passes over the bug,
19// because the bug lives in the window between a read and a write and a
20// sequential test never opens that window.
21//
22// Every worker below drives h.Roster directly and returns its error
23// through a slice. None of them touch testing.T, and none call a
24// harness helper: those report with t.Fatalf, which is only legal on
25// the test goroutine.
26//
27// A note on what these can and cannot prove. rastrillo/db's writer
28// pool holds exactly ONE connection, so write transactions queue in
29// database/sql rather than collide in SQLite — which is precisely why
30// none of these need a retry loop or a sleep, and why seeing
31// "database is locked" here would be a bug in the store (a
32// transaction held open across something that is not a database
33// operation) and never something to paper over. What the single writer
34// does NOT do is serialise a read-then-write pair that spans two
35// transactions. That gap is the whole attack surface, and it is what
36// these tests aim at.
37
38// release returns a start barrier: the workers block on wait() until
39// the test closes the gate, so they enter the store together instead
40// of trickling in as they are spawned.
41func release() (gate chan struct{}, wait func()) {
42 gate = make(chan struct{})
43 return gate, func() { <-gate }
44}
45
46// pair runs two operations concurrently off one start barrier, with
47// the spawn order flipped when swap is true.
48//
49// The flip is not decoration. Closing a channel wakes its waiters in
50// FIFO order and the LAST one readied lands in the P's runnext slot,
51// so it is the last-spawned goroutine that actually runs first — a
52// systematic bias, and a measured one: without the flip, Revoke won
53// 58-60 of every 60 rounds below and the Accept-wins branch of the
54// invariant was barely exercised at all. A race test that only ever
55// resolves one way is green for a reason unrelated to the property it
56// claims to check. Alternating the spawn order splits the outcomes
57// without weakening the race: both goroutines are still released
58// together and still contend for the same single writer connection.
59func pair(swap bool, first, second func()) {
60 if swap {
61 first, second = second, first
62 }
63 gate, wait := release()
64 var wg sync.WaitGroup
65 wg.Add(2)
66 go func() {
67 defer wg.Done()
68 wait()
69 first()
70 }()
71 go func() {
72 defer wg.Done()
73 wait()
74 second()
75 }()
76 close(gate)
77 wg.Wait()
78}
79
80// TestConcurrentClaimYieldsOneOwner races six first-signups at an
81// unclaimed instance.
82//
83// Regression it catches: Claim counting rows OUTSIDE its transaction,
84// or counting only ACTIVE rows. Either way more than one goroutine
85// sees an empty roster, and the instance ends up with two Owners —
86// two people who can each demote the other, on a roster that is
87// supposed to have exactly one root of authority.
88func TestConcurrentClaimYieldsOneOwner(t *testing.T) {
89 // Six racers, on a FRESH instance, repeated. A single round of six
90 // was measured at only ~84% detection (21 failures in 25 runs)
91 // against the mutation that moves the count out of the
92 // transaction — so a one-shot `go test` missed the single most
93 // important regression in this file about one run in five. Rounds
94 // are independent trials: twenty of them put a miss past 1 in
95 // 10^17, which is the difference between a test and a coin.
96 const (
97 n = 6
98 rounds = 20
99 )
100
101 for round := range rounds {
102 h := ideartest.New(t)
103 ctx := h.Ctx()
104
105 gate, wait := release()
106 errs := make([]error, n)
107 var wg sync.WaitGroup
108 for i := range n {
109 wg.Add(1)
110 go func() {
111 defer wg.Done()
112 wait()
113 _, errs[i] = h.Roster.Claim(ctx,
114 fmt.Sprintf("claimant-%d", i),
115 fmt.Sprintf("claimant-%d@example.test", i),
116 fmt.Sprintf("Claimant %d", i))
117 }()
118 }
119 close(gate)
120 wg.Wait()
121
122 won, refused := 0, 0
123 for i, err := range errs {
124 switch {
125 case err == nil:
126 won++
127 case errors.Is(err, idear.ErrOwnerExists):
128 refused++
129 default:
130 t.Fatalf("round %d: claimant %d failed with an unexpected error: %v", round, i, err)
131 }
132 }
133 if won != 1 {
134 t.Fatalf("round %d: %d of %d concurrent claims succeeded, want exactly 1", round, won, n)
135 }
136 if refused != n-1 {
137 t.Fatalf("round %d: %d claims were refused with ErrOwnerExists, want %d", round, refused, n-1)
138 }
139 if got := h.CountMembers(); got != 1 {
140 t.Fatalf("round %d: the roster holds %d rows, want exactly 1", round, got)
141 }
142 if owner := h.TheOwner(); !owner.Active() {
143 t.Fatalf("round %d: the owner is not active", round)
144 }
145 }
146}
147
148// TestConcurrentTransfersKeepOneOwner races six transfers out of the
149// same Owner, each to a different target.
150//
151// Regression it catches: Transfer trusting the *Member it was handed
152// instead of re-reading the actor's row inside its transaction. Every
153// goroutine holds a struct that says "I am the Owner", and every one
154// of them is telling the truth about the moment it was read. Without
155// the re-read, all six promote their target and the instance ends up
156// with six Owners and no way back — this is the exact defect round 1
157// of the bake-off found in a hand-rolled version.
158func TestConcurrentTransfersKeepOneOwner(t *testing.T) {
159 const n = 6
160 h := ideartest.New(t)
161 ctx := h.Ctx()
162
163 owner := h.Owner()
164 targets := make([]*idear.Member, n)
165 for i := range targets {
166 targets[i] = h.Member(idear.RoleAdmin)
167 }
168
169 gate, wait := release()
170 errs := make([]error, n)
171 var wg sync.WaitGroup
172 for i := range n {
173 wg.Add(1)
174 go func() {
175 defer wg.Done()
176 wait()
177 errs[i] = h.Roster.Transfer(ctx, owner, targets[i])
178 }()
179 }
180 close(gate)
181 wg.Wait()
182
183 won := 0
184 for i, err := range errs {
185 switch {
186 case err == nil:
187 won++
188 case errors.Is(err, idear.ErrForbidden):
189 // The transaction re-read an actor who is no longer Owner.
190 default:
191 t.Errorf("transfer %d failed with an unexpected error: %v", i, err)
192 }
193 }
194 if won != 1 {
195 t.Errorf("%d of %d concurrent transfers succeeded, want exactly 1", won, n)
196 }
197
198 // The count is the invariant, not the number of successes: a
199 // transaction that half-committed would leave two Owners while
200 // reporting one success.
201 newOwner := h.TheOwner()
202 if !newOwner.Active() {
203 t.Error("the new owner is not active")
204 }
205 if newOwner.ID == owner.ID {
206 t.Error("ownership did not move at all; one transfer should have won")
207 }
208 if got := h.Reload(owner.ID).Role; got != idear.RoleAdmin {
209 t.Errorf("the outgoing owner's role = %q, want admin — the demote and the promote are one transaction", got)
210 }
211}
212
213// TestRevokeRacingAcceptNeverAdmits races a Revoke against an Accept of
214// the same invitation, many times over, each iteration on a fresh
215// invitation.
216//
217// Regression it catches: Accept consuming the invitation by lookup and
218// then writing, instead of by compare-and-swap with rows-affected
219// checked. Between the lookup and the write, Revoke commits — and the
220// invitation is admitted after it was withdrawn, which is the whole
221// point of being able to withdraw one. Softening the CAS's WHERE
222// clause (dropping "revoked_at IS NULL", say) fails here too, as does
223// moving the Member insert out of the invitation's transaction.
224//
225// Note that "never both" is the assertion, not "Accept always loses".
226// Either outcome is correct; what is not correct is both succeeding.
227func TestRevokeRacingAcceptNeverAdmits(t *testing.T) {
228 const iterations = 60
229 h := ideartest.New(t)
230 ctx := h.Ctx()
231 owner := h.Owner()
232
233 admitted, revoked, both, neither := 0, 0, 0, 0
234 for i := range iterations {
235 email := fmt.Sprintf("racer-%d@example.test", i)
236 subject := fmt.Sprintf("racer-%d", i)
237 inv, token, err := h.Roster.Invite(ctx, owner, email, idear.RoleMember)
238 if err != nil {
239 t.Fatalf("iteration %d: Invite: %v", i, err)
240 }
241
242 var (
243 acceptErr, revokeErr error
244 acceptedM *idear.Member
245 )
246 pair(i%2 == 1,
247 func() { acceptedM, acceptErr = h.Roster.Accept(ctx, token, subject, "Racer") },
248 func() { revokeErr = h.Roster.Revoke(ctx, owner, inv.ID) },
249 )
250 acceptDone, revokeDone := acceptErr == nil, revokeErr == nil
251
252 if acceptErr != nil && !errors.Is(acceptErr, idear.ErrNoInvitation) {
253 t.Fatalf("iteration %d: Accept failed unexpectedly: %v", i, acceptErr)
254 }
255 if revokeErr != nil && !errors.Is(revokeErr, idear.ErrNoInvitation) {
256 t.Fatalf("iteration %d: Revoke failed unexpectedly: %v", i, revokeErr)
257 }
258
259 switch {
260 case acceptDone && revokeDone:
261 both++
262 t.Errorf("iteration %d: the invitation was BOTH accepted and revoked", i)
263 case acceptDone:
264 admitted++
265 case revokeDone:
266 revoked++
267 default:
268 neither++
269 t.Errorf("iteration %d: neither Accept nor Revoke succeeded (accept=%v revoke=%v)", i, acceptErr, revokeErr)
270 }
271
272 // The row's own state must agree with who won, and a member
273 // must exist if and only if Accept won. A CAS that updated the
274 // invitation but lost the member insert would show up here.
275 stored := h.Invitation(inv.ID)
276 _, lookupErr := h.Roster.BySubject(ctx, subject)
277 if acceptDone {
278 if stored.AcceptedAt == nil {
279 t.Errorf("iteration %d: Accept succeeded but AcceptedAt is NULL", i)
280 }
281 if stored.RevokedAt != nil {
282 t.Errorf("iteration %d: the accepted invitation is also marked revoked", i)
283 }
284 if lookupErr != nil {
285 t.Errorf("iteration %d: Accept succeeded but the member is not in the roster: %v", i, lookupErr)
286 }
287 if acceptedM != nil && acceptedM.Role != idear.RoleMember {
288 t.Errorf("iteration %d: admitted at %q, want the invited role", i, acceptedM.Role)
289 }
290 } else {
291 if stored.AcceptedAt != nil {
292 t.Errorf("iteration %d: Accept failed but the invitation is marked accepted", i)
293 }
294 if !errors.Is(lookupErr, idear.ErrNotFound) {
295 t.Errorf("iteration %d: a refused Accept admitted the subject anyway: %v", i, lookupErr)
296 }
297 }
298 }
299
300 t.Logf("%d iterations: %d admitted, %d revoked, %d both, %d neither",
301 iterations, admitted, revoked, both, neither)
302 // Both outcomes should actually occur across this many rounds. If
303 // one never does, the race is not being exercised — the test would
304 // still be green while proving nothing.
305 if admitted == 0 || revoked == 0 {
306 t.Errorf("only one outcome ever occurred (%d admitted, %d revoked); "+
307 "the two calls are not actually racing and this test proves nothing",
308 admitted, revoked)
309 }
310}
311
312// TestTransferRacingDeactivateNeverStrandsOwner races a Transfer of
313// ownership TO a member against a Deactivate OF that same member.
314//
315// Regression it catches: Transfer not confirming, inside its own
316// transaction, that the target is still active. The losing order is
317// Deactivate-then-Transfer: the transfer promotes a row that was
318// deactivated a microsecond ago, and the instance now has a
319// DEACTIVATED Owner. That is terminal through idear's own API —
320// MayActOn refuses acting on an Owner at every rank, so nobody can
321// reactivate them, nobody can demote them, and nobody can be promoted
322// past them. It also catches Deactivate checking "is this the Owner"
323// against the caller's stale struct rather than the row: in the other
324// order, the target IS the Owner by the time Deactivate's transaction
325// runs, and must be refused.
326func TestTransferRacingDeactivateNeverStrandsOwner(t *testing.T) {
327 const iterations = 40
328 transferWins, deactivateWins, bothFailed := 0, 0, 0
329
330 for i := range iterations {
331 h := ideartest.New(t)
332 ctx := h.Ctx()
333 owner := h.Owner()
334 target := h.Member(idear.RoleAdmin)
335
336 var transferErr, removeErr error
337 pair(i%2 == 1,
338 func() { transferErr = h.Roster.Transfer(ctx, owner, target) },
339 func() { removeErr = h.Roster.Deactivate(ctx, owner, target) },
340 )
341 switch {
342 case transferErr == nil:
343 transferWins++
344 case removeErr == nil:
345 deactivateWins++
346 default:
347 // Both refused. Folding this into "deactivate won" would
348 // let the both-outcomes-occurred check below pass on a
349 // run where neither operation ever succeeded.
350 bothFailed++
351 }
352
353 if transferErr != nil && !errors.Is(transferErr, idear.ErrForbidden) {
354 t.Fatalf("iteration %d: Transfer failed unexpectedly: %v", i, transferErr)
355 }
356 if removeErr != nil && !errors.Is(removeErr, idear.ErrForbidden) && !errors.Is(removeErr, idear.ErrLastOwner) {
357 t.Fatalf("iteration %d: Deactivate failed unexpectedly: %v", i, removeErr)
358 }
359
360 // The invariant, whichever order won: exactly one Owner, and
361 // that Owner is ACTIVE.
362 current := h.TheOwner()
363 if !current.Active() {
364 t.Fatalf("iteration %d: the instance has a DEACTIVATED owner (member %d) — "+
365 "nobody can administer it and nobody can be promoted (transfer=%v deactivate=%v)",
366 i, current.ID, transferErr, removeErr)
367 }
368
369 switch {
370 case transferErr == nil:
371 // Transfer won the race, so Deactivate must have been
372 // refused: by the time it ran, its target was the Owner.
373 if current.ID != target.ID {
374 t.Errorf("iteration %d: Transfer succeeded but the owner is %d, not %d", i, current.ID, target.ID)
375 }
376 if removeErr == nil {
377 t.Errorf("iteration %d: the new owner was deactivated by the racing Deactivate", i)
378 }
379 default:
380 // Deactivate won, so ownership must not have moved.
381 if current.ID != owner.ID {
382 t.Errorf("iteration %d: Transfer was refused but ownership moved to %d", i, current.ID)
383 }
384 }
385 }
386
387 t.Logf("%d iterations: transfer won %d, deactivate won %d, both refused %d",
388 iterations, transferWins, deactivateWins, bothFailed)
389 if transferWins == 0 || deactivateWins == 0 {
390 t.Errorf("only one order ever occurred (transfer %d, deactivate %d, both refused %d); "+
391 "the two calls are not actually racing and this test proves nothing",
392 transferWins, deactivateWins, bothFailed)
393 }
394 if bothFailed != 0 {
395 t.Errorf("%d iterations refused BOTH operations; one of them must always be able to win",
396 bothFailed)
397 }
398}
399