Files
scrabble-game/loadtest/internal/scenario/assemble.go
T
Ilia Denisov 04263a17ca R7: per-player transports + drop finished games in the load harness
Each virtual player now builds its own edge.Client (its own h2c connection
carrying both the Subscribe stream and the Execute calls), instead of every
player multiplexing over a single shared http2.Transport. The R2 trip report
traced the ~14% transport_error on game.state at 500 players to that single
shared transport; per-player connections mirror real clients and isolate the
artifact. The assembly burst and the gateway-hammer each get their own client.

playTurn now reports when a game has finished so playerLoop drops it from the
rotation (slices.DeleteFunc); once no active game remains the player idles while
still holding its stream. This stops secondary ops from hammering game_finished
on already-ended games (the other R2 harness finding).
2026-06-10 18:53:07 +02:00

172 lines
4.9 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
}
c := edge.New(d.gateway) // one client for the assembly burst; players play on their own
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, c, 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, c *edge.Client, 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 := c.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 := c.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 := c.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 := c.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 ""
}