Files
scrabble-game/loadtest/cmd/loadtest/main.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

193 lines
5.8 KiB
Go

// Command loadtest is the reusable load harness. It seeds a large account
// population with pre-created sessions directly in the backend Postgres, then drives
// virtual players through the gateway edge protocol (real games assembled via
// invitations, legal moves generated locally by the embedded solver), and a
// gateway-hammer that verifies the rate limiter. It prints a trip-report summary.
//
// Run it as a one-shot container on the contour's docker network so it reaches
// postgres:5432 and gateway:8081 directly:
//
// docker run --rm --network scrabble-internal \
// -e POSTGRES_PASSWORD=... -v /path/to/dawg:/dawg scrabble-loadtest run
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"scrabble/loadtest/internal/moves"
"scrabble/loadtest/internal/report"
"scrabble/loadtest/internal/scenario"
"scrabble/loadtest/internal/seed"
)
func main() {
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
var err error
switch os.Args[1] {
case "run":
err = cmdRun(ctx, log, os.Args[2:])
case "cleanup":
err = cmdCleanup(ctx, log, os.Args[2:])
default:
usage()
os.Exit(2)
}
if err != nil {
log.Error("loadtest failed", "cmd", os.Args[1], "err", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: loadtest <run|cleanup> [flags]")
fmt.Fprintln(os.Stderr, " run seed accounts, drive the realistic ramp + gateway-hammer, print the report")
fmt.Fprintln(os.Stderr, " cleanup delete everything the harness seeded (matched by marker)")
}
func cmdRun(ctx context.Context, log *slog.Logger, args []string) error {
fs := flag.NewFlagSet("run", flag.ExitOnError)
gateway := fs.String("gateway", env("LOADTEST_GATEWAY_URL", "http://gateway:8081"), "gateway base URL")
dsn := fs.String("dsn", env("LOADTEST_DSN", defaultDSN()), "backend Postgres DSN")
dawgDir := fs.String("dawg", env("LOADTEST_DAWG_DIR", "/dawg"), "directory holding the committed *.dawg files")
durable := fs.Int("durable", 10000, "durable accounts to seed")
guest := fs.Int("guest", 1000, "guest accounts to seed")
stepsStr := fs.String("steps", "50,200,500", "comma-separated concurrent-player ramp steps")
stepDur := fs.Duration("step-dur", 12*time.Minute, "hold time per ramp step")
gpp := fs.Int("games-per-player", 0, "target concurrent games per player (0 => random 3..5)")
tick := fs.Duration("tick", 800*time.Millisecond, "per-player operation cadence")
secProb := fs.Float64("secondary-prob", 0.08, "chance per tick of a non-move operation")
hammerWorkers := fs.Int("hammer-workers", 20, "gateway-hammer concurrent callers (0 disables)")
hammerDur := fs.Duration("hammer-dur", 15*time.Second, "gateway-hammer duration")
reset := fs.Bool("reset", false, "delete prior harness rows before seeding")
doCleanup := fs.Bool("cleanup", false, "delete harness rows after the run")
if err := fs.Parse(args); err != nil {
return err
}
steps, err := parseSteps(*stepsStr)
if err != nil {
return err
}
reg, err := moves.Open(*dawgDir)
if err != nil {
return err
}
defer reg.Close()
sd, err := seed.New(ctx, *dsn)
if err != nil {
return err
}
defer sd.Close()
if *reset {
n, err := sd.Cleanup(ctx)
if err != nil {
return err
}
log.Info("reset", "accounts_removed", n)
}
log.Info("seeding", "durable", *durable, "guest", *guest)
pool, err := sd.Seed(ctx, *durable, *guest)
if err != nil {
return err
}
log.Info("seeded", "durable", len(pool.Durables), "guest", len(pool.Guests))
rec := report.New()
drv := scenario.NewDriver(*gateway, reg, rec, log)
cfg := scenario.RealisticConfig{
Steps: steps, StepDur: *stepDur, GamesPerPlayer: *gpp,
Tick: *tick, SecondaryProb: *secProb,
}
if err := drv.RunRealistic(ctx, pool, cfg); err != nil && !errors.Is(err, context.Canceled) {
return err
}
if *hammerWorkers > 0 && ctx.Err() == nil && len(pool.Durables) > 0 {
drv.Hammer(ctx, pool.Durables[0], scenario.HammerConfig{Workers: *hammerWorkers, Duration: *hammerDur})
}
fmt.Println("\n==== R2 load-test report ====")
fmt.Println(rec.Summary())
if *doCleanup {
n, err := sd.Cleanup(context.WithoutCancel(ctx))
if err != nil {
return err
}
log.Info("cleanup", "accounts_removed", n)
}
return nil
}
func cmdCleanup(ctx context.Context, log *slog.Logger, args []string) error {
fs := flag.NewFlagSet("cleanup", flag.ExitOnError)
dsn := fs.String("dsn", env("LOADTEST_DSN", defaultDSN()), "backend Postgres DSN")
if err := fs.Parse(args); err != nil {
return err
}
sd, err := seed.New(ctx, *dsn)
if err != nil {
return err
}
defer sd.Close()
n, err := sd.Cleanup(ctx)
if err != nil {
return err
}
log.Info("cleanup", "accounts_removed", n)
return nil
}
// defaultDSN builds the backend Postgres DSN from the standard POSTGRES_* env the
// contour uses, pinning the backend schema.
func defaultDSN() string {
return fmt.Sprintf("postgres://%s:%s@%s:5432/%s?sslmode=disable&search_path=backend",
env("POSTGRES_USER", "scrabble"), os.Getenv("POSTGRES_PASSWORD"),
env("POSTGRES_HOST", "postgres"), env("POSTGRES_DB", "scrabble"))
}
// env returns the environment variable key or def when it is unset/empty.
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// parseSteps parses a comma-separated list of positive ramp step sizes.
func parseSteps(s string) ([]int, error) {
parts := strings.Split(s, ",")
steps := make([]int, 0, len(parts))
for _, p := range parts {
n, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil || n <= 0 {
return nil, fmt.Errorf("invalid ramp steps %q", s)
}
steps = append(steps, n)
}
if len(steps) == 0 {
return nil, fmt.Errorf("no ramp steps")
}
return steps, nil
}