// 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 [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") eval := fs.Bool("eval", true, "model the per-tile evaluate preview (the realistic gameplay hot path); --eval=false reproduces the pre-evaluate harness for an A/B baseline") evalRecon := fs.Int("eval-recon", 1, "extra full-composition evaluate re-previews per play (reconsideration), beyond one per placed tile") 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, Eval: *eval, EvalRecon: *evalRecon, } 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==== 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 }