Files
scrabble-game/loadtest/internal/seed/seed.go
T
Ilia Denisov aa137e3558
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
R2: load-test harness + contour resource observability
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.
2026-06-09 23:45:24 +02:00

178 lines
5.4 KiB
Go

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
}