Files
scrabble-game/loadtest/internal/edge/ops.go
T
Ilia Denisov e2771826fd
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
perf(gateway): pool backend conns; loadtest evaluate hot path
The loadtest harness never modelled game.evaluate — the debounced per-tile
play preview a real client fires several times per turn, the hottest gameplay
call. Model it (one evaluate per placed tile + reconsideration re-previews +
draft.save, human-paced; --eval / --eval-recon toggle it).

That realistic load surfaced the real bottleneck: the gateway's backend HTTP
client used the default transport (MaxIdleConnsPerHost=2), so every sync call
to the single backend host churned a fresh TCP connection — ~26500 TIME_WAIT
sockets at 500 players (near the ephemeral-port ceiling), burning ~1.75 gateway
cores while the backend sat near-idle. It was the unfixed root of the residual
transport_error the earlier passes chased on the client side.

Widen the keep-alive pool (backendMaxIdleConns=512, ~2x the observed 225-conn
peak). At 500 players the churn collapses to ~0 and peak gateway CPU drops ~7x
(~1.75 -> ~0.26 cores); postgres (~1.65 cores) becomes the busiest service.
This overturns the earlier "gateway is the binding constraint, scale it
horizontally" sizing — that was sizing around this bug, not a real floor.

Consolidate the loadtest trip reports into one loadtest/REPORT.md (drop the
R2/R7 split) and bake the finding into README / PRERELEASE / ARCHITECTURE /
TESTING.
2026-06-21 19:55:57 +02:00

146 lines
5.9 KiB
Go

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 string, tiles []PlayTile) (Game, string, error) {
r, err := c.execute(ctx, token, msgSubmitPlay, submitPlay(gameID, 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
}
// Evaluate previews a tentative play's legality and score without committing it. It is
// the per-tile composition call a real client fires (debounced) on every change while
// arranging a word, so it is the hottest gameplay request at scale. The harness records
// only the result code and latency; an illegal preview is a successful "ok" call.
func (c *Client) Evaluate(ctx context.Context, token, gameID string, tiles []PlayTile) (string, error) {
r, err := c.execute(ctx, token, msgEvaluate, evalReq(gameID, tiles))
return r.Code, err
}
// 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
}