R2: load-test harness + contour resource observability
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 3s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 3s
New scrabble/loadtest module (the pre-release stress harness): seeds 1000 guest + 10000 durable accounts with pre-created sessions directly in Postgres (token hash matches backend/internal/session), drives virtual players through the edge protocol (real 2-4p games assembled via invitations, mid-ranked legal moves generated locally by the embedded scrabble-solver — the edge carries no board, so the client replays history), plus nudge/chat/check-word/draft/profile/stats and a gateway-hammer that verifies the rate limiter. Prints a trip-report summary (per-op latency percentiles, result codes, live-event tally). Go unit tests cover the pure pieces; the DAWG-backed move test runs under BACKEND_DICT_DIR. Contour: add cAdvisor + postgres_exporter + a 'Scrabble - Resources' Grafana dashboard and the two Prometheus scrape jobs, for the R2/R7 stress-run resource baseline. CI: gate ./loadtest/... (path filter + vet/build/test). Docs: TESTING, ARCHITECTURE, project CLAUDE repo layout.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"scrabble/loadtest/internal/edge"
|
||||
"scrabble/loadtest/internal/moves"
|
||||
"scrabble/loadtest/internal/seed"
|
||||
)
|
||||
|
||||
// Game is one assembled match: its id, variant and members in seat order (Members[0]
|
||||
// is the inviter, seat 0).
|
||||
type Game struct {
|
||||
ID string
|
||||
Variant string
|
||||
Members []seed.Account
|
||||
}
|
||||
|
||||
// seatOf returns the seat index of accountID in the game, or -1.
|
||||
func (g *Game) seatOf(accountID string) int {
|
||||
for i, m := range g.Members {
|
||||
if m.ID.String() == accountID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// assembleCohort forms games among a cohort of active players via the invitation
|
||||
// flow, aiming for gamesPerPlayer (3-5) concurrent games per player with 2-4 players
|
||||
// each. It returns the games it managed to start. Failures are logged and skipped so
|
||||
// a partial assembly still drives load.
|
||||
func (d *Driver) assembleCohort(ctx context.Context, cohort []seed.Account, gamesPerPlayer int, rng *rand.Rand) []*Game {
|
||||
if len(cohort) < 2 {
|
||||
return nil
|
||||
}
|
||||
gamesOf := make(map[string]int, len(cohort))
|
||||
var games []*Game
|
||||
for i := range cohort {
|
||||
inviter := cohort[i]
|
||||
target := 3 + rng.Intn(3) // 3..5
|
||||
if gamesPerPlayer > 0 {
|
||||
target = gamesPerPlayer
|
||||
}
|
||||
for gamesOf[inviter.ID.String()] < target {
|
||||
members := pickMembers(cohort, inviter, rng)
|
||||
if len(members) < 2 {
|
||||
break
|
||||
}
|
||||
variant := moves.Variants()[rng.Intn(len(moves.Variants()))]
|
||||
g, err := d.assemble(ctx, members, variant)
|
||||
if err != nil {
|
||||
d.log.Debug("assemble game", "err", err)
|
||||
break
|
||||
}
|
||||
games = append(games, g)
|
||||
for _, m := range members {
|
||||
gamesOf[m.ID.String()]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return games
|
||||
}
|
||||
|
||||
// pickMembers builds a 2-4 player group led by inviter, drawing distinct others from
|
||||
// the cohort at random.
|
||||
func pickMembers(cohort []seed.Account, inviter seed.Account, rng *rand.Rand) []seed.Account {
|
||||
size := 2 + rng.Intn(3) // 2..4
|
||||
members := []seed.Account{inviter}
|
||||
seen := map[string]bool{inviter.ID.String(): true}
|
||||
for attempts := 0; len(members) < size && attempts < 4*size; attempts++ {
|
||||
cand := cohort[rng.Intn(len(cohort))]
|
||||
if seen[cand.ID.String()] {
|
||||
continue
|
||||
}
|
||||
seen[cand.ID.String()] = true
|
||||
members = append(members, cand)
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
// assemble runs the invitation flow for one game: the inviter (members[0]) invites
|
||||
// the rest, each invitee accepts the pending invitation, and the completing accept
|
||||
// starts the game, which is then located in the inviter's game list.
|
||||
func (d *Driver) assemble(ctx context.Context, members []seed.Account, variant string) (*Game, error) {
|
||||
inviter := members[0]
|
||||
inviteeIDs := make([]string, len(members)-1)
|
||||
for i, m := range members[1:] {
|
||||
inviteeIDs[i] = m.ID.String()
|
||||
}
|
||||
|
||||
t0 := time.Now()
|
||||
code, err := d.edge.CreateInvitation(ctx, inviter.Token, inviteeIDs, variant)
|
||||
d.rec.Record("invitation.create", code, time.Since(t0))
|
||||
if err != nil || code != "ok" {
|
||||
return nil, fmt.Errorf("invitation.create: %s", code)
|
||||
}
|
||||
|
||||
for _, invitee := range members[1:] {
|
||||
t0 = time.Now()
|
||||
list, lc, err := d.edge.ListInvitations(ctx, invitee.Token)
|
||||
d.rec.Record("invitation.list", lc, time.Since(t0))
|
||||
if err != nil || lc != "ok" {
|
||||
return nil, fmt.Errorf("invitation.list: %s", lc)
|
||||
}
|
||||
invID := findPending(list, inviter.ID.String())
|
||||
if invID == "" {
|
||||
return nil, fmt.Errorf("no pending invitation from %s", inviter.ID)
|
||||
}
|
||||
t0 = time.Now()
|
||||
ac, err := d.edge.AcceptInvitation(ctx, invitee.Token, invID)
|
||||
d.rec.Record("invitation.accept", ac, time.Since(t0))
|
||||
if err != nil || ac != "ok" {
|
||||
return nil, fmt.Errorf("invitation.accept: %s", ac)
|
||||
}
|
||||
}
|
||||
|
||||
t0 = time.Now()
|
||||
games, gc, err := d.edge.GamesList(ctx, inviter.Token)
|
||||
d.rec.Record("games.list", gc, time.Since(t0))
|
||||
if err != nil || gc != "ok" {
|
||||
return nil, fmt.Errorf("games.list: %s", gc)
|
||||
}
|
||||
ids := make([]string, len(members))
|
||||
for i, m := range members {
|
||||
ids[i] = m.ID.String()
|
||||
}
|
||||
gameID := findGame(games, ids)
|
||||
if gameID == "" {
|
||||
return nil, fmt.Errorf("started game not found for %d members", len(members))
|
||||
}
|
||||
return &Game{ID: gameID, Variant: variant, Members: members}, nil
|
||||
}
|
||||
|
||||
// findPending returns the id of a pending invitation from inviterID, or "".
|
||||
func findPending(list []edge.Invitation, inviterID string) string {
|
||||
for _, inv := range list {
|
||||
if inv.InviterID == inviterID && inv.Status == "pending" {
|
||||
return inv.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findGame returns the id of the active game whose seat set equals memberIDs, or "".
|
||||
func findGame(games []edge.Game, memberIDs []string) string {
|
||||
want := make(map[string]bool, len(memberIDs))
|
||||
for _, id := range memberIDs {
|
||||
want[id] = true
|
||||
}
|
||||
for _, g := range games {
|
||||
if !g.Active() || len(g.Seats) != len(memberIDs) {
|
||||
continue
|
||||
}
|
||||
match := true
|
||||
for _, s := range g.Seats {
|
||||
if !want[s] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return g.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user