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
+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{}
}