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).
This commit is contained in:
Ilia Denisov
2026-06-10 18:53:07 +02:00
parent 7210bed560
commit 04263a17ca
5 changed files with 95 additions and 65 deletions
+78 -52
View File
@@ -9,6 +9,7 @@ import (
"context"
"log/slog"
"math/rand"
"slices"
"sync"
"time"
@@ -18,18 +19,20 @@ import (
"scrabble/loadtest/internal/seed"
)
// Driver ties the edge client, the local move generator and the run recorder
// together. All three are safe for concurrent use by many player goroutines.
// Driver ties the gateway endpoint, the local move generator and the run recorder
// together. It builds one edge client per virtual player, so each player owns its
// h2c connection (its Subscribe stream and Execute calls share it) the way a real
// client does, rather than multiplexing every player over a single shared transport.
type Driver struct {
edge *edge.Client
moves *moves.Registry
rec *report.Recorder
log *slog.Logger
gateway string // gateway base URL, e.g. http://gateway:8081
moves *moves.Registry
rec *report.Recorder
log *slog.Logger
}
// NewDriver builds a Driver.
func NewDriver(c *edge.Client, m *moves.Registry, rec *report.Recorder, log *slog.Logger) *Driver {
return &Driver{edge: c, moves: m, rec: rec, log: log}
// NewDriver builds a Driver targeting the gateway base URL.
func NewDriver(gateway string, m *moves.Registry, rec *report.Recorder, log *slog.Logger) *Driver {
return &Driver{gateway: gateway, moves: m, rec: rec, log: log}
}
// RealisticConfig parameterises the under-the-limit ramp.
@@ -98,11 +101,16 @@ func (d *Driver) RunRealistic(ctx context.Context, pool *seed.Pool, cfg Realisti
return nil
}
// playerLoop runs one virtual player: a live-event subscription (loads the push hub,
// counts events) plus a round-robin turn loop over the player's games.
// playerLoop runs one virtual player over its own edge client (its own h2c
// connection): a live-event subscription (loads the push hub, counts events) plus a
// round-robin turn loop over the player's games. A game that has finished is dropped
// from the rotation so secondary ops stop hitting an ended game; once no active game
// remains the player idles, still holding its stream, until the run ends.
func (d *Driver) playerLoop(ctx context.Context, p seed.Account, games []*Game, cfg RealisticConfig, rng *rand.Rand) {
go d.subscribeLoop(ctx, p)
if len(games) == 0 {
c := edge.New(d.gateway)
go d.subscribeLoop(ctx, c, p)
active := games
if len(active) == 0 {
<-ctx.Done()
return
}
@@ -114,22 +122,30 @@ func (d *Driver) playerLoop(ctx context.Context, p seed.Account, games []*Game,
case <-ctx.Done():
return
case <-ticker.C:
g := games[gi%len(games)]
g := active[gi%len(active)]
gi++
if rng.Float64() < cfg.SecondaryProb {
d.secondaryOp(ctx, p, g, rng)
d.secondaryOp(ctx, c, p, g, rng)
continue
}
d.playTurn(ctx, p, g, rng)
if d.playTurn(ctx, c, p, g, rng) {
active = slices.DeleteFunc(active, func(x *Game) bool { return x == g })
gi = 0
if len(active) == 0 {
<-ctx.Done()
return
}
}
}
}
}
// subscribeLoop holds the player's live-event stream open, counting events and
// reconnecting with a brief backoff after a drop, until the run ends.
func (d *Driver) subscribeLoop(ctx context.Context, p seed.Account) {
// subscribeLoop holds the player's live-event stream open on the player's client,
// counting events and reconnecting with a brief backoff after a drop, until the run
// ends.
func (d *Driver) subscribeLoop(ctx context.Context, c *edge.Client, p seed.Account) {
for ctx.Err() == nil {
err := d.edge.Subscribe(ctx, p.Token, func(e edge.Event) { d.rec.Event(e.Kind) })
err := c.Subscribe(ctx, p.Token, func(e edge.Event) { d.rec.Event(e.Kind) })
if ctx.Err() != nil {
return
}
@@ -144,80 +160,90 @@ func (d *Driver) subscribeLoop(ctx context.Context, p seed.Account) {
}
}
// playTurn plays one turn in g when it is the player's move: fetch state, replay
// history, pick a legal move and submit it (or exchange / pass).
func (d *Driver) playTurn(ctx context.Context, p seed.Account, g *Game, rng *rand.Rand) {
// playTurn plays one turn in g over the player's client when it is the player's
// move: fetch state, replay history, pick a legal move and submit it (or exchange /
// pass). It reports whether the game has finished, so the caller can drop it from the
// rotation.
func (d *Driver) playTurn(ctx context.Context, c *edge.Client, p seed.Account, g *Game, rng *rand.Rand) (finished bool) {
seat := g.seatOf(p.ID.String())
if seat < 0 {
return
return false
}
t0 := time.Now()
st, code, err := d.edge.State(ctx, p.Token, g.ID)
st, code, err := c.State(ctx, p.Token, g.ID)
d.rec.Record("game.state", code, time.Since(t0))
if err != nil || code != "ok" || !st.Game.Active() || st.Game.ToMove != seat {
return
if err != nil || code != "ok" {
return false
}
if !st.Game.Active() {
return true
}
if st.Game.ToMove != seat {
return false
}
t0 = time.Now()
hist, hc, err := d.edge.History(ctx, p.Token, g.ID)
hist, hc, err := c.History(ctx, p.Token, g.ID)
d.rec.Record("game.history", hc, time.Since(t0))
if err != nil || hc != "ok" {
return
return false
}
action, err := d.moves.Pick(g.Variant, hist, st.Rack, st.BagLen, rng)
if err != nil {
d.log.Debug("pick move", "variant", g.Variant, "err", err)
return
return false
}
switch action.Kind {
case "play":
t0 = time.Now()
_, c, _ := d.edge.SubmitPlay(ctx, p.Token, g.ID, action.Dir, action.Tiles)
d.rec.Record("game.submit_play", c, time.Since(t0))
_, code, _ := c.SubmitPlay(ctx, p.Token, g.ID, action.Dir, action.Tiles)
d.rec.Record("game.submit_play", code, time.Since(t0))
case "exchange":
t0 = time.Now()
_, c, _ := d.edge.Exchange(ctx, p.Token, g.ID, action.Exchange)
d.rec.Record("game.exchange", c, time.Since(t0))
_, code, _ := c.Exchange(ctx, p.Token, g.ID, action.Exchange)
d.rec.Record("game.exchange", code, time.Since(t0))
default:
t0 = time.Now()
_, c, _ := d.edge.Pass(ctx, p.Token, g.ID)
d.rec.Record("game.pass", c, time.Since(t0))
_, code, _ := c.Pass(ctx, p.Token, g.ID)
d.rec.Record("game.pass", code, time.Since(t0))
}
return false
}
// secondaryOp exercises one of the non-move edge operations the plan calls out, so
// the run touches nudge / chat / check-word / draft / profile / stats too.
func (d *Driver) secondaryOp(ctx context.Context, p seed.Account, g *Game, rng *rand.Rand) {
// the run touches nudge / chat / check-word / draft / profile / stats too, over the
// player's own client.
func (d *Driver) secondaryOp(ctx context.Context, c *edge.Client, p seed.Account, g *Game, rng *rand.Rand) {
t0 := time.Now()
switch rng.Intn(7) {
case 0:
c, _ := d.edge.Nudge(ctx, p.Token, g.ID)
d.rec.Record("chat.nudge", c, time.Since(t0))
code, _ := c.Nudge(ctx, p.Token, g.ID)
d.rec.Record("chat.nudge", code, time.Since(t0))
case 1:
c, _ := d.edge.ChatPost(ctx, p.Token, g.ID, "gg")
d.rec.Record("chat.post", c, time.Since(t0))
code, _ := c.ChatPost(ctx, p.Token, g.ID, "gg")
d.rec.Record("chat.post", code, time.Since(t0))
case 2:
c, _ := d.edge.CheckWord(ctx, p.Token, g.ID, []byte{0, 1, 2})
d.rec.Record("game.check_word", c, time.Since(t0))
code, _ := c.CheckWord(ctx, p.Token, g.ID, []byte{0, 1, 2})
d.rec.Record("game.check_word", code, time.Since(t0))
case 3:
// rack_order is an opaque string and board_tiles a (here empty) array, per the
// backend draft DTO; a malformed shape is rejected as bad_request.
c, _ := d.edge.DraftSave(ctx, p.Token, g.ID, `{"rack_order":"","board_tiles":[]}`)
d.rec.Record("draft.save", c, time.Since(t0))
code, _ := c.DraftSave(ctx, p.Token, g.ID, `{"rack_order":"","board_tiles":[]}`)
d.rec.Record("draft.save", code, time.Since(t0))
case 4:
c, _ := d.edge.DraftGet(ctx, p.Token, g.ID)
d.rec.Record("draft.get", c, time.Since(t0))
code, _ := c.DraftGet(ctx, p.Token, g.ID)
d.rec.Record("draft.get", code, time.Since(t0))
case 5:
lang := "en"
if rng.Intn(2) == 1 {
lang = "ru"
}
c, _ := d.edge.ProfileUpdate(ctx, p.Token, p.Name, lang)
d.rec.Record("profile.update", c, time.Since(t0))
code, _ := c.ProfileUpdate(ctx, p.Token, p.Name, lang)
d.rec.Record("profile.update", code, time.Since(t0))
default:
c, _ := d.edge.Stats(ctx, p.Token)
d.rec.Record("stats.get", c, time.Since(t0))
code, _ := c.Stats(ctx, p.Token)
d.rec.Record("stats.get", code, time.Since(t0))
}
}