package idear_test import ( "errors" "fmt" "sync" "testing" "amadan.net/rastrillo/idear" "amadan.net/rastrillo/idear/internal/ideartest" ) // These four are the point of the store, and they are written as // ACTUAL races: goroutines released together off a start barrier, not // sequential calls arranged to look concurrent. Round 1 of the CARLOS // bake-off found a real ownership-transfer defect in a hand-rolled // membership layer, and found it only because someone ran it as a // race; a sequential rehearsal of the same calls passes over the bug, // because the bug lives in the window between a read and a write and a // sequential test never opens that window. // // Every worker below drives h.Roster directly and returns its error // through a slice. None of them touch testing.T, and none call a // harness helper: those report with t.Fatalf, which is only legal on // the test goroutine. // // A note on what these can and cannot prove. rastrillo/db's writer // pool holds exactly ONE connection, so write transactions queue in // database/sql rather than collide in SQLite — which is precisely why // none of these need a retry loop or a sleep, and why seeing // "database is locked" here would be a bug in the store (a // transaction held open across something that is not a database // operation) and never something to paper over. What the single writer // does NOT do is serialise a read-then-write pair that spans two // transactions. That gap is the whole attack surface, and it is what // these tests aim at. // release returns a start barrier: the workers block on wait() until // the test closes the gate, so they enter the store together instead // of trickling in as they are spawned. func release() (gate chan struct{}, wait func()) { gate = make(chan struct{}) return gate, func() { <-gate } } // pair runs two operations concurrently off one start barrier, with // the spawn order flipped when swap is true. // // The flip is not decoration. Closing a channel wakes its waiters in // FIFO order and the LAST one readied lands in the P's runnext slot, // so it is the last-spawned goroutine that actually runs first — a // systematic bias, and a measured one: without the flip, Revoke won // 58-60 of every 60 rounds below and the Accept-wins branch of the // invariant was barely exercised at all. A race test that only ever // resolves one way is green for a reason unrelated to the property it // claims to check. Alternating the spawn order splits the outcomes // without weakening the race: both goroutines are still released // together and still contend for the same single writer connection. func pair(swap bool, first, second func()) { if swap { first, second = second, first } gate, wait := release() var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() wait() first() }() go func() { defer wg.Done() wait() second() }() close(gate) wg.Wait() } // TestConcurrentClaimYieldsOneOwner races six first-signups at an // unclaimed instance. // // Regression it catches: Claim counting rows OUTSIDE its transaction, // or counting only ACTIVE rows. Either way more than one goroutine // sees an empty roster, and the instance ends up with two Owners — // two people who can each demote the other, on a roster that is // supposed to have exactly one root of authority. func TestConcurrentClaimYieldsOneOwner(t *testing.T) { // Six racers, on a FRESH instance, repeated. A single round of six // was measured at only ~84% detection (21 failures in 25 runs) // against the mutation that moves the count out of the // transaction — so a one-shot `go test` missed the single most // important regression in this file about one run in five. Rounds // are independent trials: twenty of them put a miss past 1 in // 10^17, which is the difference between a test and a coin. const ( n = 6 rounds = 20 ) for round := range rounds { h := ideartest.New(t) ctx := h.Ctx() gate, wait := release() errs := make([]error, n) var wg sync.WaitGroup for i := range n { wg.Add(1) go func() { defer wg.Done() wait() _, errs[i] = h.Roster.Claim(ctx, fmt.Sprintf("claimant-%d", i), fmt.Sprintf("claimant-%d@example.test", i), fmt.Sprintf("Claimant %d", i)) }() } close(gate) wg.Wait() won, refused := 0, 0 for i, err := range errs { switch { case err == nil: won++ case errors.Is(err, idear.ErrOwnerExists): refused++ default: t.Fatalf("round %d: claimant %d failed with an unexpected error: %v", round, i, err) } } if won != 1 { t.Fatalf("round %d: %d of %d concurrent claims succeeded, want exactly 1", round, won, n) } if refused != n-1 { t.Fatalf("round %d: %d claims were refused with ErrOwnerExists, want %d", round, refused, n-1) } if got := h.CountMembers(); got != 1 { t.Fatalf("round %d: the roster holds %d rows, want exactly 1", round, got) } if owner := h.TheOwner(); !owner.Active() { t.Fatalf("round %d: the owner is not active", round) } } } // TestConcurrentTransfersKeepOneOwner races six transfers out of the // same Owner, each to a different target. // // Regression it catches: Transfer trusting the *Member it was handed // instead of re-reading the actor's row inside its transaction. Every // goroutine holds a struct that says "I am the Owner", and every one // of them is telling the truth about the moment it was read. Without // the re-read, all six promote their target and the instance ends up // with six Owners and no way back — this is the exact defect round 1 // of the bake-off found in a hand-rolled version. func TestConcurrentTransfersKeepOneOwner(t *testing.T) { const n = 6 h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() targets := make([]*idear.Member, n) for i := range targets { targets[i] = h.Member(idear.RoleAdmin) } gate, wait := release() errs := make([]error, n) var wg sync.WaitGroup for i := range n { wg.Add(1) go func() { defer wg.Done() wait() errs[i] = h.Roster.Transfer(ctx, owner, targets[i]) }() } close(gate) wg.Wait() won := 0 for i, err := range errs { switch { case err == nil: won++ case errors.Is(err, idear.ErrForbidden): // The transaction re-read an actor who is no longer Owner. default: t.Errorf("transfer %d failed with an unexpected error: %v", i, err) } } if won != 1 { t.Errorf("%d of %d concurrent transfers succeeded, want exactly 1", won, n) } // The count is the invariant, not the number of successes: a // transaction that half-committed would leave two Owners while // reporting one success. newOwner := h.TheOwner() if !newOwner.Active() { t.Error("the new owner is not active") } if newOwner.ID == owner.ID { t.Error("ownership did not move at all; one transfer should have won") } if got := h.Reload(owner.ID).Role; got != idear.RoleAdmin { t.Errorf("the outgoing owner's role = %q, want admin — the demote and the promote are one transaction", got) } } // TestRevokeRacingAcceptNeverAdmits races a Revoke against an Accept of // the same invitation, many times over, each iteration on a fresh // invitation. // // Regression it catches: Accept consuming the invitation by lookup and // then writing, instead of by compare-and-swap with rows-affected // checked. Between the lookup and the write, Revoke commits — and the // invitation is admitted after it was withdrawn, which is the whole // point of being able to withdraw one. Softening the CAS's WHERE // clause (dropping "revoked_at IS NULL", say) fails here too, as does // moving the Member insert out of the invitation's transaction. // // Note that "never both" is the assertion, not "Accept always loses". // Either outcome is correct; what is not correct is both succeeding. func TestRevokeRacingAcceptNeverAdmits(t *testing.T) { const iterations = 60 h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() admitted, revoked, both, neither := 0, 0, 0, 0 for i := range iterations { email := fmt.Sprintf("racer-%d@example.test", i) subject := fmt.Sprintf("racer-%d", i) inv, token, err := h.Roster.Invite(ctx, owner, email, idear.RoleMember) if err != nil { t.Fatalf("iteration %d: Invite: %v", i, err) } var ( acceptErr, revokeErr error acceptedM *idear.Member ) pair(i%2 == 1, func() { acceptedM, acceptErr = h.Roster.Accept(ctx, token, subject, "Racer") }, func() { revokeErr = h.Roster.Revoke(ctx, owner, inv.ID) }, ) acceptDone, revokeDone := acceptErr == nil, revokeErr == nil if acceptErr != nil && !errors.Is(acceptErr, idear.ErrNoInvitation) { t.Fatalf("iteration %d: Accept failed unexpectedly: %v", i, acceptErr) } if revokeErr != nil && !errors.Is(revokeErr, idear.ErrNoInvitation) { t.Fatalf("iteration %d: Revoke failed unexpectedly: %v", i, revokeErr) } switch { case acceptDone && revokeDone: both++ t.Errorf("iteration %d: the invitation was BOTH accepted and revoked", i) case acceptDone: admitted++ case revokeDone: revoked++ default: neither++ t.Errorf("iteration %d: neither Accept nor Revoke succeeded (accept=%v revoke=%v)", i, acceptErr, revokeErr) } // The row's own state must agree with who won, and a member // must exist if and only if Accept won. A CAS that updated the // invitation but lost the member insert would show up here. stored := h.Invitation(inv.ID) _, lookupErr := h.Roster.BySubject(ctx, subject) if acceptDone { if stored.AcceptedAt == nil { t.Errorf("iteration %d: Accept succeeded but AcceptedAt is NULL", i) } if stored.RevokedAt != nil { t.Errorf("iteration %d: the accepted invitation is also marked revoked", i) } if lookupErr != nil { t.Errorf("iteration %d: Accept succeeded but the member is not in the roster: %v", i, lookupErr) } if acceptedM != nil && acceptedM.Role != idear.RoleMember { t.Errorf("iteration %d: admitted at %q, want the invited role", i, acceptedM.Role) } } else { if stored.AcceptedAt != nil { t.Errorf("iteration %d: Accept failed but the invitation is marked accepted", i) } if !errors.Is(lookupErr, idear.ErrNotFound) { t.Errorf("iteration %d: a refused Accept admitted the subject anyway: %v", i, lookupErr) } } } t.Logf("%d iterations: %d admitted, %d revoked, %d both, %d neither", iterations, admitted, revoked, both, neither) // Both outcomes should actually occur across this many rounds. If // one never does, the race is not being exercised — the test would // still be green while proving nothing. if admitted == 0 || revoked == 0 { t.Errorf("only one outcome ever occurred (%d admitted, %d revoked); "+ "the two calls are not actually racing and this test proves nothing", admitted, revoked) } } // TestTransferRacingDeactivateNeverStrandsOwner races a Transfer of // ownership TO a member against a Deactivate OF that same member. // // Regression it catches: Transfer not confirming, inside its own // transaction, that the target is still active. The losing order is // Deactivate-then-Transfer: the transfer promotes a row that was // deactivated a microsecond ago, and the instance now has a // DEACTIVATED Owner. That is terminal through idear's own API — // MayActOn refuses acting on an Owner at every rank, so nobody can // reactivate them, nobody can demote them, and nobody can be promoted // past them. It also catches Deactivate checking "is this the Owner" // against the caller's stale struct rather than the row: in the other // order, the target IS the Owner by the time Deactivate's transaction // runs, and must be refused. func TestTransferRacingDeactivateNeverStrandsOwner(t *testing.T) { const iterations = 40 transferWins, deactivateWins, bothFailed := 0, 0, 0 for i := range iterations { h := ideartest.New(t) ctx := h.Ctx() owner := h.Owner() target := h.Member(idear.RoleAdmin) var transferErr, removeErr error pair(i%2 == 1, func() { transferErr = h.Roster.Transfer(ctx, owner, target) }, func() { removeErr = h.Roster.Deactivate(ctx, owner, target) }, ) switch { case transferErr == nil: transferWins++ case removeErr == nil: deactivateWins++ default: // Both refused. Folding this into "deactivate won" would // let the both-outcomes-occurred check below pass on a // run where neither operation ever succeeded. bothFailed++ } if transferErr != nil && !errors.Is(transferErr, idear.ErrForbidden) { t.Fatalf("iteration %d: Transfer failed unexpectedly: %v", i, transferErr) } if removeErr != nil && !errors.Is(removeErr, idear.ErrForbidden) && !errors.Is(removeErr, idear.ErrLastOwner) { t.Fatalf("iteration %d: Deactivate failed unexpectedly: %v", i, removeErr) } // The invariant, whichever order won: exactly one Owner, and // that Owner is ACTIVE. current := h.TheOwner() if !current.Active() { t.Fatalf("iteration %d: the instance has a DEACTIVATED owner (member %d) — "+ "nobody can administer it and nobody can be promoted (transfer=%v deactivate=%v)", i, current.ID, transferErr, removeErr) } switch { case transferErr == nil: // Transfer won the race, so Deactivate must have been // refused: by the time it ran, its target was the Owner. if current.ID != target.ID { t.Errorf("iteration %d: Transfer succeeded but the owner is %d, not %d", i, current.ID, target.ID) } if removeErr == nil { t.Errorf("iteration %d: the new owner was deactivated by the racing Deactivate", i) } default: // Deactivate won, so ownership must not have moved. if current.ID != owner.ID { t.Errorf("iteration %d: Transfer was refused but ownership moved to %d", i, current.ID) } } } t.Logf("%d iterations: transfer won %d, deactivate won %d, both refused %d", iterations, transferWins, deactivateWins, bothFailed) if transferWins == 0 || deactivateWins == 0 { t.Errorf("only one order ever occurred (transfer %d, deactivate %d, both refused %d); "+ "the two calls are not actually racing and this test proves nothing", transferWins, deactivateWins, bothFailed) } if bothFailed != 0 { t.Errorf("%d iterations refused BOTH operations; one of them must always be able to win", bothFailed) } }