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,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
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, " ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user