Files
scrabble-game/loadtest/internal/scenario/assemble.go
T
Ilia Denisov aa137e3558
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
R2: load-test harness + contour resource observability
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.
2026-06-09 23:45:24 +02:00

171 lines
4.8 KiB
Go

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 ""
}