ecb21bd218
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m18s
EvaluatePlay (the hottest gameplay call, fired on every tile placement) now uses the warm live-game cache directly: an active game stays cached (mutated in place across moves, evicted only on finish), so the cached engine game and its immutable seat list answer the membership check and the score with no DB read. The cold path (eviction / first load) still loads and validates via the store. The seat list is cached alongside the engine game for the membership fast path. GetGame also folds its two round-trips (game, then seats) into one LEFT JOIN, preserving the contract (same Game, a seatless game still returns empty seats, seat order kept) — one round-trip for every remaining caller. Measured at 500 players: evaluate p99 halves (200 -> 100 ms) and the per-op query count drops. It does NOT cut postgres CPU — that is write-bound (per-move CommitMove plus draft upserts and journal replays), the cheap indexed GetGame reads were never its bottleneck, and postgres runs with headroom (~1.5 of 2 cores). So this is a latency / query-volume optimization, not a DB-CPU one. Regression cover: a non-player evaluate against a warm game asserts the cached-seat membership path; the integration suite exercises GetGame's join across every game op.
141 lines
3.7 KiB
Go
141 lines
3.7 KiB
Go
package game
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/engine"
|
|
)
|
|
|
|
// keyedMutex hands out one mutex per game id, serialising every operation on a
|
|
// single game (engine.Game is not safe for concurrent use) while letting
|
|
// different games proceed in parallel. Locks are reference-counted and removed
|
|
// once no caller holds or awaits them.
|
|
type keyedMutex struct {
|
|
mu sync.Mutex
|
|
locks map[uuid.UUID]*lockRef
|
|
}
|
|
|
|
type lockRef struct {
|
|
mu sync.Mutex
|
|
refs int
|
|
}
|
|
|
|
func newKeyedMutex() *keyedMutex {
|
|
return &keyedMutex{locks: make(map[uuid.UUID]*lockRef)}
|
|
}
|
|
|
|
// lock acquires the mutex for id and returns its release function.
|
|
func (k *keyedMutex) lock(id uuid.UUID) func() {
|
|
k.mu.Lock()
|
|
ref := k.locks[id]
|
|
if ref == nil {
|
|
ref = &lockRef{}
|
|
k.locks[id] = ref
|
|
}
|
|
ref.refs++
|
|
k.mu.Unlock()
|
|
|
|
ref.mu.Lock()
|
|
return func() {
|
|
ref.mu.Unlock()
|
|
k.mu.Lock()
|
|
ref.refs--
|
|
if ref.refs == 0 {
|
|
delete(k.locks, id)
|
|
}
|
|
k.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// gameCache holds live engine.Game values keyed by game id and evicts an entry
|
|
// once it has been idle for ttl. An evicted game is transparently rebuilt from
|
|
// the journal on next access, so eviction never affects correctness. It is safe
|
|
// for concurrent use.
|
|
type gameCache struct {
|
|
mu sync.Mutex
|
|
entries map[uuid.UUID]*cachedGame
|
|
ttl time.Duration
|
|
now func() time.Time
|
|
}
|
|
|
|
type cachedGame struct {
|
|
game *engine.Game
|
|
seats []Seat
|
|
variant string
|
|
lastAccess time.Time
|
|
}
|
|
|
|
func newGameCache(ttl time.Duration, now func() time.Time) *gameCache {
|
|
return &gameCache{entries: make(map[uuid.UUID]*cachedGame), ttl: ttl, now: now}
|
|
}
|
|
|
|
// get returns the live game and its immutable seat list for id and refreshes its idle
|
|
// timer, or (nil, nil, false). The seats let a read check membership (and label seats)
|
|
// without re-loading the game from the store, since seats never change after a game starts.
|
|
func (c *gameCache) get(id uuid.UUID) (*engine.Game, []Seat, bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
e, ok := c.entries[id]
|
|
if !ok {
|
|
return nil, nil, false
|
|
}
|
|
e.lastAccess = c.now()
|
|
return e.game, e.seats, true
|
|
}
|
|
|
|
// put stores g as the live game for id together with its seat list. variant labels the
|
|
// entry so the active-games gauge can report counts by variant without inspecting engine
|
|
// internals; seats are the game's immutable seat standings for the membership fast path.
|
|
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string, seats []Seat) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.entries[id] = &cachedGame{game: g, seats: seats, variant: variant, lastAccess: c.now()}
|
|
}
|
|
|
|
// remove drops id from the cache (used on a finished game and after a failed
|
|
// persist, so the next access rebuilds from the journal).
|
|
func (c *gameCache) remove(id uuid.UUID) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
delete(c.entries, id)
|
|
}
|
|
|
|
// sweep evicts every entry idle longer than ttl and returns how many were
|
|
// dropped.
|
|
func (c *gameCache) sweep() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
cutoff := c.now().Add(-c.ttl)
|
|
var n int
|
|
for id, e := range c.entries {
|
|
if e.lastAccess.Before(cutoff) {
|
|
delete(c.entries, id)
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// size reports the number of resident games (for diagnostics and tests).
|
|
func (c *gameCache) size() int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return len(c.entries)
|
|
}
|
|
|
|
// countByVariant tallies the resident games by their variant label. It backs the
|
|
// game_cache_active observable gauge; the resident set is the bounded number of
|
|
// live (active) games, so the scan under the lock is cheap.
|
|
func (c *gameCache) countByVariant() map[string]int {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
out := make(map[string]int, len(c.entries))
|
|
for _, e := range c.entries {
|
|
out[e.variant]++
|
|
}
|
|
return out
|
|
}
|