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
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:
@@ -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{}
|
||||
}
|
||||
Reference in New Issue
Block a user