R2: load-test harness + contour resource observability
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 3s

New scrabble/loadtest module (the pre-release stress harness): seeds 1000 guest +
10000 durable accounts with pre-created sessions directly in Postgres (token hash
matches backend/internal/session), drives virtual players through the edge protocol
(real 2-4p games assembled via invitations, mid-ranked legal moves generated locally
by the embedded scrabble-solver — the edge carries no board, so the client replays
history), plus nudge/chat/check-word/draft/profile/stats and a gateway-hammer that
verifies the rate limiter. Prints a trip-report summary (per-op latency percentiles,
result codes, live-event tally). Go unit tests cover the pure pieces; the DAWG-backed
move test runs under BACKEND_DICT_DIR.

Contour: add cAdvisor + postgres_exporter + a 'Scrabble - Resources' Grafana
dashboard and the two Prometheus scrape jobs, for the R2/R7 stress-run resource
baseline.

CI: gate ./loadtest/... (path filter + vet/build/test). Docs: TESTING, ARCHITECTURE,
project CLAUDE repo layout.
This commit is contained in:
Ilia Denisov
2026-06-09 23:45:24 +02:00
parent bf3ee62711
commit aa137e3558
27 changed files with 2554 additions and 7 deletions
+43
View File
@@ -0,0 +1,43 @@
# Multi-stage build for the R2 load harness. Mirrors backend/Dockerfile: a
# golang-alpine builder yields a static binary on distroless nonroot, with the
# dictionary DAWGs baked in from the scrabble-dictionary release (the harness runs
# the same solver as the backend, so it needs the same dictionary). The published
# scrabble-solver module is fetched from Gitea (GOPRIVATE), so the build stage needs
# git and network.
#
# The harness is not a contour service; build and run it ad hoc, from the repo root
# so go.work, pkg/, gateway/ and loadtest/ are in the Docker context:
# docker build -f loadtest/Dockerfile -t scrabble-loadtest .
# docker run --rm --network scrabble-internal -e POSTGRES_PASSWORD=... scrabble-loadtest run
# --- dictionary artifact -----------------------------------------------------
FROM alpine:3.20 AS dawg
ARG DICT_VERSION=v1.0.0
RUN apk add --no-cache curl tar
RUN mkdir -p /dawg \
&& curl -fsSL -o /tmp/dawg.tar.gz \
"https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" \
&& tar xzf /tmp/dawg.tar.gz -C /dawg
# --- build -------------------------------------------------------------------
FROM golang:1.26.3-alpine AS build
WORKDIR /src
# git: the published solver module is fetched from Gitea directly (GOPRIVATE).
RUN apk add --no-cache git
ENV GOPRIVATE=gitea.iliadenisov.ru/*
COPY go.work go.work.sum ./
COPY pkg ./pkg
COPY gateway ./gateway
COPY loadtest ./loadtest
# Reduce the workspace to what the harness needs: loadtest + gateway (edge proto) + pkg.
RUN go work edit -dropuse=./backend -dropuse=./platform/telegram
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/loadtest ./loadtest/cmd/loadtest
# --- runtime -----------------------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/loadtest /usr/local/bin/loadtest
COPY --from=dawg /dawg /opt/dawg
ENV LOADTEST_DAWG_DIR=/opt/dawg
ENTRYPOINT ["/usr/local/bin/loadtest"]
+94
View File
@@ -0,0 +1,94 @@
# loadtest — R2 stress harness
Reusable load harness for the pre-release stress pass (`PRERELEASE.md` R2/R7). It
seeds a large account population with pre-created sessions, drives virtual players
through the **gateway edge protocol** in realistic games, hammers the rate limiter,
and prints a trip-report summary. It stays in the repo for repeats.
## What it does
1. **Seed** (direct Postgres, schema `backend`): inserts `--durable` durable accounts
(each with a confirmed email identity) + `--guest` guest accounts and an active
`sessions` row per account, then hands the plaintext bearer tokens to the driver.
Token hashes match `backend/internal/session` (`hex(sha256(token))`), so the seeded
sessions resolve. Every row is tagged with the `lt:` marker for cleanup.
2. **Drive** (edge protocol over h2c): assembles real 24 player games via the
invitation flow (`invitation.create``invitation.accept`, no robots), then runs
each player's turn loop — poll `game.state`, replay `game.history`, generate a legal
**mid-ranked** move with the embedded `scrabble-solver`, and `game.submit_play`
(or pass/exchange). A fraction of turns exercise nudge / chat / check-word / draft /
profile-update / stats. Each player also holds a live `Subscribe` stream. The
moderate ramp is **50 → 200 → 500** concurrent players, ~12 min per step.
3. **Hammer**: drives `games.list` from one account far above the per-user rate limit
to verify the limiter holds (`rate_limited` results) and measure its cost.
4. **Report**: per-operation latency percentiles, throughput, result-code breakdown,
live-event tally and the aggregate error rate.
The driver runs the solver **locally** because the edge protocol carries no board: the
client reconstructs it from decoded history (the same invariant as the UI).
## Connection model
The harness reaches Postgres and the gateway directly, so run it as a one-shot
container on the contour's docker network (this bypasses the host→gateway hairpin):
```sh
# from the repo root
docker build -f loadtest/Dockerfile -t scrabble-loadtest .
docker run --rm --name scrabble-loadtest --network scrabble-internal \
-e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" \
scrabble-loadtest run
```
Defaults assume the contour service names: `postgres:5432` and `gateway:8081`. The
DAWGs are baked into the image (`/opt/dawg`, pinned to the dictionary release). Run with
`--name scrabble-loadtest` so the harness's own CPU/memory show up as a `scrabble-*`
series in cAdvisor (keeping it separable from the system under test). Capture the
resource baseline from the Grafana **Scrabble — Resources** dashboard
(cAdvisor + postgres_exporter) while the run is in progress.
## Commands & flags
```
loadtest run [flags] seed, drive the ramp + hammer, print the report
loadtest cleanup [flags] delete everything the harness seeded (matched by the lt: marker)
```
Key `run` flags (env in parentheses):
| flag | default | meaning |
|------|---------|---------|
| `--gateway` (`LOADTEST_GATEWAY_URL`) | `http://gateway:8081` | gateway base URL |
| `--dsn` (`LOADTEST_DSN`) | from `POSTGRES_*` | backend Postgres DSN (schema `backend`) |
| `--dawg` (`LOADTEST_DAWG_DIR`) | `/dawg` (image: `/opt/dawg`) | committed `*.dawg` directory |
| `--durable` / `--guest` | `10000` / `1000` | accounts to seed |
| `--steps` | `50,200,500` | concurrent-player ramp steps |
| `--step-dur` | `12m` | hold time per step |
| `--games-per-player` | `0` (random 35) | target concurrent games per player |
| `--tick` | `800ms` | per-player op cadence (keeps a player under the per-user limit) |
| `--secondary-prob` | `0.08` | chance per tick of a non-move op |
| `--hammer-workers` / `--hammer-dur` | `20` / `15s` | gateway-hammer (0 workers disables) |
| `--reset` / `--cleanup` | `false` | delete harness rows before / after the run |
`run` re-seeds every time (plaintext tokens are never stored), so pass `--reset` to
clear a prior run's rows first. The authoritative hard reset of the contour remains the
DB wipe (`DROP SCHEMA backend CASCADE` + backend restart).
## Build & test
```sh
go build ./loadtest/...
go vet ./loadtest/...
BACKEND_DICT_DIR=../scrabble-solver/dawg go test -count=1 ./loadtest/...
```
The DAWG-backed `moves` test runs only when `BACKEND_DICT_DIR` is set (as the engine
tests use); the pure logic (hashing, board replay, rack build, move selection, report)
runs unconditionally.
## Caveat
The harness shares the host CPU with the contour, so the early-pass resource baseline
is read with the harness's own container series in mind; a cleaner number on separate
hardware is an R7 goal. The moderate ramp keeps the generator from being the bottleneck.
+193
View File
@@ -0,0 +1,193 @@
// Command loadtest is the R2 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
}
+16
View File
@@ -0,0 +1,16 @@
module scrabble/loadtest
go 1.26.3
require (
connectrpc.com/connect v1.19.2
gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0
github.com/google/flatbuffers v23.5.26+incompatible
github.com/google/uuid v1.6.0
github.com/iliadenisov/dafsa v1.1.0
github.com/jackc/pgx/v5 v5.9.2
golang.org/x/net v0.53.0
google.golang.org/protobuf v1.36.11
scrabble/gateway v0.0.0
scrabble/pkg v0.0.0
)
+136
View File
@@ -0,0 +1,136 @@
// Package edge is the load harness's client of the gateway edge protocol: the
// Connect Execute envelope carrying FlatBuffers payloads, plus the Subscribe live
// stream, over h2c. It exposes typed wrappers for the operations the driver
// exercises, decoding responses into plain Go structs so the scenario layer never
// touches FlatBuffers directly.
package edge
import (
"context"
"crypto/tls"
"net"
"net/http"
"time"
"connectrpc.com/connect"
"golang.org/x/net/http2"
edgev1 "scrabble/gateway/proto/edge/v1"
"scrabble/gateway/proto/edge/v1/edgev1connect"
)
// Message types the driver uses, mirroring gateway/internal/transcode's catalog.
const (
msgSubmitPlay = "game.submit_play"
msgPass = "game.pass"
msgExchange = "game.exchange"
msgState = "game.state"
msgHistory = "game.history"
msgGamesList = "games.list"
msgCheckWord = "game.check_word"
msgNudge = "chat.nudge"
msgChatPost = "chat.post"
msgDraftSave = "draft.save"
msgDraftGet = "draft.get"
msgProfileGet = "profile.get"
msgProfileUpd = "profile.update"
msgStatsGet = "stats.get"
msgInvCreate = "invitation.create"
msgInvAccept = "invitation.accept"
msgInvList = "invitation.list"
msgEnqueue = "lobby.enqueue"
)
// Client speaks the edge protocol to a single gateway base URL over h2c. It is safe
// for concurrent use by many virtual players (the underlying http2.Transport pools
// and multiplexes connections).
type Client struct {
rpc edgev1connect.GatewayClient
}
// New builds a Client for baseURL (for example http://gateway:8081). The transport
// speaks HTTP/2 cleartext (h2c) to match the gateway, dialling plaintext TCP rather
// than TLS.
func New(baseURL string) *Client {
hc := &http.Client{
Transport: &http2.Transport{
AllowHTTP: true,
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
},
},
}
return &Client{rpc: edgev1connect.NewGatewayClient(hc, baseURL)}
}
// Result is the decoded Execute envelope: Code is "ok" or a stable domain error
// code (a non-ok Code is a domain outcome, not a transport failure); Payload is the
// FlatBuffers response body (empty on error).
type Result struct {
Code string
Payload []byte
}
// execute runs one operation as token (empty for an unauthenticated op). A transport
// or connection error is returned as err; a domain rejection is reported in
// Result.Code with a nil err.
func (c *Client) execute(ctx context.Context, token, msgType string, payload []byte) (Result, error) {
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: msgType, Payload: payload})
if token != "" {
req.Header().Set("Authorization", "Bearer "+token)
}
resp, err := c.rpc.Execute(ctx, req)
if err != nil {
return Result{Code: connectCode(err)}, err
}
return Result{Code: resp.Msg.ResultCode, Payload: resp.Msg.Payload}, nil
}
// connectCode renders a transport error as a short code for the report (e.g.
// "rate_limited" for HTTP 429, "unavailable", "deadline"), so the gateway-hammer can
// tally limiter rejections without inspecting full errors.
func connectCode(err error) string {
switch connect.CodeOf(err) {
case connect.CodeResourceExhausted:
return "rate_limited"
case connect.CodeUnauthenticated:
return "unauthenticated"
case connect.CodeUnavailable:
return "unavailable"
case connect.CodeDeadlineExceeded:
return "deadline"
default:
return "transport_error"
}
}
// Event is one decoded live event: its kind and raw FlatBuffers payload (the driver
// reacts to kind alone — your_turn / match_found drive a state fetch).
type Event struct {
Kind string
}
// Subscribe opens the live-event stream as token and invokes onEvent for each event
// until the context is cancelled or the stream ends. It blocks; run it in its own
// goroutine. Stream errors are returned for the caller to count and (optionally)
// reconnect.
func (c *Client) Subscribe(ctx context.Context, token string, onEvent func(Event)) error {
req := connect.NewRequest(&edgev1.SubscribeRequest{})
req.Header().Set("Authorization", "Bearer "+token)
stream, err := c.rpc.Subscribe(ctx, req)
if err != nil {
return err
}
defer stream.Close()
for stream.Receive() {
if onEvent != nil {
onEvent(Event{Kind: stream.Msg().Kind})
}
}
return stream.Err()
}
// pollInterval bounds how often a player re-checks one game's state; exported for the
// scenario's pacing math so a virtual player stays under the per-user rate limit.
const DefaultPollInterval = 3 * time.Second
+186
View File
@@ -0,0 +1,186 @@
package edge
import (
fb "scrabble/pkg/fbs/scrabblefb"
)
// Game is the decoded non-private game summary the driver needs to decide a turn.
type Game struct {
ID string
Variant string
DictVer string
Status string
Players int
ToMove int
MoveCount int
Seats []string // account ids in seat order
}
// Active reports whether the game is still in progress.
func (g Game) Active() bool { return g.Status == "active" }
// SeatOf returns the seat index of accountID, or -1 if it is not seated.
func (g Game) SeatOf(accountID string) int {
for i, id := range g.Seats {
if id == accountID {
return i
}
}
return -1
}
// State is a player's private view: the shared game plus their seat, rack (alphabet
// indices; 255 a blank) and bag size.
type State struct {
Game Game
Seat int
Rack []byte
BagLen int
}
// Tile is one placed tile from a decoded history record (concrete letter, blank flag).
type Tile struct {
Row, Col int
Letter string
Blank bool
}
// Move is one decoded history record (a committed play carries Tiles; pass/exchange
// carry only Action).
type Move struct {
Action string
Dir string
Tiles []Tile
}
// Invitation is the decoded subset the assembler matches on.
type Invitation struct {
ID string
InviterID string
Status string
GameID string
}
func decodeGameView(gv *fb.GameView) Game {
g := Game{
ID: string(gv.Id()),
Variant: string(gv.Variant()),
DictVer: string(gv.DictVersion()),
Status: string(gv.Status()),
Players: int(gv.Players()),
ToMove: int(gv.ToMove()),
MoveCount: int(gv.MoveCount()),
}
n := gv.SeatsLength()
g.Seats = make([]string, n)
var sv fb.SeatView
for j := 0; j < n; j++ {
if gv.Seats(&sv, j) {
g.Seats[sv.Seat()] = string(sv.AccountId())
}
}
return g
}
// decodeState reads a StateView payload.
func decodeState(payload []byte) State {
sv := fb.GetRootAsStateView(payload, 0)
var gv fb.GameView
st := State{
Seat: int(sv.Seat()),
BagLen: int(sv.BagLen()),
Rack: append([]byte(nil), sv.RackBytes()...),
}
if g := sv.Game(&gv); g != nil {
st.Game = decodeGameView(g)
}
return st
}
// decodeHistory reads a History payload into the decoded move journal.
func decodeHistory(payload []byte) []Move {
h := fb.GetRootAsHistory(payload, 0)
n := h.MovesLength()
moves := make([]Move, 0, n)
var mr fb.MoveRecord
for j := 0; j < n; j++ {
if !h.Moves(&mr, j) {
continue
}
m := Move{Action: string(mr.Action()), Dir: string(mr.Dir())}
tn := mr.TilesLength()
m.Tiles = make([]Tile, 0, tn)
var tr fb.TileRecord
for k := 0; k < tn; k++ {
if mr.Tiles(&tr, k) {
m.Tiles = append(m.Tiles, Tile{
Row: int(tr.Row()), Col: int(tr.Col()),
Letter: string(tr.Letter()), Blank: tr.Blank(),
})
}
}
moves = append(moves, m)
}
return moves
}
// decodeMoveResultGame reads a MoveResult payload and returns its post-move game.
func decodeMoveResultGame(payload []byte) Game {
mr := fb.GetRootAsMoveResult(payload, 0)
var gv fb.GameView
if g := mr.Game(&gv); g != nil {
return decodeGameView(g)
}
return Game{}
}
// decodeGameList reads a GameList payload.
func decodeGameList(payload []byte) []Game {
gl := fb.GetRootAsGameList(payload, 0)
n := gl.GamesLength()
games := make([]Game, 0, n)
var gv fb.GameView
for j := 0; j < n; j++ {
if gl.Games(&gv, j) {
games = append(games, decodeGameView(&gv))
}
}
return games
}
// decodeInvitationList reads an InvitationList payload into the matched subset.
func decodeInvitationList(payload []byte) []Invitation {
il := fb.GetRootAsInvitationList(payload, 0)
n := il.InvitationsLength()
out := make([]Invitation, 0, n)
var inv fb.Invitation
var ref fb.AccountRef
for j := 0; j < n; j++ {
if !il.Invitations(&inv, j) {
continue
}
iv := Invitation{
ID: string(inv.Id()),
Status: string(inv.Status()),
GameID: string(inv.GameId()),
}
if r := inv.Inviter(&ref); r != nil {
iv.InviterID = string(r.AccountId())
}
out = append(out, iv)
}
return out
}
// decodeMatch reads a MatchResult payload.
func decodeMatch(payload []byte) (matched bool, game Game) {
mr := fb.GetRootAsMatchResult(payload, 0)
if !mr.Matched() {
return false, Game{}
}
var gv fb.GameView
if g := mr.Game(&gv); g != nil {
return true, decodeGameView(g)
}
return true, Game{}
}
+184
View File
@@ -0,0 +1,184 @@
package edge
import (
flatbuffers "github.com/google/flatbuffers/go"
fb "scrabble/pkg/fbs/scrabblefb"
)
// PlayTile is one tile to place, addressed by alphabet index (255 marks a blank's
// carrier letter together with Blank=true), as the submit-play request carries it.
type PlayTile struct {
Row, Col int
Letter byte
Blank bool
}
// gameAction builds a GameActionRequest payload (just a game id): pass, nudge,
// history, draft.get.
func gameAction(gameID string) []byte {
b := flatbuffers.NewBuilder(64)
gid := b.CreateString(gameID)
fb.GameActionRequestStart(b)
fb.GameActionRequestAddGameId(b, gid)
b.Finish(fb.GameActionRequestEnd(b))
return b.FinishedBytes()
}
// stateReq builds a StateRequest payload. includeAlphabet asks the backend to embed
// the variant alphabet table (the driver sets it once per variant).
func stateReq(gameID string, includeAlphabet bool) []byte {
b := flatbuffers.NewBuilder(64)
gid := b.CreateString(gameID)
fb.StateRequestStart(b)
fb.StateRequestAddGameId(b, gid)
fb.StateRequestAddIncludeAlphabet(b, includeAlphabet)
b.Finish(fb.StateRequestEnd(b))
return b.FinishedBytes()
}
// submitPlay builds a SubmitPlayRequest payload. dir is "H" or "V"; tiles are the
// newly-placed tiles in main-word order.
func submitPlay(gameID, dir string, tiles []PlayTile) []byte {
b := flatbuffers.NewBuilder(256)
gid := b.CreateString(gameID)
d := b.CreateString(dir)
offs := make([]flatbuffers.UOffsetT, len(tiles))
for i, t := range tiles {
fb.PlayTileStart(b)
fb.PlayTileAddRow(b, int32(t.Row))
fb.PlayTileAddCol(b, int32(t.Col))
fb.PlayTileAddLetter(b, t.Letter)
fb.PlayTileAddBlank(b, t.Blank)
offs[i] = fb.PlayTileEnd(b)
}
fb.SubmitPlayRequestStartTilesVector(b, len(offs))
for i := len(offs) - 1; i >= 0; i-- {
b.PrependUOffsetT(offs[i])
}
tilesVec := b.EndVector(len(offs))
fb.SubmitPlayRequestStart(b)
fb.SubmitPlayRequestAddGameId(b, gid)
fb.SubmitPlayRequestAddDir(b, d)
fb.SubmitPlayRequestAddTiles(b, tilesVec)
b.Finish(fb.SubmitPlayRequestEnd(b))
return b.FinishedBytes()
}
// exchange builds an ExchangeRequest payload swapping the listed rack tiles (alphabet
// indices; 255 a blank).
func exchange(gameID string, tiles []byte) []byte {
b := flatbuffers.NewBuilder(64)
gid := b.CreateString(gameID)
vec := b.CreateByteVector(tiles)
fb.ExchangeRequestStart(b)
fb.ExchangeRequestAddGameId(b, gid)
fb.ExchangeRequestAddTiles(b, vec)
b.Finish(fb.ExchangeRequestEnd(b))
return b.FinishedBytes()
}
// checkWord builds a CheckWordRequest payload (alphabet indices for the word).
func checkWord(gameID string, word []byte) []byte {
b := flatbuffers.NewBuilder(64)
gid := b.CreateString(gameID)
vec := b.CreateByteVector(word)
fb.CheckWordRequestStart(b)
fb.CheckWordRequestAddGameId(b, gid)
fb.CheckWordRequestAddWord(b, vec)
b.Finish(fb.CheckWordRequestEnd(b))
return b.FinishedBytes()
}
// chatPost builds a ChatPostRequest payload.
func chatPost(gameID, body string) []byte {
b := flatbuffers.NewBuilder(128)
gid := b.CreateString(gameID)
bd := b.CreateString(body)
fb.ChatPostRequestStart(b)
fb.ChatPostRequestAddGameId(b, gid)
fb.ChatPostRequestAddBody(b, bd)
b.Finish(fb.ChatPostRequestEnd(b))
return b.FinishedBytes()
}
// draftSave builds a DraftRequest payload carrying the opaque composition JSON.
func draftSave(gameID, jsonStr string) []byte {
b := flatbuffers.NewBuilder(128)
gid := b.CreateString(gameID)
j := b.CreateString(jsonStr)
fb.DraftRequestStart(b)
fb.DraftRequestAddGameId(b, gid)
fb.DraftRequestAddJson(b, j)
b.Finish(fb.DraftRequestEnd(b))
return b.FinishedBytes()
}
// updateProfile builds an UpdateProfileRequest payload. It resends the marker display
// name and sane defaults so the account stays findable by the seeder's Cleanup.
func updateProfile(displayName, lang string) []byte {
b := flatbuffers.NewBuilder(192)
name := b.CreateString(displayName)
pl := b.CreateString(lang)
tz := b.CreateString("UTC")
as := b.CreateString("00:00")
ae := b.CreateString("07:00")
fb.UpdateProfileRequestStart(b)
fb.UpdateProfileRequestAddDisplayName(b, name)
fb.UpdateProfileRequestAddPreferredLanguage(b, pl)
fb.UpdateProfileRequestAddTimeZone(b, tz)
fb.UpdateProfileRequestAddAwayStart(b, as)
fb.UpdateProfileRequestAddAwayEnd(b, ae)
fb.UpdateProfileRequestAddBlockChat(b, false)
fb.UpdateProfileRequestAddBlockFriendRequests(b, false)
fb.UpdateProfileRequestAddNotificationsInAppOnly(b, true)
b.Finish(fb.UpdateProfileRequestEnd(b))
return b.FinishedBytes()
}
// createInvitation builds a CreateInvitationRequest payload. turnTimeoutSecs 0 asks
// the backend for its default; dropoutTiles "remove" is the standard policy.
func createInvitation(inviteeIDs []string, variant string, turnTimeoutSecs int) []byte {
b := flatbuffers.NewBuilder(256)
idOffs := make([]flatbuffers.UOffsetT, len(inviteeIDs))
for i, id := range inviteeIDs {
idOffs[i] = b.CreateString(id)
}
fb.CreateInvitationRequestStartInviteeIdsVector(b, len(idOffs))
for i := len(idOffs) - 1; i >= 0; i-- {
b.PrependUOffsetT(idOffs[i])
}
ids := b.EndVector(len(idOffs))
variantOff := b.CreateString(variant)
dropout := b.CreateString("remove")
fb.CreateInvitationRequestStart(b)
fb.CreateInvitationRequestAddInviteeIds(b, ids)
fb.CreateInvitationRequestAddVariant(b, variantOff)
fb.CreateInvitationRequestAddTurnTimeoutSecs(b, int32(turnTimeoutSecs))
fb.CreateInvitationRequestAddHintsAllowed(b, true)
fb.CreateInvitationRequestAddHintsPerPlayer(b, 1)
fb.CreateInvitationRequestAddDropoutTiles(b, dropout)
b.Finish(fb.CreateInvitationRequestEnd(b))
return b.FinishedBytes()
}
// invitationAction builds an InvitationActionRequest payload (accept / decline /
// cancel by id).
func invitationAction(invitationID string) []byte {
b := flatbuffers.NewBuilder(64)
id := b.CreateString(invitationID)
fb.InvitationActionRequestStart(b)
fb.InvitationActionRequestAddInvitationId(b, id)
b.Finish(fb.InvitationActionRequestEnd(b))
return b.FinishedBytes()
}
// enqueueReq builds an EnqueueRequest payload (join the per-variant auto-match pool).
func enqueueReq(variant string) []byte {
b := flatbuffers.NewBuilder(64)
v := b.CreateString(variant)
fb.EnqueueRequestStart(b)
fb.EnqueueRequestAddVariant(b, v)
b.Finish(fb.EnqueueRequestEnd(b))
return b.FinishedBytes()
}
+136
View File
@@ -0,0 +1,136 @@
package edge
import "context"
// The typed operations below each build a request, run Execute and decode the
// response. They return the decoded value (where any), the domain result code
// ("ok" or a stable error code) and a transport error. The scenario layer times the
// call and records the code; a non-"ok" code with a nil error is a domain rejection
// (for example "not_your_turn"), not a failure of the harness.
// State fetches the caller's private view of a game.
func (c *Client) State(ctx context.Context, token, gameID string) (State, string, error) {
r, err := c.execute(ctx, token, msgState, stateReq(gameID, false))
if err != nil || r.Code != "ok" {
return State{}, r.Code, err
}
return decodeState(r.Payload), r.Code, nil
}
// History fetches a game's decoded move journal (the board-replay source).
func (c *Client) History(ctx context.Context, token, gameID string) ([]Move, string, error) {
r, err := c.execute(ctx, token, msgHistory, gameAction(gameID))
if err != nil || r.Code != "ok" {
return nil, r.Code, err
}
return decodeHistory(r.Payload), r.Code, nil
}
// SubmitPlay commits a play and returns the post-move game.
func (c *Client) SubmitPlay(ctx context.Context, token, gameID, dir string, tiles []PlayTile) (Game, string, error) {
r, err := c.execute(ctx, token, msgSubmitPlay, submitPlay(gameID, dir, tiles))
if err != nil || r.Code != "ok" {
return Game{}, r.Code, err
}
return decodeMoveResultGame(r.Payload), r.Code, nil
}
// Pass forfeits the turn and returns the post-move game.
func (c *Client) Pass(ctx context.Context, token, gameID string) (Game, string, error) {
r, err := c.execute(ctx, token, msgPass, gameAction(gameID))
if err != nil || r.Code != "ok" {
return Game{}, r.Code, err
}
return decodeMoveResultGame(r.Payload), r.Code, nil
}
// Exchange swaps the listed rack tiles and returns the post-move game.
func (c *Client) Exchange(ctx context.Context, token, gameID string, tiles []byte) (Game, string, error) {
r, err := c.execute(ctx, token, msgExchange, exchange(gameID, tiles))
if err != nil || r.Code != "ok" {
return Game{}, r.Code, err
}
return decodeMoveResultGame(r.Payload), r.Code, nil
}
// Nudge prods the opponent whose turn it is.
func (c *Client) Nudge(ctx context.Context, token, gameID string) (string, error) {
r, err := c.execute(ctx, token, msgNudge, gameAction(gameID))
return r.Code, err
}
// ChatPost posts a per-game chat line.
func (c *Client) ChatPost(ctx context.Context, token, gameID, body string) (string, error) {
r, err := c.execute(ctx, token, msgChatPost, chatPost(gameID, body))
return r.Code, err
}
// CheckWord looks a word up in the game's pinned dictionary.
func (c *Client) CheckWord(ctx context.Context, token, gameID string, word []byte) (string, error) {
r, err := c.execute(ctx, token, msgCheckWord, checkWord(gameID, word))
return r.Code, err
}
// DraftSave stores the player's client-side composition.
func (c *Client) DraftSave(ctx context.Context, token, gameID, jsonStr string) (string, error) {
r, err := c.execute(ctx, token, msgDraftSave, draftSave(gameID, jsonStr))
return r.Code, err
}
// DraftGet retrieves the player's stored composition.
func (c *Client) DraftGet(ctx context.Context, token, gameID string) (string, error) {
r, err := c.execute(ctx, token, msgDraftGet, gameAction(gameID))
return r.Code, err
}
// ProfileUpdate overwrites the profile, resending the marker display name.
func (c *Client) ProfileUpdate(ctx context.Context, token, displayName, lang string) (string, error) {
r, err := c.execute(ctx, token, msgProfileUpd, updateProfile(displayName, lang))
return r.Code, err
}
// Stats reads the caller's lifetime statistics.
func (c *Client) Stats(ctx context.Context, token string) (string, error) {
r, err := c.execute(ctx, token, msgStatsGet, nil)
return r.Code, err
}
// GamesList lists the caller's games (active and finished).
func (c *Client) GamesList(ctx context.Context, token string) ([]Game, string, error) {
r, err := c.execute(ctx, token, msgGamesList, nil)
if err != nil || r.Code != "ok" {
return nil, r.Code, err
}
return decodeGameList(r.Payload), r.Code, nil
}
// CreateInvitation proposes a 2-4 player friend game to the named invitees.
func (c *Client) CreateInvitation(ctx context.Context, token string, inviteeIDs []string, variant string) (string, error) {
r, err := c.execute(ctx, token, msgInvCreate, createInvitation(inviteeIDs, variant, 0))
return r.Code, err
}
// AcceptInvitation accepts an invitation by id (the completing accept starts the game).
func (c *Client) AcceptInvitation(ctx context.Context, token, invitationID string) (string, error) {
r, err := c.execute(ctx, token, msgInvAccept, invitationAction(invitationID))
return r.Code, err
}
// ListInvitations lists the caller's open invitations.
func (c *Client) ListInvitations(ctx context.Context, token string) ([]Invitation, string, error) {
r, err := c.execute(ctx, token, msgInvList, nil)
if err != nil || r.Code != "ok" {
return nil, r.Code, err
}
return decodeInvitationList(r.Payload), r.Code, nil
}
// Enqueue joins the per-variant auto-match pool and reports any immediate pairing.
func (c *Client) Enqueue(ctx context.Context, token, variant string) (bool, Game, string, error) {
r, err := c.execute(ctx, token, msgEnqueue, enqueueReq(variant))
if err != nil || r.Code != "ok" {
return false, Game{}, r.Code, err
}
matched, game := decodeMatch(r.Payload)
return matched, game, r.Code, nil
}
+186
View File
@@ -0,0 +1,186 @@
// Package moves turns a game's public history and the caller's private rack into a
// legal turn, by reconstructing the board and running the embedded scrabble-solver
// locally (the edge protocol carries no board — the client replays history). It
// picks a mid-ranked move so games progress realistically rather than optimally.
package moves
import (
"fmt"
"math/rand"
"path/filepath"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
dawg "github.com/iliadenisov/dafsa"
"scrabble/loadtest/internal/edge"
)
// blankIndex is the rack/exchange sentinel for a blank tile on the wire (Stage 13).
const blankIndex = 255
// variantSpec maps an edge variant label to its ruleset constructor and committed
// DAWG filename (the descriptive names kept by R1).
type variantSpec struct {
ruleset func() *rules.Ruleset
dawg string
}
var specs = map[string]variantSpec{
"scrabble_en": {rules.English, "en_sowpods.dawg"},
"scrabble_ru": {rules.RussianScrabble, "ru_scrabble.dawg"},
"erudit_ru": {rules.Erudit, "ru_erudit.dawg"},
}
// Variants returns the edge variant labels the harness drives, in catalogue order.
func Variants() []string { return []string{"scrabble_en", "scrabble_ru", "erudit_ru"} }
// engine is one loaded variant: its ruleset and a solver over its DAWG.
type engine struct {
rs *rules.Ruleset
finder dawg.Finder
solver *scrabble.Solver
}
// Registry holds a solver per variant, built from the committed DAWGs in dir. It is
// safe for concurrent use: every Pick builds its own board and rack, and the solver
// holds only read-only state (the same way the backend shares one solver per variant
// across concurrent games).
type Registry struct {
engines map[string]*engine
}
// Open loads every variant's DAWG from dir and builds a solver over each. dir holds
// the committed dawg files (the sibling scrabble-solver checkout's dawg/, or the
// dictionary release artifact).
func Open(dir string) (*Registry, error) {
r := &Registry{engines: make(map[string]*engine)}
for label, spec := range specs {
rs := spec.ruleset()
finder, err := dawg.Load(filepath.Join(dir, spec.dawg))
if err != nil {
r.Close()
return nil, fmt.Errorf("moves: load %s dawg %s from %s: %w", label, spec.dawg, dir, err)
}
r.engines[label] = &engine{rs: rs, finder: finder, solver: scrabble.NewSolver(rs, finder)}
}
return r, nil
}
// Close releases every loaded DAWG.
func (r *Registry) Close() {
for _, e := range r.engines {
if e.finder != nil {
_ = e.finder.Close()
}
}
}
// Action is a chosen turn. Kind is "play", "exchange" or "pass". A play carries Dir
// ("H"/"V") and Tiles; an exchange carries Exchange (rack indices to swap).
type Action struct {
Kind string
Dir string
Tiles []edge.PlayTile
Exchange []byte
}
// Pick reconstructs the board for variant from history, builds the rack from the
// alphabet-index rack, generates the legal plays and returns a mid-ranked one. With
// no legal play it exchanges (when the bag holds a full rack) or passes. rng makes
// the choice deterministic per caller; pass each virtual player its own *rand.Rand
// (rand.Rand is not safe for concurrent use).
func (r *Registry) Pick(variant string, history []edge.Move, rackIdx []byte, bagLen int, rng *rand.Rand) (Action, error) {
e, ok := r.engines[variant]
if !ok {
return Action{}, fmt.Errorf("moves: unknown variant %q", variant)
}
b, err := replayBoard(e.rs, history)
if err != nil {
return Action{}, err
}
legal := e.solver.GenerateMoves(b, buildRack(e.rs, rackIdx), scrabble.Both)
if len(legal) == 0 {
return noPlay(rackIdx, bagLen >= e.rs.RackSize), nil
}
m := midRanked(legal, rng)
return Action{Kind: "play", Dir: dirString(m.Dir), Tiles: toPlayTiles(m.Tiles)}, nil
}
// toPlayTiles maps the solver's newly-placed tiles to the edge submit-play tiles
// (addressed by alphabet index, carrying the blank flag).
func toPlayTiles(placements []scrabble.Placement) []edge.PlayTile {
tiles := make([]edge.PlayTile, len(placements))
for i, p := range placements {
tiles[i] = edge.PlayTile{Row: p.Row, Col: p.Col, Letter: p.Letter, Blank: p.Blank}
}
return tiles
}
// replayBoard mirrors backend engine.ReplayBoard using only the solver's public API:
// each play record's letters are re-indexed through the alphabet and applied to an
// empty board. Non-play records are ignored.
func replayBoard(rs *rules.Ruleset, history []edge.Move) (*board.Board, error) {
b := board.New(rs.Rows, rs.Cols)
for _, rec := range history {
if rec.Action != "play" {
continue
}
ps := make([]scrabble.Placement, len(rec.Tiles))
for i, t := range rec.Tiles {
idx, err := rs.Alphabet.Index(t.Letter)
if err != nil {
return nil, fmt.Errorf("moves: replay letter %q at (%d,%d): %w", t.Letter, t.Row, t.Col, err)
}
ps[i] = scrabble.Placement{Row: t.Row, Col: t.Col, Letter: idx, Blank: t.Blank}
}
scrabble.Apply(b, scrabble.Move{Tiles: ps})
}
return b, nil
}
// buildRack turns the alphabet-index rack (255 a blank) into a solver Rack.
func buildRack(rs *rules.Ruleset, rackIdx []byte) rack.Rack {
rk := rack.New(rs.Alphabet.Size())
for _, idx := range rackIdx {
if idx == blankIndex {
rk.AddBlank()
} else {
rk.Add(idx)
}
}
return rk
}
// midRanked returns a move from the middle third of the score-ranked list
// (GenerateMoves returns highest-first), spreading the pick within that band with
// rng. A tiny list yields its lowest-scoring move.
func midRanked(moves []scrabble.Move, rng *rand.Rand) scrabble.Move {
n := len(moves)
if n <= 2 {
return moves[n-1]
}
lo, hi := n/3, 2*n/3
if hi <= lo {
hi = lo + 1
}
return moves[lo+rng.Intn(hi-lo)]
}
// noPlay chooses an exchange (when the bag can refill a full rack) or a pass.
func noPlay(rackIdx []byte, canExchange bool) Action {
if canExchange && len(rackIdx) > 0 {
return Action{Kind: "exchange", Exchange: append([]byte(nil), rackIdx...)}
}
return Action{Kind: "pass"}
}
// dirString renders a solver direction as the "H"/"V" the edge submit-play expects.
func dirString(d scrabble.Direction) string {
if d == scrabble.Vertical {
return "V"
}
return "H"
}
+157
View File
@@ -0,0 +1,157 @@
package moves
import (
"math/rand"
"os"
"strings"
"testing"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
"scrabble/loadtest/internal/edge"
)
// TestReplayBoardMatchesParse checks that replaying decoded history reproduces the
// exact board (positions, letters and blank flags) that board.Parse builds from the
// equivalent text grid, and that non-play records are ignored.
func TestReplayBoardMatchesParse(t *testing.T) {
rs := rules.English()
history := []edge.Move{
{Action: "pass"}, // must be ignored
{Action: "play", Tiles: []edge.Tile{
{Row: 7, Col: 7, Letter: "c"},
{Row: 7, Col: 8, Letter: "a"},
{Row: 7, Col: 9, Letter: "t"},
}},
{Action: "play", Tiles: []edge.Tile{
{Row: 7, Col: 10, Letter: "s", Blank: true}, // a blank standing for s
}},
}
got, err := replayBoard(rs, history)
if err != nil {
t.Fatalf("replayBoard: %v", err)
}
rows := make([]string, rs.Rows)
for i := range rows {
rows[i] = strings.Repeat(".", rs.Cols)
}
// row 7: cols 0-6 empty, cat at 7-9, an uppercase S (blank) at 10.
rows[7] = strings.Repeat(".", 7) + "cat" + "S" + strings.Repeat(".", rs.Cols-11)
want, err := board.Parse(rows, rs.Alphabet)
if err != nil {
t.Fatalf("board.Parse: %v", err)
}
for r := 0; r < rs.Rows; r++ {
for c := 0; c < rs.Cols; c++ {
if got.At(r, c) != want.At(r, c) {
t.Fatalf("cell (%d,%d): replay = %#x, parse = %#x", r, c, got.At(r, c), want.At(r, c))
}
}
}
}
// TestBuildRack checks the alphabet-index rack (255 a blank) is reconstructed faithfully.
func TestBuildRack(t *testing.T) {
rs := rules.English()
rk := buildRack(rs, []byte{0, 0, 2, blankIndex}) // a a c blank
if rk.Count(0) != 2 {
t.Errorf("count(a) = %d, want 2", rk.Count(0))
}
if rk.Count(2) != 1 {
t.Errorf("count(c) = %d, want 1", rk.Count(2))
}
if rk.Blanks() != 1 {
t.Errorf("blanks = %d, want 1", rk.Blanks())
}
if rk.Total() != 4 {
t.Errorf("total = %d, want 4", rk.Total())
}
}
// TestMidRanked checks the pick always lands in the middle third of a ranked list and
// that tiny lists yield their lowest-scoring move.
func TestMidRanked(t *testing.T) {
ms := make([]scrabble.Move, 9) // scores 100..92, index i has score 100-i
for i := range ms {
ms[i] = scrabble.Move{Score: 100 - i}
}
rng := rand.New(rand.NewSource(1))
for n := 0; n < 100; n++ {
idx := 100 - midRanked(ms, rng).Score // recover the index from the score
if idx < 3 || idx >= 6 {
t.Fatalf("picked index %d outside middle third [3,6)", idx)
}
}
if got := midRanked([]scrabble.Move{{Score: 5}}, rng).Score; got != 5 {
t.Errorf("n=1 pick score = %d, want 5", got)
}
if got := midRanked([]scrabble.Move{{Score: 9}, {Score: 4}}, rng).Score; got != 4 {
t.Errorf("n=2 pick score = %d, want 4 (lower-scoring)", got)
}
}
// TestToPlayTiles checks the solver-placement to edge-tile mapping, including blanks.
func TestToPlayTiles(t *testing.T) {
tiles := toPlayTiles([]scrabble.Placement{
{Row: 1, Col: 2, Letter: 5},
{Row: 1, Col: 3, Letter: 255, Blank: true},
})
want := []edge.PlayTile{
{Row: 1, Col: 2, Letter: 5},
{Row: 1, Col: 3, Letter: 255, Blank: true},
}
if len(tiles) != len(want) {
t.Fatalf("len = %d, want %d", len(tiles), len(want))
}
for i := range want {
if tiles[i] != want[i] {
t.Errorf("tile %d = %+v, want %+v", i, tiles[i], want[i])
}
}
}
// TestPickUnknownVariant rejects a variant the registry does not hold.
func TestPickUnknownVariant(t *testing.T) {
reg := &Registry{engines: map[string]*engine{}}
if _, err := reg.Pick("nope", nil, nil, 0, rand.New(rand.NewSource(1))); err == nil {
t.Fatal("want error for an unknown variant")
}
}
// TestPickWithDawg drives the full path against the committed DAWGs when they are
// available (BACKEND_DICT_DIR, as the engine tests use); it generates a first-move
// play from a productive rack.
func TestPickWithDawg(t *testing.T) {
dir := os.Getenv("BACKEND_DICT_DIR")
if dir == "" {
t.Skip("BACKEND_DICT_DIR not set; skipping DAWG-backed test")
}
reg, err := Open(dir)
if err != nil {
t.Fatalf("Open(%s): %v", dir, err)
}
defer reg.Close()
rng := rand.New(rand.NewSource(1))
rack := []byte{2, 0, 19, 18, 4, 17, 13} // c a t s e r n — a productive English rack
act, err := reg.Pick("scrabble_en", nil, rack, 90, rng)
if err != nil {
t.Fatalf("Pick: %v", err)
}
switch act.Kind {
case "play":
if len(act.Tiles) == 0 {
t.Error("play action has no tiles")
}
if act.Dir != "H" && act.Dir != "V" {
t.Errorf("dir = %q, want H or V", act.Dir)
}
case "exchange", "pass":
// acceptable when the rack has no legal first move
default:
t.Errorf("unexpected action kind %q", act.Kind)
}
}
+204
View File
@@ -0,0 +1,204 @@
// Package report collects per-operation latency, result-code and live-event counts
// across all virtual players and renders a text summary for the R2 trip report. It
// is safe for concurrent use. Latencies go into fixed buckets (a Prometheus-style
// histogram) so percentiles cost no per-sample memory at load-test scale.
package report
import (
"fmt"
"sort"
"strings"
"sync"
"time"
)
// boundsMs are the upper bounds (milliseconds) of the latency histogram buckets; a
// trailing overflow bucket catches anything slower.
var boundsMs = []float64{1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000}
type opStat struct {
count int64
sumNs int64
maxNs int64
codes map[string]int64
buckets []int64 // len(boundsMs)+1; last is the overflow bucket
}
func newOpStat() *opStat {
return &opStat{codes: map[string]int64{}, buckets: make([]int64, len(boundsMs)+1)}
}
func (s *opStat) record(code string, d time.Duration) {
s.count++
s.sumNs += int64(d)
if int64(d) > s.maxNs {
s.maxNs = int64(d)
}
s.codes[code]++
ms := float64(d) / float64(time.Millisecond)
i := sort.SearchFloat64s(boundsMs, ms)
s.buckets[i]++
}
// quantile estimates the q-th percentile (0<q<1) as the upper bound of the bucket
// the q-th sample falls in; the overflow bucket renders as ">5000".
func (s *opStat) quantile(q float64) string {
if s.count == 0 {
return "-"
}
target := int64(q*float64(s.count) + 0.5)
if target < 1 {
target = 1
}
var cum int64
for i, n := range s.buckets {
cum += n
if cum >= target {
if i == len(boundsMs) {
return ">5000"
}
return fmt.Sprintf("%g", boundsMs[i])
}
}
return ">5000"
}
// Recorder accumulates the run's measurements.
type Recorder struct {
mu sync.Mutex
ops map[string]*opStat
events map[string]int64
streamErrs int64
start time.Time
}
// New returns an empty Recorder with the run clock started.
func New() *Recorder {
return &Recorder{ops: map[string]*opStat{}, events: map[string]int64{}, start: time.Now()}
}
// Record logs one operation call: its name, domain/transport code and latency.
func (r *Recorder) Record(op, code string, d time.Duration) {
r.mu.Lock()
defer r.mu.Unlock()
s := r.ops[op]
if s == nil {
s = newOpStat()
r.ops[op] = s
}
s.record(code, d)
}
// Event logs one received live event of the given kind.
func (r *Recorder) Event(kind string) {
r.mu.Lock()
defer r.mu.Unlock()
r.events[kind]++
}
// StreamErr logs one Subscribe stream error (a drop the player reconnects from).
func (r *Recorder) StreamErr() {
r.mu.Lock()
defer r.mu.Unlock()
r.streamErrs++
}
// Totals returns the aggregate call count and the count of non-"ok" results, for the
// pass/fail summary.
func (r *Recorder) Totals() (calls, nonOK int64) {
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range r.ops {
calls += s.count
for code, n := range s.codes {
if code != "ok" {
nonOK += n
}
}
}
return calls, nonOK
}
// Summary renders the human-readable run report: a per-operation table (count,
// throughput, p50/p90/p99/max latency, code breakdown), the live-event tally and the
// aggregate error rate.
func (r *Recorder) Summary() string {
r.mu.Lock()
defer r.mu.Unlock()
elapsed := time.Since(r.start).Seconds()
if elapsed <= 0 {
elapsed = 1
}
names := make([]string, 0, len(r.ops))
for op := range r.ops {
names = append(names, op)
}
sort.Strings(names)
var b strings.Builder
fmt.Fprintf(&b, "elapsed: %.0fs\n\n", elapsed)
fmt.Fprintf(&b, "%-20s %8s %8s %7s %7s %7s %7s %s\n",
"operation", "count", "req/s", "p50ms", "p90ms", "p99ms", "maxms", "codes")
var totalCalls, totalNonOK int64
for _, op := range names {
s := r.ops[op]
totalCalls += s.count
var nonOK int64
for code, n := range s.codes {
if code != "ok" {
nonOK += n
}
}
totalNonOK += nonOK
fmt.Fprintf(&b, "%-20s %8d %8.1f %7s %7s %7s %7.0f %s\n",
op, s.count, float64(s.count)/elapsed,
s.quantile(0.50), s.quantile(0.90), s.quantile(0.99),
float64(s.maxNs)/float64(time.Millisecond), codeBreakdown(s.codes))
}
fmt.Fprintf(&b, "\ntotal calls: %d, throughput: %.1f req/s\n", totalCalls, float64(totalCalls)/elapsed)
rate := 0.0
if totalCalls > 0 {
rate = 100 * float64(totalNonOK) / float64(totalCalls)
}
fmt.Fprintf(&b, "non-ok results: %d (%.2f%%)\n", totalNonOK, rate)
if len(r.events) > 0 {
fmt.Fprintf(&b, "\nlive events:\n")
ekeys := make([]string, 0, len(r.events))
for k := range r.events {
ekeys = append(ekeys, k)
}
sort.Strings(ekeys)
for _, k := range ekeys {
fmt.Fprintf(&b, " %-16s %d\n", k, r.events[k])
}
}
fmt.Fprintf(&b, "stream errors: %d\n", r.streamErrs)
return b.String()
}
// codeBreakdown renders a stat's code counts as "ok:1234 not_your_turn:5 ...",
// highest-count first.
func codeBreakdown(codes map[string]int64) string {
type kv struct {
code string
n int64
}
pairs := make([]kv, 0, len(codes))
for c, n := range codes {
pairs = append(pairs, kv{c, n})
}
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].n != pairs[j].n {
return pairs[i].n > pairs[j].n
}
return pairs[i].code < pairs[j].code
})
parts := make([]string, len(pairs))
for i, p := range pairs {
parts[i] = fmt.Sprintf("%s:%d", p.code, p.n)
}
return strings.Join(parts, " ")
}
+53
View File
@@ -0,0 +1,53 @@
package report
import (
"strings"
"testing"
"time"
)
// TestRecorderTotalsAndSummary checks call/error tallying and that the rendered
// summary surfaces operations, codes, events and stream errors.
func TestRecorderTotalsAndSummary(t *testing.T) {
r := New()
r.Record("game.state", "ok", 5*time.Millisecond)
r.Record("game.state", "ok", 7*time.Millisecond)
r.Record("game.submit_play", "not_your_turn", 3*time.Millisecond)
r.Record("hammer:games.list", "rate_limited", time.Millisecond)
r.Event("your_turn")
r.Event("your_turn")
r.StreamErr()
calls, nonOK := r.Totals()
if calls != 4 {
t.Errorf("calls = %d, want 4", calls)
}
if nonOK != 2 {
t.Errorf("nonOK = %d, want 2", nonOK)
}
s := r.Summary()
for _, want := range []string{"game.state", "not_your_turn", "rate_limited", "your_turn", "stream errors: 1"} {
if !strings.Contains(s, want) {
t.Errorf("summary missing %q\n---\n%s", want, s)
}
}
}
// TestOpStatQuantile checks the bucketed percentile estimate lands on the right bucket
// bound.
func TestOpStatQuantile(t *testing.T) {
s := newOpStat()
for i := 0; i < 90; i++ {
s.record("ok", 10*time.Millisecond)
}
for i := 0; i < 10; i++ {
s.record("ok", 1000*time.Millisecond)
}
if got := s.quantile(0.50); got != "10" {
t.Errorf("p50 = %s, want 10", got)
}
if got := s.quantile(0.99); got != "1000" {
t.Errorf("p99 = %s, want 1000", got)
}
}
+170
View File
@@ -0,0 +1,170 @@
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
}
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, 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, 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 := d.edge.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 := d.edge.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 := d.edge.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 := d.edge.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 ""
}
+45
View File
@@ -0,0 +1,45 @@
package scenario
import (
"context"
"sync"
"time"
"scrabble/loadtest/internal/seed"
)
// HammerConfig parameterises the gateway-hammer: how many concurrent callers and for
// how long to deliberately exceed the per-user rate limit from a single account.
type HammerConfig struct {
Workers int
Duration time.Duration
}
// DefaultHammer returns a hammer that comfortably exceeds the 300/min per-user limit.
func DefaultHammer() HammerConfig {
return HammerConfig{Workers: 20, Duration: 15 * time.Second}
}
// Hammer drives games.list from a single account far above the per-user rate limit to
// verify the limiter holds — rejections surface as the "rate_limited" code — and to
// measure its cost. Every call is recorded under "hammer:games.list" so the report
// shows the ok/rate_limited split and the rejection latency separately from the
// realistic traffic.
func (d *Driver) Hammer(ctx context.Context, acc seed.Account, cfg HammerConfig) {
runCtx, cancel := context.WithTimeout(ctx, cfg.Duration)
defer cancel()
d.log.Info("gateway-hammer", "workers", cfg.Workers, "duration", cfg.Duration)
var wg sync.WaitGroup
for w := 0; w < cfg.Workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for runCtx.Err() == nil {
t0 := time.Now()
_, code, _ := d.edge.GamesList(runCtx, acc.Token)
d.rec.Record("hammer:games.list", code, time.Since(t0))
}
}()
}
wg.Wait()
}
+241
View File
@@ -0,0 +1,241 @@
// Package scenario drives virtual players against the gateway edge protocol: it
// assembles real games through the invitation flow, then runs each player's turn
// loop (poll state, replay history, generate a legal move with the embedded solver,
// submit it) plus a fraction of secondary operations. It exposes the moderate
// realistic ramp agreed for the R2 early pass and a separate gateway-hammer.
package scenario
import (
"context"
"log/slog"
"math/rand"
"sync"
"time"
"scrabble/loadtest/internal/edge"
"scrabble/loadtest/internal/moves"
"scrabble/loadtest/internal/report"
"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.
type Driver struct {
edge *edge.Client
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}
}
// RealisticConfig parameterises the under-the-limit ramp.
type RealisticConfig struct {
Steps []int // concurrent active players per step (cumulative)
StepDur time.Duration // hold time per step
GamesPerPlayer int // target concurrent games per player; 0 => random 3..5
Tick time.Duration // per-player operation cadence (keeps a player under the per-user limit)
SecondaryProb float64 // chance per tick of a non-move operation
}
// DefaultRealistic returns the moderate ramp agreed for the R2 early pass: 50 -> 200
// -> 500 concurrent players, ~12 minutes per step, ~1 op/s per player.
func DefaultRealistic() RealisticConfig {
return RealisticConfig{
Steps: []int{50, 200, 500},
StepDur: 12 * time.Minute,
Tick: 800 * time.Millisecond,
SecondaryProb: 0.08,
}
}
// RunRealistic runs the staged ramp. Each step activates more players (drawn from the
// seeded pool), assembles a cohort of games for them and starts their turn loops; the
// loops run until the whole ramp ends. Players from earlier steps keep playing, so
// load is cumulative.
func (d *Driver) RunRealistic(ctx context.Context, pool *seed.Pool, cfg RealisticConfig) error {
players := shuffledPool(pool)
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
activated := 0
for si, target := range cfg.Steps {
if target > len(players) {
target = len(players)
}
cohort := players[activated:target]
activated = target
if len(cohort) >= 2 {
rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(si)))
games := d.assembleCohort(runCtx, cohort, cfg.GamesPerPlayer, rng)
byPlayer := gamesByPlayer(games)
d.log.Info("ramp step", "step", si+1, "active", activated, "cohort", len(cohort), "games", len(games))
for pi := range cohort {
p := cohort[pi]
wg.Add(1)
go func(p seed.Account, pg []*Game, sd int64) {
defer wg.Done()
d.playerLoop(runCtx, p, pg, cfg, rand.New(rand.NewSource(sd)))
}(p, byPlayer[p.ID.String()], time.Now().UnixNano()+int64(pi))
}
} else {
d.log.Warn("ramp step skipped: cohort too small", "step", si+1, "cohort", len(cohort))
}
select {
case <-time.After(cfg.StepDur):
case <-ctx.Done():
cancel()
wg.Wait()
return ctx.Err()
}
}
cancel()
wg.Wait()
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.
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 {
<-ctx.Done()
return
}
ticker := time.NewTicker(cfg.Tick)
defer ticker.Stop()
gi := 0
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
g := games[gi%len(games)]
gi++
if rng.Float64() < cfg.SecondaryProb {
d.secondaryOp(ctx, p, g, rng)
continue
}
d.playTurn(ctx, p, g, rng)
}
}
}
// 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) {
for ctx.Err() == nil {
err := d.edge.Subscribe(ctx, p.Token, func(e edge.Event) { d.rec.Event(e.Kind) })
if ctx.Err() != nil {
return
}
if err != nil {
d.rec.StreamErr()
}
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
}
}
// 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) {
seat := g.seatOf(p.ID.String())
if seat < 0 {
return
}
t0 := time.Now()
st, code, err := d.edge.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
}
t0 = time.Now()
hist, hc, err := d.edge.History(ctx, p.Token, g.ID)
d.rec.Record("game.history", hc, time.Since(t0))
if err != nil || hc != "ok" {
return
}
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
}
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))
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))
default:
t0 = time.Now()
_, c, _ := d.edge.Pass(ctx, p.Token, g.ID)
d.rec.Record("game.pass", c, time.Since(t0))
}
}
// 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) {
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))
case 1:
c, _ := d.edge.ChatPost(ctx, p.Token, g.ID, "gg")
d.rec.Record("chat.post", c, 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))
case 3:
c, _ := d.edge.DraftSave(ctx, p.Token, g.ID, `{"rack_order":[],"board_tiles":[]}`)
d.rec.Record("draft.save", c, time.Since(t0))
case 4:
c, _ := d.edge.DraftGet(ctx, p.Token, g.ID)
d.rec.Record("draft.get", c, 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))
default:
c, _ := d.edge.Stats(ctx, p.Token)
d.rec.Record("stats.get", c, time.Since(t0))
}
}
// shuffledPool returns every seeded account in random order, so an active set is a
// representative mix of durable and guest accounts.
func shuffledPool(pool *seed.Pool) []seed.Account {
all := pool.All()
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
rng.Shuffle(len(all), func(i, j int) { all[i], all[j] = all[j], all[i] })
return all
}
// gamesByPlayer indexes the assembled games by each member's account id.
func gamesByPlayer(games []*Game) map[string][]*Game {
m := make(map[string][]*Game)
for _, g := range games {
for _, mem := range g.Members {
id := mem.ID.String()
m[id] = append(m[id], g)
}
}
return m
}
+177
View File
@@ -0,0 +1,177 @@
package seed
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Marker prefixes every display_name the harness writes. Cleanup matches on it, so
// the harness only ever deletes its own rows and never touches real accounts.
const Marker = "lt:"
// Schema-qualified targets so the seeder does not depend on the connection's
// search_path (the backend pins search_path=backend; we qualify explicitly).
var (
accountsTbl = pgx.Identifier{"backend", "accounts"}
identitiesTbl = pgx.Identifier{"backend", "identities"}
sessionsTbl = pgx.Identifier{"backend", "sessions"}
)
// Account is one seeded player: its account id, marker display name and the
// plaintext bearer token the driver presents in the Authorization header. Guest
// marks a guest (no identity, accrues no statistics). Name is retained so a
// profile.update can resend the marker display name and keep the row findable by
// Cleanup.
type Account struct {
ID uuid.UUID
Name string
Token string
Guest bool
}
// Pool is the seeded population, split by durability.
type Pool struct {
Guests []Account
Durables []Account
}
// All returns every seeded account, durables first.
func (p *Pool) All() []Account {
out := make([]Account, 0, len(p.Durables)+len(p.Guests))
out = append(out, p.Durables...)
out = append(out, p.Guests...)
return out
}
// Seeder writes and removes the harness population over a pgx pool against the
// backend Postgres schema.
type Seeder struct{ pool *pgxpool.Pool }
// New connects to dsn (the backend Postgres) and verifies the connection.
func New(ctx context.Context, dsn string) (*Seeder, error) {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("seed: connect: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("seed: ping: %w", err)
}
return &Seeder{pool: pool}, nil
}
// Close releases the pool.
func (s *Seeder) Close() { s.pool.Close() }
// Seed inserts nDurable durable accounts (each with a confirmed email identity) and
// nGuest guest accounts, an active session per account, and returns the population
// with the plaintext tokens. Rows go in over COPY in foreign-key order (accounts,
// then identities and sessions). Every row carries Marker in its display name /
// external id so Cleanup can find them.
func (s *Seeder) Seed(ctx context.Context, nDurable, nGuest int) (*Pool, error) {
pool := &Pool{
Durables: make([]Account, 0, nDurable),
Guests: make([]Account, 0, nGuest),
}
var acctRows, identRows, sessRows [][]any
add := func(guest bool, i int) error {
aid, err := uuid.NewV7()
if err != nil {
return err
}
sid, err := uuid.NewV7()
if err != nil {
return err
}
token, hash, err := GenerateToken()
if err != nil {
return err
}
lang := "en"
if i%2 == 1 {
lang = "ru"
}
kind := "d"
if guest {
kind = "g"
}
name := fmt.Sprintf("%s%s-%06d", Marker, kind, i)
acctRows = append(acctRows, []any{aid, name, guest, lang})
sessRows = append(sessRows, []any{sid, aid, hash, "active"})
if !guest {
iid, err := uuid.NewV7()
if err != nil {
return err
}
ext := fmt.Sprintf("%s%s@loadtest.invalid", Marker, aid)
identRows = append(identRows, []any{iid, aid, "email", ext, true})
}
acc := Account{ID: aid, Name: name, Token: token, Guest: guest}
if guest {
pool.Guests = append(pool.Guests, acc)
} else {
pool.Durables = append(pool.Durables, acc)
}
return nil
}
for i := 0; i < nDurable; i++ {
if err := add(false, i); err != nil {
return nil, err
}
}
for i := 0; i < nGuest; i++ {
if err := add(true, i); err != nil {
return nil, err
}
}
if _, err := s.pool.CopyFrom(ctx, accountsTbl,
[]string{"account_id", "display_name", "is_guest", "preferred_language"},
pgx.CopyFromRows(acctRows)); err != nil {
return nil, fmt.Errorf("seed: copy accounts: %w", err)
}
if len(identRows) > 0 {
if _, err := s.pool.CopyFrom(ctx, identitiesTbl,
[]string{"identity_id", "account_id", "kind", "external_id", "confirmed"},
pgx.CopyFromRows(identRows)); err != nil {
return nil, fmt.Errorf("seed: copy identities: %w", err)
}
}
if _, err := s.pool.CopyFrom(ctx, sessionsTbl,
[]string{"session_id", "account_id", "token_hash", "status"},
pgx.CopyFromRows(sessRows)); err != nil {
return nil, fmt.Errorf("seed: copy sessions: %w", err)
}
return pool, nil
}
// Cleanup removes everything the harness created: first the games any harness
// account is seated in (cascading game_players / game_moves / complaints / chat),
// then the harness accounts (cascading identities, sessions, stats, invitations,
// drafts and the rest). It is scoped by Marker, so it is safe to run against a
// contour that also holds real data. The authoritative hard reset remains the
// contour DB wipe (DROP SCHEMA backend CASCADE + backend restart). It returns the
// number of accounts removed.
func (s *Seeder) Cleanup(ctx context.Context) (int, error) {
if _, err := s.pool.Exec(ctx, `
DELETE FROM backend.games
WHERE game_id IN (
SELECT p.game_id FROM backend.game_players p
JOIN backend.accounts a ON a.account_id = p.account_id
WHERE a.display_name LIKE $1
)`, Marker+"%"); err != nil {
return 0, fmt.Errorf("seed: cleanup games: %w", err)
}
tag, err := s.pool.Exec(ctx,
`DELETE FROM backend.accounts WHERE display_name LIKE $1`, Marker+"%")
if err != nil {
return 0, fmt.Errorf("seed: cleanup accounts: %w", err)
}
return int(tag.RowsAffected()), nil
}
+33
View File
@@ -0,0 +1,33 @@
// Package seed creates accounts, identities and sessions directly in the backend
// Postgres schema so the load driver can authenticate as many pre-provisioned
// players without paying the per-IP cost of the auth edge operations. It owns the
// inverse operation too (cleanup of everything it created).
package seed
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
// GenerateToken mints an opaque bearer token and its stored hash. token is the
// plaintext handed to the client; hash is what the seeder writes to
// sessions.token_hash. The transformation matches backend/internal/session so a
// resolve of token recomputes the same hash and finds the seeded row.
func GenerateToken() (token, hash string, err error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", "", err
}
token = base64.RawURLEncoding.EncodeToString(buf)
return token, HashToken(token), nil
}
// HashToken returns the hex-encoded SHA-256 of token. It is the exact hash the
// backend session resolver computes (backend/internal/session/token.go), kept in
// lockstep so seeded sessions validate.
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
+44
View File
@@ -0,0 +1,44 @@
package seed
import (
"crypto/sha256"
"encoding/hex"
"testing"
)
// TestHashTokenMatchesSHA256Hex pins HashToken to the exact transformation the
// backend session resolver uses (hex-encoded SHA-256), the invariant that makes a
// seeded session resolve.
func TestHashTokenMatchesSHA256Hex(t *testing.T) {
const token = "an-example-bearer-token"
sum := sha256.Sum256([]byte(token))
want := hex.EncodeToString(sum[:])
if got := HashToken(token); got != want {
t.Fatalf("HashToken(%q) = %s, want %s", token, got, want)
}
}
// TestGenerateTokenRoundTrip checks that a minted token hashes to the stored hash and
// that tokens are unique.
func TestGenerateTokenRoundTrip(t *testing.T) {
token, hash, err := GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if token == "" || hash == "" {
t.Fatal("empty token or hash")
}
if len(hash) != 64 {
t.Fatalf("hash length = %d, want 64 hex chars", len(hash))
}
if got := HashToken(token); got != hash {
t.Fatalf("hash mismatch: GenerateToken returned %s, HashToken(token) = %s", hash, got)
}
token2, _, err := GenerateToken()
if err != nil {
t.Fatalf("GenerateToken (2nd): %v", err)
}
if token2 == token {
t.Fatal("two generated tokens are identical")
}
}