8881214213
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN) references from comments, doc-comments, service READMEs, the current-state docs (ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the .fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage history. - Rename the only stage-named identifiers: registerStage8 -> registerSocialOps, registerStage11 -> registerLinkOps (gateway transcode). - Split stage6_test.go: TestEmailLoginFlow -> email_test.go, TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go. - Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments). go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
194 lines
5.9 KiB
Go
194 lines
5.9 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/edge"
|
|
"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(edge.New(*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
|
|
}
|