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