v0.11.0 harden persistence and prepare game replays
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package core
|
||||
|
||||
import "time"
|
||||
|
||||
// Request types
|
||||
|
||||
type CreateGameRequest struct {
|
||||
@@ -27,7 +29,7 @@ type GameResponse struct {
|
||||
GameID string `json:"gameId"`
|
||||
FEN string `json:"fen"`
|
||||
Turn string `json:"turn"` // "w" or "b"
|
||||
State string `json:"state"` // "ongoing", "white_wins", etc
|
||||
State string `json:"state"` // "ongoing", "white wins", etc
|
||||
Moves []string `json:"moves"`
|
||||
Players PlayersResponse `json:"players"`
|
||||
LastMove *MoveInfo `json:"lastMove,omitempty"`
|
||||
@@ -45,8 +47,48 @@ type BoardResponse struct {
|
||||
Board string `json:"board"` // ASCII representation
|
||||
}
|
||||
|
||||
// GameHistoryResponse is the durable replay representation of a game. Moves
|
||||
// are ordered and include the resulting FEN so clients do not need an engine
|
||||
// to replay a stored game.
|
||||
type GameHistoryResponse struct {
|
||||
GameID string `json:"gameId"`
|
||||
InitialFEN string `json:"initialFen"`
|
||||
Result string `json:"result,omitempty"`
|
||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||
Players PlayersResponse `json:"players"`
|
||||
Moves []HistoryMove `json:"moves"`
|
||||
}
|
||||
|
||||
type HistoryMove struct {
|
||||
MoveNumber int `json:"moveNumber"`
|
||||
MoveUCI string `json:"moveUci"`
|
||||
FENAfterMove string `json:"fenAfterMove"`
|
||||
PlayerColor string `json:"playerColor"`
|
||||
MoveTimeUTC time.Time `json:"moveTimeUtc"`
|
||||
}
|
||||
|
||||
// GameSummary is intentionally sufficient for a client-side game picker; the
|
||||
// full move list remains on the per-game history endpoint.
|
||||
type GameSummary struct {
|
||||
GameID string `json:"gameId"`
|
||||
InitialFEN string `json:"initialFen"`
|
||||
Result string `json:"result,omitempty"`
|
||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||
MoveCount int `json:"moveCount"`
|
||||
Players PlayersResponse `json:"players"`
|
||||
}
|
||||
|
||||
type GameListResponse struct {
|
||||
Games []GameSummary `json:"games"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
NextOffset *int `json:"nextOffset,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,17 @@ package core
|
||||
|
||||
// Error codes
|
||||
const (
|
||||
ErrGameNotFound = "GAME_NOT_FOUND"
|
||||
ErrInvalidMove = "INVALID_MOVE"
|
||||
ErrNotHumanTurn = "NOT_HUMAN_TURN"
|
||||
ErrGameOver = "GAME_OVER"
|
||||
ErrRateLimitExceeded = "RATE_LIMIT_EXCEEDED"
|
||||
ErrInvalidContent = "INVALID_CONTENT_TYPE"
|
||||
ErrInvalidRequest = "INVALID_REQUEST"
|
||||
ErrInvalidFEN = "INVALID_FEN"
|
||||
ErrInternalError = "INTERNAL_ERROR"
|
||||
ErrResourceLimit = "RESOURCE_LIMIT"
|
||||
ErrUnauthorized = "UNAUTHORIZED"
|
||||
)
|
||||
ErrGameNotFound = "GAME_NOT_FOUND"
|
||||
ErrInvalidMove = "INVALID_MOVE"
|
||||
ErrNotHumanTurn = "NOT_HUMAN_TURN"
|
||||
ErrGameOver = "GAME_OVER"
|
||||
ErrRateLimitExceeded = "RATE_LIMIT_EXCEEDED"
|
||||
ErrInvalidContent = "INVALID_CONTENT_TYPE"
|
||||
ErrInvalidRequest = "INVALID_REQUEST"
|
||||
ErrInvalidFEN = "INVALID_FEN"
|
||||
ErrInternalError = "INTERNAL_ERROR"
|
||||
ErrResourceLimit = "RESOURCE_LIMIT"
|
||||
ErrUnauthorized = "UNAUTHORIZED"
|
||||
ErrConflict = "GAME_CONFLICT"
|
||||
ErrStorageUnavailable = "STORAGE_UNAVAILABLE"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ type State int
|
||||
const (
|
||||
StateOngoing State = iota
|
||||
StatePending // Computer is calculating a move
|
||||
StateStuck // Computer is calculating a move
|
||||
StateStuck // Engine work failed and requires recovery or undo
|
||||
StateWhiteWins
|
||||
StateBlackWins
|
||||
StateDraw
|
||||
@@ -31,4 +31,31 @@ func (s State) String() string {
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsTerminal reports whether the game has a durable result. Pending and stuck
|
||||
// are operational states and must not be archived as completed games.
|
||||
func (s State) IsTerminal() bool {
|
||||
switch s {
|
||||
case StateWhiteWins, StateBlackWins, StateDraw, StateStalemate:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Result returns the stable persistence/API value for a terminal state.
|
||||
func (s State) Result() (string, bool) {
|
||||
switch s {
|
||||
case StateWhiteWins:
|
||||
return "white_wins", true
|
||||
case StateBlackWins:
|
||||
return "black_wins", true
|
||||
case StateDraw:
|
||||
return "draw", true
|
||||
case StateStalemate:
|
||||
return "stalemate", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
+109
-11
@@ -2,6 +2,7 @@ package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/board"
|
||||
"chess/internal/server/core"
|
||||
@@ -25,20 +26,41 @@ type MoveResult struct {
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
snapshots []Snapshot `json:"snapshots"`
|
||||
players map[core.Color]*core.Player `json:"players"`
|
||||
state core.State `json:"state"`
|
||||
lastResult *MoveResult `json:"lastResult,omitempty"`
|
||||
snapshots []Snapshot
|
||||
players map[core.Color]*core.Player
|
||||
state core.State
|
||||
lastResult *MoveResult
|
||||
endTimeUTC *time.Time
|
||||
}
|
||||
|
||||
// View is an immutable copy of the state needed by processors and transports.
|
||||
// Service returns views instead of exposing mutable Game pointers outside its
|
||||
// lock, preventing torn responses and concurrent move races.
|
||||
type View struct {
|
||||
FEN string
|
||||
InitialFEN string
|
||||
NextTurnColor core.Color
|
||||
Moves []string
|
||||
WhitePlayer *core.Player
|
||||
BlackPlayer *core.Player
|
||||
State core.State
|
||||
LastResult *MoveResult
|
||||
EndTimeUTC *time.Time
|
||||
}
|
||||
|
||||
func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurnColor core.Color) *Game {
|
||||
// Determine which player's turn it is initially
|
||||
var initialPlayerID string
|
||||
var initialPlayerType core.PlayerType
|
||||
if startingTurnColor == core.ColorWhite {
|
||||
initialPlayerID = whitePlayer.ID
|
||||
initialPlayerType = whitePlayer.Type
|
||||
} else {
|
||||
initialPlayerID = blackPlayer.ID
|
||||
initialPlayerType = blackPlayer.Type
|
||||
}
|
||||
whiteCopy := *whitePlayer
|
||||
blackCopy := *blackPlayer
|
||||
|
||||
return &Game{
|
||||
snapshots: []Snapshot{
|
||||
@@ -46,19 +68,69 @@ func New(initialFEN string, whitePlayer, blackPlayer *core.Player, startingTurnC
|
||||
FEN: initialFEN,
|
||||
PreviousMove: "",
|
||||
NextTurnColor: startingTurnColor,
|
||||
PlayerType: initialPlayerType,
|
||||
PlayerID: initialPlayerID,
|
||||
},
|
||||
},
|
||||
players: map[core.Color]*core.Player{
|
||||
core.ColorWhite: whitePlayer,
|
||||
core.ColorBlack: blackPlayer,
|
||||
core.ColorWhite: &whiteCopy,
|
||||
core.ColorBlack: &blackCopy,
|
||||
},
|
||||
state: core.StateOngoing,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Game) View() View {
|
||||
view := View{
|
||||
FEN: g.CurrentFEN(),
|
||||
InitialFEN: g.InitialFEN(),
|
||||
NextTurnColor: g.NextTurnColor(),
|
||||
Moves: g.Moves(),
|
||||
State: g.state,
|
||||
}
|
||||
if player := g.players[core.ColorWhite]; player != nil {
|
||||
copy := *player
|
||||
view.WhitePlayer = ©
|
||||
}
|
||||
if player := g.players[core.ColorBlack]; player != nil {
|
||||
copy := *player
|
||||
view.BlackPlayer = ©
|
||||
}
|
||||
if g.lastResult != nil {
|
||||
copy := *g.lastResult
|
||||
view.LastResult = ©
|
||||
}
|
||||
if g.endTimeUTC != nil {
|
||||
copy := *g.endTimeUTC
|
||||
view.EndTimeUTC = ©
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (v View) NextPlayer() *core.Player {
|
||||
if v.NextTurnColor == core.ColorWhite {
|
||||
return v.WhitePlayer
|
||||
}
|
||||
return v.BlackPlayer
|
||||
}
|
||||
|
||||
func (v View) Player(color core.Color) *core.Player {
|
||||
if color == core.ColorWhite {
|
||||
return v.WhitePlayer
|
||||
}
|
||||
if color == core.ColorBlack {
|
||||
return v.BlackPlayer
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Game) SetLastResult(result *MoveResult) {
|
||||
g.lastResult = result
|
||||
if result == nil {
|
||||
g.lastResult = nil
|
||||
return
|
||||
}
|
||||
copy := *result
|
||||
g.lastResult = ©
|
||||
}
|
||||
|
||||
func (g *Game) LastResult() *MoveResult {
|
||||
@@ -94,18 +166,23 @@ func (g *Game) AddSnapshot(fen string, move string, nextTurnColor core.Color) {
|
||||
FEN: fen,
|
||||
PreviousMove: move,
|
||||
NextTurnColor: nextTurnColor,
|
||||
PlayerType: nextPlayer.Type,
|
||||
PlayerID: nextPlayer.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (g *Game) UpdatePlayers(whitePlayer, blackPlayer *core.Player) {
|
||||
g.players[core.ColorWhite] = whitePlayer
|
||||
g.players[core.ColorBlack] = blackPlayer
|
||||
whiteCopy := *whitePlayer
|
||||
blackCopy := *blackPlayer
|
||||
g.players[core.ColorWhite] = &whiteCopy
|
||||
g.players[core.ColorBlack] = &blackCopy
|
||||
|
||||
// Update current snapshot's PlayerID to reflect new player
|
||||
if len(g.snapshots) > 0 {
|
||||
currentSnap := &g.snapshots[len(g.snapshots)-1]
|
||||
currentSnap.PlayerID = g.players[currentSnap.NextTurnColor].ID
|
||||
currentPlayer := g.players[currentSnap.NextTurnColor]
|
||||
currentSnap.PlayerID = currentPlayer.ID
|
||||
currentSnap.PlayerType = currentPlayer.Type
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +199,7 @@ func (g *Game) UndoMoves(count int) error {
|
||||
g.snapshots = g.snapshots[:len(g.snapshots)-count]
|
||||
g.state = core.StateOngoing // Reset game state when undoing
|
||||
g.lastResult = nil // Clear last result
|
||||
g.endTimeUTC = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -140,9 +218,29 @@ func (g *Game) State() core.State {
|
||||
}
|
||||
|
||||
func (g *Game) SetState(s core.State) {
|
||||
g.SetStateAt(s, time.Now().UTC())
|
||||
}
|
||||
|
||||
func (g *Game) SetStateAt(s core.State, at time.Time) {
|
||||
if s.IsTerminal() {
|
||||
if !g.state.IsTerminal() || g.endTimeUTC == nil {
|
||||
ended := at.UTC()
|
||||
g.endTimeUTC = &ended
|
||||
}
|
||||
} else if g.state.IsTerminal() {
|
||||
g.endTimeUTC = nil
|
||||
}
|
||||
g.state = s
|
||||
}
|
||||
|
||||
func (g *Game) EndTimeUTC() *time.Time {
|
||||
if g.endTimeUTC == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *g.endTimeUTC
|
||||
return ©
|
||||
}
|
||||
|
||||
func (g *Game) InitialFEN() string {
|
||||
if len(g.snapshots) > 0 {
|
||||
return g.snapshots[0].FEN
|
||||
@@ -191,4 +289,4 @@ func (g *Game) HasComputerPlayer() bool {
|
||||
black := g.players[core.ColorBlack]
|
||||
return (white != nil && white.Type == core.PlayerComputer) ||
|
||||
(black != nil && black.Type == core.PlayerComputer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,9 +91,14 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
|
||||
req.Email = strings.ToLower(req.Email)
|
||||
}
|
||||
|
||||
// Create user (temp by default via API)
|
||||
user, err := h.svc.CreateUser(req.Username, req.Email, req.Password, false)
|
||||
// Create the user and initial session atomically (temp by default via API).
|
||||
user, sessionID, err := h.svc.RegisterUser(req.Username, req.Email, req.Password, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
if errors.Is(err, service.ErrAtCapacity) || errors.Is(err, service.ErrPermanentSlotsFull) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "registration temporarily unavailable",
|
||||
@@ -114,15 +119,6 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Create session for new user
|
||||
sessionID, err := h.svc.CreateUserSession(user.UserID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to create session",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := h.svc.GenerateUserToken(user.UserID, sessionID)
|
||||
if err != nil {
|
||||
@@ -193,6 +189,11 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
|
||||
// Authenticate user and create session (invalidates previous session)
|
||||
user, sessionID, err := h.svc.AuthenticateUser(req.Identifier, req.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid credentials",
|
||||
Code: core.ErrInvalidRequest,
|
||||
@@ -263,4 +264,3 @@ func (h *HTTPHandler) LogoutHandler(c *fiber.Ctx) error {
|
||||
|
||||
return c.JSON(fiber.Map{"message": "logged out"})
|
||||
}
|
||||
|
||||
|
||||
+104
-10
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -29,7 +30,7 @@ func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandle
|
||||
return &HTTPHandler{proc: proc, svc: svc}
|
||||
}
|
||||
|
||||
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool) *fiber.App {
|
||||
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRequests bool) *fiber.App {
|
||||
// Create handler
|
||||
h := NewHTTPHandler(proc, svc)
|
||||
|
||||
@@ -43,9 +44,13 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
|
||||
|
||||
// Global middleware (order matters)
|
||||
app.Use(recover.New())
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} ${status} ${method} ${path} ${latency}\n",
|
||||
}))
|
||||
if logRequests {
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} HTTP ${status} ${method} ${path} ${latency}\n",
|
||||
TimeFormat: time.RFC3339,
|
||||
TimeZone: "UTC",
|
||||
}))
|
||||
}
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
|
||||
@@ -136,12 +141,14 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
|
||||
|
||||
// Register game routes with auth middleware
|
||||
api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
|
||||
api.Get("/games/:gameId/history", h.GetGameHistory)
|
||||
api.Put("/games/:gameId/players", h.ConfigurePlayers)
|
||||
api.Get("/games/:gameId", h.GetGame)
|
||||
api.Delete("/games/:gameId", h.DeleteGame)
|
||||
api.Post("/games/:gameId/moves", OptionalAuth(validateToken), h.MakeMove)
|
||||
api.Post("/games/:gameId/undo", h.UndoMove)
|
||||
api.Get("/games/:gameId/board", h.GetBoard)
|
||||
api.Get("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames)
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -191,10 +198,15 @@ func customErrorHandler(c *fiber.Ctx, err error) error {
|
||||
|
||||
// Health check endpoint with storage status
|
||||
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
|
||||
storageHealth := h.svc.GetStorageHealth()
|
||||
status := "healthy"
|
||||
if storageHealth == "degraded" {
|
||||
status = "degraded"
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "healthy",
|
||||
"status": status,
|
||||
"time": time.Now().Unix(),
|
||||
"storage": h.svc.GetStorageHealth(),
|
||||
"storage": storageHealth,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -324,7 +336,7 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// First check if game exists and get current state
|
||||
g, err := h.svc.GetGame(gameID)
|
||||
g, err := h.svc.GetGameView(gameID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
|
||||
Error: "game not found",
|
||||
@@ -332,8 +344,8 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
currentMoveCount := len(g.Moves())
|
||||
st := g.State()
|
||||
currentMoveCount := len(g.Moves)
|
||||
st := g.State
|
||||
settled := st != core.StateOngoing && st != core.StatePending
|
||||
// If move count already different, return immediately
|
||||
if moveCount != currentMoveCount || settled {
|
||||
@@ -414,6 +426,8 @@ func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
|
||||
statusCode = fiber.StatusNotFound
|
||||
case core.ErrUnauthorized:
|
||||
statusCode = fiber.StatusForbidden
|
||||
case core.ErrConflict:
|
||||
statusCode = fiber.StatusConflict
|
||||
}
|
||||
return c.Status(statusCode).JSON(resp.Error)
|
||||
}
|
||||
@@ -470,7 +484,7 @@ func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// DeleteGame ends and cleans up a game
|
||||
// DeleteGame unloads a live game while retaining its durable history.
|
||||
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
@@ -519,3 +533,83 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// GetGameHistory serves persisted replay data. Histories are public by game ID,
|
||||
// matching the existing public live-game read model.
|
||||
func (h *HTTPHandler) GetGameHistory(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format", Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
history, err := h.svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "game history storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
case errors.Is(err, service.ErrGameNotFound):
|
||||
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
|
||||
Error: "game history not found", Code: core.ErrGameNotFound,
|
||||
})
|
||||
default:
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to load game history", Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.JSON(history)
|
||||
}
|
||||
|
||||
// GetCurrentUserGames returns a bounded list suitable for CLI and web game
|
||||
// pickers. The extra row used to compute nextOffset stays internal.
|
||||
func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(string)
|
||||
if !ok || userID == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "unauthorized", Code: core.ErrUnauthorized,
|
||||
})
|
||||
}
|
||||
|
||||
limit, err := queryInt(c, "limit", 50, 1, 100)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid pagination", Code: core.ErrInvalidRequest, Details: err.Error(),
|
||||
})
|
||||
}
|
||||
offset, err := queryInt(c, "offset", 0, 0, 1_000_000)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid pagination", Code: core.ErrInvalidRequest, Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
games, err := h.svc.GetUserGames(userID, limit, offset)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "stored games unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to load stored games", Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
return c.JSON(games)
|
||||
}
|
||||
|
||||
func queryInt(c *fiber.Ctx, name string, defaultValue, minimum, maximum int) (int, error) {
|
||||
raw := c.Query(name)
|
||||
if raw == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < minimum || value > maximum {
|
||||
return 0, fmt.Errorf("%s must be between %d and %d", name, minimum, maximum)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
@@ -18,15 +20,20 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "missing authorization token",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Code: core.ErrUnauthorized,
|
||||
})
|
||||
}
|
||||
|
||||
userID, claims, err := validateToken(token)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid or expired token",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Code: core.ErrUnauthorized,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,7 +45,8 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth validates JWT if present but allows anonymous access
|
||||
// OptionalAuth permits an absent token but rejects an invalid token instead of
|
||||
// silently downgrading an intended authenticated request to anonymous access.
|
||||
func OptionalAuth(validateToken TokenValidator) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
token := extractBearerToken(c.Get("Authorization"))
|
||||
@@ -47,11 +55,20 @@ func OptionalAuth(validateToken TokenValidator) fiber.Handler {
|
||||
}
|
||||
|
||||
userID, claims, err := validateToken(token)
|
||||
if err == nil {
|
||||
c.Locals("userID", userID)
|
||||
if sessionID, ok := claims["session_id"].(string); ok {
|
||||
c.Locals("sessionID", sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid or expired token", Code: core.ErrUnauthorized,
|
||||
})
|
||||
}
|
||||
|
||||
c.Locals("userID", userID)
|
||||
if sessionID, ok := claims["session_id"].(string); ok {
|
||||
c.Locals("sessionID", sessionID)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
@@ -64,4 +81,4 @@ func extractBearerToken(header string) string {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(header, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"chess/internal/server/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func TestOptionalAuthAllowsAbsenceButRejectsInvalidToken(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Get("/optional", OptionalAuth(func(string) (string, map[string]any, error) {
|
||||
return "", nil, errors.New("invalid token")
|
||||
}), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
})
|
||||
|
||||
request := httptest.NewRequest("GET", "/optional", nil)
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != fiber.StatusNoContent {
|
||||
t.Fatalf("anonymous status = %d, want %d", response.StatusCode, fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest("GET", "/optional", nil)
|
||||
request.Header.Set("Authorization", "Bearer invalid")
|
||||
response, err = app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != fiber.StatusUnauthorized {
|
||||
t.Fatalf("invalid-token status = %d, want %d", response.StatusCode, fiber.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareReportsStorageUnavailable(t *testing.T) {
|
||||
for _, middleware := range []func(TokenValidator) fiber.Handler{AuthRequired, OptionalAuth} {
|
||||
app := fiber.New()
|
||||
app.Get("/protected", middleware(func(string) (string, map[string]any, error) {
|
||||
return "", nil, service.ErrStorageUnavailable
|
||||
}), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
})
|
||||
|
||||
request := httptest.NewRequest("GET", "/protected", nil)
|
||||
request.Header.Set("Authorization", "Bearer token")
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != fiber.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want %d", response.StatusCode, fiber.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -185,14 +186,11 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
|
||||
}
|
||||
}
|
||||
|
||||
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn()); err != nil {
|
||||
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn(), initialState); err != nil {
|
||||
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
|
||||
}
|
||||
if initialState != core.StateOngoing {
|
||||
p.svc.UpdateGameState(gameID, initialState)
|
||||
}
|
||||
|
||||
g, err := p.svc.GetGame(gameID)
|
||||
g, err := p.svc.GetGameView(gameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game creation failed", core.ErrInternalError)
|
||||
}
|
||||
@@ -217,13 +215,13 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
|
||||
args.Black.SearchTime = minSearchTime
|
||||
}
|
||||
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
g, err := p.svc.GetGameView(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
// Block configuration changes during computer move
|
||||
if g.State() == core.StatePending {
|
||||
if g.State == core.StatePending {
|
||||
return p.errorResponse("cannot change players while computer is calculating", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
@@ -237,7 +235,7 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
|
||||
}
|
||||
|
||||
// Get updated game
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
g, _ = p.svc.GetGameView(cmd.GameID)
|
||||
response := p.buildGameResponse(cmd.GameID, g)
|
||||
|
||||
return ProcessorResponse{
|
||||
@@ -248,7 +246,7 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
|
||||
|
||||
// handleGetGame retrieves game state and triggers computer move if needed
|
||||
func (p *Processor) handleGetGame(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
g, err := p.svc.GetGameView(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
@@ -272,27 +270,30 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
g, err := p.svc.GetGameView(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
// Validate game state
|
||||
switch g.State() {
|
||||
switch g.State {
|
||||
case core.StatePending:
|
||||
return p.errorResponse("computer move in progress", core.ErrInvalidRequest)
|
||||
case core.StateStuck:
|
||||
return p.errorResponse("game is stuck due to engine error", core.ErrGameOver)
|
||||
case core.StateWhiteWins, core.StateBlackWins, core.StateDraw, core.StateStalemate:
|
||||
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State()), core.ErrGameOver)
|
||||
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State), core.ErrGameOver)
|
||||
case core.StateOngoing:
|
||||
break
|
||||
default:
|
||||
return p.errorResponse("game is in invalid state", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
currentColor := g.NextTurnColor()
|
||||
currentColor := g.NextTurnColor
|
||||
currentPlayer := g.NextPlayer()
|
||||
if currentPlayer == nil {
|
||||
return p.errorResponse("current player is missing", core.ErrInternalError)
|
||||
}
|
||||
|
||||
// Handle computer move trigger
|
||||
if strings.TrimSpace(args.Move) == "cccc" {
|
||||
@@ -300,10 +301,18 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
return p.errorResponse("not computer player's turn", core.ErrNotHumanTurn)
|
||||
}
|
||||
|
||||
p.svc.UpdateGameState(cmd.GameID, core.StatePending)
|
||||
p.triggerComputerMove(cmd.GameID, g)
|
||||
if err := p.svc.BeginComputerMove(cmd.GameID, g.FEN, currentColor); err != nil {
|
||||
if errors.Is(err, service.ErrGameChanged) {
|
||||
return p.errorResponse("game changed; refresh and retry", core.ErrConflict)
|
||||
}
|
||||
return p.errorResponse(fmt.Sprintf("failed to start computer move: %v", err), core.ErrInternalError)
|
||||
}
|
||||
if err := p.triggerComputerMove(cmd.GameID, g); err != nil {
|
||||
p.svc.UpdateGameState(cmd.GameID, core.StateStuck)
|
||||
return p.errorResponse(fmt.Sprintf("failed to queue computer move: %v", err), core.ErrResourceLimit)
|
||||
}
|
||||
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
g, _ = p.svc.GetGameView(cmd.GameID)
|
||||
response := p.buildGameResponse(cmd.GameID, g)
|
||||
response.LastMove = &core.MoveInfo{
|
||||
PlayerColor: currentColor.String(),
|
||||
@@ -322,16 +331,11 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
}
|
||||
|
||||
// Authorization: first-move-claims-slot model
|
||||
slotOwner := g.GetSlotOwner(currentColor)
|
||||
slotOwner := currentPlayer.ClaimedBy
|
||||
|
||||
if slotOwner == "" {
|
||||
// Slot unclaimed - claim it with this move
|
||||
if cmd.UserID != "" {
|
||||
if err := p.svc.ClaimGameSlot(cmd.GameID, currentColor, cmd.UserID); err != nil {
|
||||
return p.errorResponse(fmt.Sprintf("failed to claim slot: %v", err), core.ErrInternalError)
|
||||
}
|
||||
}
|
||||
// Anonymous users can also claim by making a move (slot remains "unclaimed" but move proceeds)
|
||||
// An authenticated user claims only when the validated move commits.
|
||||
// Anonymous moves deliberately leave the slot unclaimed.
|
||||
} else if cmd.UserID != "" && slotOwner != cmd.UserID {
|
||||
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
|
||||
}
|
||||
@@ -345,7 +349,7 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
return p.errorResponse("invalid move format", core.ErrInvalidMove)
|
||||
}
|
||||
|
||||
currentFEN := g.CurrentFEN()
|
||||
currentFEN := g.FEN
|
||||
|
||||
// Validate move and classify the resulting position in one engine session
|
||||
p.mu.Lock()
|
||||
@@ -365,16 +369,22 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
}
|
||||
|
||||
// Atomic commit: move + state + metadata, single notification
|
||||
if err = p.svc.ApplyMoveWithState(cmd.GameID, move, newFEN, finalState, &game.MoveResult{
|
||||
Move: move,
|
||||
PlayerColor: currentColor,
|
||||
GameState: finalState,
|
||||
if err = p.svc.ApplyMoveWithState(cmd.GameID, service.MoveCommit{
|
||||
ExpectedFEN: currentFEN, ExpectedState: core.StateOngoing, ExpectedTurn: currentColor,
|
||||
ActorUserID: cmd.UserID, MoveUCI: move, NewFEN: newFEN, State: finalState,
|
||||
Result: &game.MoveResult{Move: move, PlayerColor: currentColor, GameState: finalState},
|
||||
}); err != nil {
|
||||
if errors.Is(err, service.ErrSlotOwner) {
|
||||
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
|
||||
}
|
||||
if errors.Is(err, service.ErrGameChanged) {
|
||||
return p.errorResponse("game changed while move was being validated; refresh and retry", core.ErrConflict)
|
||||
}
|
||||
return p.errorResponse(fmt.Sprintf("failed to apply move: %v", err), core.ErrInternalError)
|
||||
}
|
||||
|
||||
// buildGameResponse populates LastMove from the committed LastResult
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
g, _ = p.svc.GetGameView(cmd.GameID)
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: p.buildGameResponse(cmd.GameID, g),
|
||||
@@ -387,12 +397,12 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
// snapshot had legal moves made from it, so resetting to Ongoing is sound
|
||||
// without re-classification.
|
||||
func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
g, err := p.svc.GetGameView(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
if g.State() == core.StatePending {
|
||||
if g.State == core.StatePending {
|
||||
return p.errorResponse("cannot undo while computer move is in progress", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
@@ -410,25 +420,22 @@ func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
|
||||
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// Reset game state to ongoing after undo
|
||||
p.svc.UpdateGameState(cmd.GameID, core.StateOngoing)
|
||||
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
g, _ = p.svc.GetGameView(cmd.GameID)
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: p.buildGameResponse(cmd.GameID, g),
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteGame removes a game
|
||||
// handleDeleteGame unloads a game from live memory.
|
||||
func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
g, err := p.svc.GetGameView(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
// Only block deletion if actively computing
|
||||
if g.State() == core.StatePending {
|
||||
if g.State == core.StatePending {
|
||||
return p.errorResponse("cannot delete game while computer move is in progress", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
@@ -443,12 +450,12 @@ func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
|
||||
|
||||
// handleGetBoard returns board visualization
|
||||
func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
g, err := p.svc.GetGameView(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
b, err := board.ParseFEN(g.CurrentFEN())
|
||||
b, err := board.ParseFEN(g.FEN)
|
||||
if err != nil {
|
||||
return p.errorResponse("error parsing FEN", core.ErrInvalidFEN)
|
||||
}
|
||||
@@ -457,7 +464,7 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: core.BoardResponse{
|
||||
FEN: g.CurrentFEN(),
|
||||
FEN: g.FEN,
|
||||
Board: ascii,
|
||||
},
|
||||
}
|
||||
@@ -467,18 +474,18 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
|
||||
// re-classifies via the validation engine: worker output is never trusted for
|
||||
// end-state determination, and no-move results are verified against the
|
||||
// position rather than the IsMate info-line byproduct.
|
||||
func (p *Processor) triggerComputerMove(gameID string, g *game.Game) {
|
||||
fen := g.CurrentFEN()
|
||||
color := g.NextTurnColor()
|
||||
func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
|
||||
fen := g.FEN
|
||||
color := g.NextTurnColor
|
||||
player := g.NextPlayer()
|
||||
|
||||
p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
|
||||
currentGame, err := p.svc.GetGame(gameID)
|
||||
if err != nil || currentGame.State() != core.StatePending {
|
||||
return p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
|
||||
currentGame, err := p.svc.GetGameView(gameID)
|
||||
if err != nil || currentGame.State != core.StatePending || currentGame.FEN != fen {
|
||||
return // Deleted, or state resolved elsewhere
|
||||
}
|
||||
if result.Error != nil {
|
||||
log.Printf("engine error for game %s: %v", gameID, result.Error)
|
||||
slog.Error("computer engine failed", "game_id", gameID, "error", result.Error)
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
||||
return
|
||||
}
|
||||
@@ -508,10 +515,19 @@ func (p *Processor) triggerComputerMove(gameID string, g *game.Game) {
|
||||
return
|
||||
}
|
||||
|
||||
p.svc.ApplyMoveWithState(gameID, result.Move, newFEN, finalState, &game.MoveResult{
|
||||
Move: result.Move, PlayerColor: color,
|
||||
Score: result.Score, Depth: result.Depth, GameState: finalState,
|
||||
})
|
||||
if err := p.svc.ApplyMoveWithState(gameID, service.MoveCommit{
|
||||
ExpectedFEN: fen, ExpectedState: core.StatePending, ExpectedTurn: color,
|
||||
MoveUCI: result.Move, NewFEN: newFEN, State: finalState,
|
||||
Result: &game.MoveResult{
|
||||
Move: result.Move, PlayerColor: color,
|
||||
Score: result.Score, Depth: result.Depth, GameState: finalState,
|
||||
},
|
||||
}); err != nil {
|
||||
slog.Error("failed to apply computer move", "game_id", gameID, "error", err)
|
||||
if !errors.Is(err, service.ErrGameChanged) && !errors.Is(err, service.ErrGameNotFound) {
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -583,27 +599,28 @@ func (p *Processor) checkGameEnd(gameID, fen string) {
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("game %s: end-state check attempt %d failed: %v", gameID, attempt+1, err)
|
||||
slog.Warn("game end-state check failed",
|
||||
"game_id", gameID, "attempt", attempt+1, "error", err)
|
||||
}
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
||||
}
|
||||
|
||||
// buildGameResponse constructs standard game response
|
||||
func (p *Processor) buildGameResponse(gameID string, g *game.Game) core.GameResponse {
|
||||
func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse {
|
||||
resp := core.GameResponse{
|
||||
GameID: gameID,
|
||||
FEN: g.CurrentFEN(),
|
||||
Turn: g.NextTurnColor().String(),
|
||||
State: g.State().String(),
|
||||
Moves: g.Moves(),
|
||||
FEN: g.FEN,
|
||||
Turn: g.NextTurnColor.String(),
|
||||
State: g.State.String(),
|
||||
Moves: g.Moves,
|
||||
Players: core.PlayersResponse{
|
||||
White: g.GetPlayer(core.ColorWhite),
|
||||
Black: g.GetPlayer(core.ColorBlack),
|
||||
White: g.WhitePlayer,
|
||||
Black: g.BlackPlayer,
|
||||
},
|
||||
}
|
||||
|
||||
// Include last move if available
|
||||
if result := g.LastResult(); result != nil {
|
||||
if result := g.LastResult; result != nil {
|
||||
resp.LastMove = &core.MoveInfo{
|
||||
Move: result.Move,
|
||||
PlayerColor: result.PlayerColor.String(),
|
||||
@@ -628,6 +645,9 @@ func (p *Processor) errorResponse(message, code string) ProcessorResponse {
|
||||
|
||||
// Close cleans up resources
|
||||
func (p *Processor) Close() error {
|
||||
p.queue.Shutdown(5 * time.Second)
|
||||
return p.validationEng.Close()
|
||||
queueErr := p.queue.Shutdown(5 * time.Second)
|
||||
p.mu.Lock()
|
||||
engineErr := p.validationEng.Close()
|
||||
p.mu.Unlock()
|
||||
return errors.Join(queueErr, engineErr)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package processor
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -33,11 +33,15 @@ type EngineResult struct {
|
||||
|
||||
// EngineQueue manages async engine computations
|
||||
type EngineQueue struct {
|
||||
tasks chan EngineTask
|
||||
workers int
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
tasks chan EngineTask
|
||||
workers int
|
||||
wg sync.WaitGroup
|
||||
callbackWG sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
submitMu sync.RWMutex
|
||||
closed bool
|
||||
shutdownOnce sync.Once
|
||||
}
|
||||
|
||||
// NewEngineQueue creates a queue with specified worker count
|
||||
@@ -76,7 +80,7 @@ func (q *EngineQueue) worker(id int) {
|
||||
if eng, err = engine.New(); err == nil {
|
||||
break
|
||||
}
|
||||
log.Printf("worker %d: engine init failed: %v; retrying", id, err)
|
||||
slog.Warn("engine worker initialization failed; retrying", "worker", id, "error", err)
|
||||
select {
|
||||
case <-q.ctx.Done():
|
||||
return
|
||||
@@ -84,6 +88,7 @@ func (q *EngineQueue) worker(id int) {
|
||||
}
|
||||
}
|
||||
defer eng.Close()
|
||||
slog.Debug("engine worker started", "worker", id)
|
||||
for {
|
||||
select {
|
||||
case task, ok := <-q.tasks:
|
||||
@@ -99,6 +104,10 @@ func (q *EngineQueue) worker(id int) {
|
||||
|
||||
// processTask executes a single engine calculation
|
||||
func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult {
|
||||
started := time.Now()
|
||||
defer func() {
|
||||
slog.Debug("engine task completed", "game_id", task.GameID, "duration", time.Since(started))
|
||||
}()
|
||||
result := EngineResult{GameID: task.GameID}
|
||||
if err := eng.NewGame(); err != nil {
|
||||
result.Error = err
|
||||
@@ -134,8 +143,18 @@ func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult
|
||||
|
||||
// Submit adds a task to the queue
|
||||
func (q *EngineQueue) Submit(task EngineTask) error {
|
||||
q.submitMu.RLock()
|
||||
defer q.submitMu.RUnlock()
|
||||
return q.submitLocked(task)
|
||||
}
|
||||
|
||||
func (q *EngineQueue) submitLocked(task EngineTask) error {
|
||||
if q.closed {
|
||||
return fmt.Errorf("queue is shutting down")
|
||||
}
|
||||
select {
|
||||
case q.tasks <- task:
|
||||
slog.Debug("engine task queued", "game_id", task.GameID, "queue_depth", len(q.tasks))
|
||||
return nil
|
||||
case <-q.ctx.Done():
|
||||
return fmt.Errorf("queue is shutting down")
|
||||
@@ -146,8 +165,24 @@ func (q *EngineQueue) Submit(task EngineTask) error {
|
||||
|
||||
// SubmitAsync submits a task without blocking for result
|
||||
func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *core.Player, callback func(EngineResult)) error {
|
||||
if player == nil {
|
||||
return fmt.Errorf("computer player is missing")
|
||||
}
|
||||
if callback == nil {
|
||||
return fmt.Errorf("engine callback is missing")
|
||||
}
|
||||
respChan := make(chan EngineResult, 1)
|
||||
if err := q.Submit(EngineTask{GameID: gameID, FEN: fen, Color: color, Player: player, Response: respChan}); err != nil {
|
||||
q.submitMu.RLock()
|
||||
err := q.submitLocked(EngineTask{
|
||||
GameID: gameID, FEN: fen, Color: color, Player: player, Response: respChan,
|
||||
})
|
||||
if err == nil {
|
||||
// Registered while the submit lock is held, so Shutdown cannot begin
|
||||
// waiting between a successful send and this Add.
|
||||
q.callbackWG.Add(1)
|
||||
}
|
||||
q.submitMu.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
budget := 1000
|
||||
@@ -156,11 +191,18 @@ func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *
|
||||
}
|
||||
wait := time.Duration(budget)*time.Millisecond*2 + 30*time.Second // search budget + queue-wait headroom
|
||||
go func() {
|
||||
defer q.callbackWG.Done()
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case result := <-respChan:
|
||||
callback(result)
|
||||
case <-time.After(wait):
|
||||
case <-timer.C:
|
||||
callback(EngineResult{GameID: gameID, Error: fmt.Errorf("engine timeout")})
|
||||
case <-q.ctx.Done():
|
||||
// Live state is intentionally abandoned during server shutdown. The
|
||||
// last fully committed position remains the durable replay boundary.
|
||||
return
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
@@ -168,12 +210,18 @@ func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *
|
||||
|
||||
// Shutdown gracefully stops the queue
|
||||
func (q *EngineQueue) Shutdown(timeout time.Duration) error {
|
||||
q.cancel()
|
||||
close(q.tasks)
|
||||
q.shutdownOnce.Do(func() {
|
||||
q.submitMu.Lock()
|
||||
q.closed = true
|
||||
q.cancel()
|
||||
close(q.tasks)
|
||||
q.submitMu.Unlock()
|
||||
})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
q.wg.Wait()
|
||||
q.callbackWG.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
)
|
||||
|
||||
func TestEngineQueueShutdownCancelsAndWaitsForCallback(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
queue := &EngineQueue{
|
||||
tasks: make(chan EngineTask, 1),
|
||||
workers: 1,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
player := &core.Player{Type: core.PlayerComputer, SearchTime: 10_000}
|
||||
var callbackCalled atomic.Bool
|
||||
if err := queue.SubmitAsync("game-1", "fen", core.ColorWhite, player, func(EngineResult) {
|
||||
callbackCalled.Store(true)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := queue.Shutdown(time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if callbackCalled.Load() {
|
||||
t.Fatal("shutdown callback mutated live state after cancellation")
|
||||
}
|
||||
if err := queue.Submit(EngineTask{}); err == nil {
|
||||
t.Fatal("submit succeeded after shutdown")
|
||||
}
|
||||
if err := queue.Shutdown(time.Second); err != nil {
|
||||
t.Fatalf("second shutdown was not idempotent: %v", err)
|
||||
}
|
||||
}
|
||||
+205
-63
@@ -1,7 +1,9 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
@@ -11,8 +13,32 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrGameNotFound = errors.New("game not found")
|
||||
ErrGameChanged = errors.New("game changed while move was being validated")
|
||||
ErrSlotOwner = errors.New("player slot is owned by another user")
|
||||
)
|
||||
|
||||
type MoveCommit struct {
|
||||
ExpectedFEN string
|
||||
ExpectedState core.State
|
||||
ExpectedTurn core.Color
|
||||
ActorUserID string
|
||||
MoveUCI string
|
||||
NewFEN string
|
||||
State core.State
|
||||
Result *game.MoveResult
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// CreateGame registers a new game with pre-constructed players
|
||||
func (s *Service) CreateGame(id string, whitePlayer, blackPlayer *core.Player, initialFEN string, startingTurn core.Color) error {
|
||||
func (s *Service) CreateGame(
|
||||
id string,
|
||||
whitePlayer, blackPlayer *core.Player,
|
||||
initialFEN string,
|
||||
startingTurn core.Color,
|
||||
initialState core.State,
|
||||
) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -29,11 +55,14 @@ func (s *Service) CreateGame(id string, whitePlayer, blackPlayer *core.Player, i
|
||||
s.computerGames.Add(1)
|
||||
}
|
||||
|
||||
// Store game with provided players
|
||||
s.games[id] = game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
|
||||
now := time.Now().UTC()
|
||||
g := game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
|
||||
g.SetStateAt(initialState, now)
|
||||
s.games[id] = g
|
||||
|
||||
// Persist if storage enabled
|
||||
if s.store != nil {
|
||||
result, _ := initialState.Result()
|
||||
record := storage.GameRecord{
|
||||
GameID: id,
|
||||
InitialFEN: initialFEN,
|
||||
@@ -41,14 +70,21 @@ func (s *Service) CreateGame(id string, whitePlayer, blackPlayer *core.Player, i
|
||||
WhiteType: int(whitePlayer.Type),
|
||||
WhiteLevel: whitePlayer.Level,
|
||||
WhiteSearchTime: whitePlayer.SearchTime,
|
||||
WhiteClaimedBy: whitePlayer.ClaimedBy,
|
||||
BlackPlayerID: blackPlayer.ID,
|
||||
BlackType: int(blackPlayer.Type),
|
||||
BlackLevel: blackPlayer.Level,
|
||||
BlackSearchTime: blackPlayer.SearchTime,
|
||||
StartTimeUTC: time.Now().UTC(),
|
||||
BlackClaimedBy: blackPlayer.ClaimedBy,
|
||||
Result: result,
|
||||
StartTimeUTC: now,
|
||||
EndTimeUTC: g.EndTimeUTC(),
|
||||
}
|
||||
if err := s.store.RecordNewGame(record); err != nil {
|
||||
slog.Error("failed to queue game persistence", "game_id", id, "error", err)
|
||||
}
|
||||
s.store.RecordNewGame(record)
|
||||
}
|
||||
slog.Debug("game created", "game_id", id, "state", initialState.String(), "persistent", s.store != nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -62,23 +98,85 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
if g.State() == core.StatePending {
|
||||
return errors.New("cannot change players while computer is calculating")
|
||||
}
|
||||
|
||||
oldWhite := g.GetPlayer(core.ColorWhite)
|
||||
oldBlack := g.GetPlayer(core.ColorBlack)
|
||||
oldHasComputer := g.HasComputerPlayer()
|
||||
newHasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
|
||||
if !oldHasComputer && newHasComputer && s.computerGames.Load() >= MaxComputerGames {
|
||||
return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames)
|
||||
}
|
||||
|
||||
// Player configuration is mutable, but historical user association is not.
|
||||
// Preserve a human ID while the slot remains human, and preserve any claim
|
||||
// even if the slot later becomes computer-controlled.
|
||||
if oldWhite != nil {
|
||||
if oldWhite.Type == core.PlayerHuman && whitePlayer.Type == core.PlayerHuman {
|
||||
whitePlayer.ID = oldWhite.ID
|
||||
}
|
||||
whitePlayer.ClaimedBy = oldWhite.ClaimedBy
|
||||
}
|
||||
if oldBlack != nil {
|
||||
if oldBlack.Type == core.PlayerHuman && blackPlayer.Type == core.PlayerHuman {
|
||||
blackPlayer.ID = oldBlack.ID
|
||||
}
|
||||
blackPlayer.ClaimedBy = oldBlack.ClaimedBy
|
||||
}
|
||||
|
||||
// Update the game's players
|
||||
g.UpdatePlayers(whitePlayer, blackPlayer)
|
||||
if oldHasComputer != newHasComputer {
|
||||
if newHasComputer {
|
||||
s.computerGames.Add(1)
|
||||
} else {
|
||||
s.computerGames.Add(-1)
|
||||
}
|
||||
}
|
||||
if s.store != nil {
|
||||
err := s.store.RecordPlayers(gameID, playerRecord(whitePlayer), playerRecord(blackPlayer))
|
||||
if err != nil {
|
||||
slog.Error("failed to queue player persistence", "game_id", gameID, "error", err)
|
||||
}
|
||||
}
|
||||
slog.Debug("game players updated", "game_id", gameID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGame retrieves a game by ID
|
||||
func (s *Service) GetGame(gameID string) (*game.Game, error) {
|
||||
// GetGameView retrieves an immutable game snapshot by ID.
|
||||
func (s *Service) GetGameView(gameID string) (game.View, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("game not found: %s", gameID)
|
||||
return game.View{}, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
return g, nil
|
||||
return g.View(), nil
|
||||
}
|
||||
|
||||
// BeginComputerMove is an optimistic state transition: only the request that
|
||||
// observed the current ongoing position may enqueue engine work.
|
||||
func (s *Service) BeginComputerMove(gameID, expectedFEN string, expectedTurn core.Color) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
if g.State() != core.StateOngoing || g.CurrentFEN() != expectedFEN || g.NextTurnColor() != expectedTurn {
|
||||
return ErrGameChanged
|
||||
}
|
||||
if player := g.NextPlayer(); player == nil || player.Type != core.PlayerComputer {
|
||||
return errors.New("current player is not a computer")
|
||||
}
|
||||
g.SetStateAt(core.StatePending, time.Now().UTC())
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()), core.StatePending)
|
||||
slog.Debug("computer move started", "game_id", gameID, "turn", expectedTurn.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateGameID creates a new unique game ID
|
||||
@@ -95,70 +193,86 @@ func (s *Service) GenerateGameID() string {
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyMove adds a validated move to the game history
|
||||
func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
|
||||
// ApplyMoveWithState verifies that the position validated by the processor is
|
||||
// still current, then commits the move, optional first-move claim, and result as
|
||||
// one in-memory transition and one SQLite transaction.
|
||||
func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
|
||||
// Determine whose turn it was before this move
|
||||
currentTurn := g.NextTurnColor()
|
||||
nextTurn := core.OppositeColor(currentTurn)
|
||||
if g.CurrentFEN() != commit.ExpectedFEN ||
|
||||
g.State() != commit.ExpectedState ||
|
||||
currentTurn != commit.ExpectedTurn {
|
||||
return ErrGameChanged
|
||||
}
|
||||
|
||||
// Add the new position to game history
|
||||
g.AddSnapshot(newFEN, moveUCI, nextTurn)
|
||||
|
||||
// Notify waiting clients about the state change
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()), g.State())
|
||||
|
||||
// Persist if storage enabled
|
||||
if s.store != nil {
|
||||
moveNumber := len(g.Moves())
|
||||
record := storage.MoveRecord{
|
||||
GameID: gameID,
|
||||
MoveNumber: moveNumber,
|
||||
MoveUCI: moveUCI,
|
||||
FENAfterMove: newFEN,
|
||||
PlayerColor: currentTurn.String(),
|
||||
MoveTimeUTC: time.Now().UTC(),
|
||||
currentPlayer := g.NextPlayer()
|
||||
claimUserID := ""
|
||||
if currentPlayer == nil {
|
||||
return errors.New("current player is missing")
|
||||
}
|
||||
if currentPlayer.Type == core.PlayerHuman {
|
||||
owner := g.GetSlotOwner(currentTurn)
|
||||
switch {
|
||||
case owner != "" && commit.ActorUserID == "":
|
||||
return ErrSlotOwner
|
||||
case owner != "" && owner != commit.ActorUserID:
|
||||
return ErrSlotOwner
|
||||
case owner == "" && commit.ActorUserID != "":
|
||||
claimUserID = commit.ActorUserID
|
||||
}
|
||||
s.store.RecordMove(record)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyMoveWithState atomically records a move, its resulting state, and move
|
||||
// metadata, then notifies waiters exactly once with the settled state.
|
||||
func (s *Service) ApplyMoveWithState(gameID, moveUCI, newFEN string, state core.State, result *game.MoveResult) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
at := commit.At.UTC()
|
||||
if commit.At.IsZero() {
|
||||
at = time.Now().UTC()
|
||||
}
|
||||
|
||||
currentTurn := g.NextTurnColor()
|
||||
g.AddSnapshot(newFEN, moveUCI, core.OppositeColor(currentTurn))
|
||||
g.SetState(state)
|
||||
if result != nil {
|
||||
g.SetLastResult(result)
|
||||
if claimUserID != "" {
|
||||
if err := g.ClaimSlot(currentTurn, claimUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
g.AddSnapshot(commit.NewFEN, commit.MoveUCI, core.OppositeColor(currentTurn))
|
||||
g.SetStateAt(commit.State, at)
|
||||
if commit.Result != nil {
|
||||
g.SetLastResult(commit.Result)
|
||||
}
|
||||
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()), state)
|
||||
|
||||
if s.store != nil {
|
||||
s.store.RecordMove(storage.MoveRecord{
|
||||
GameID: gameID, MoveNumber: len(g.Moves()), MoveUCI: moveUCI,
|
||||
FENAfterMove: newFEN, PlayerColor: currentTurn.String(),
|
||||
MoveTimeUTC: time.Now().UTC(),
|
||||
})
|
||||
result, _ := commit.State.Result()
|
||||
persistence := storage.MovePersistence{
|
||||
Move: storage.MoveRecord{
|
||||
GameID: gameID, MoveNumber: len(g.Moves()), MoveUCI: commit.MoveUCI,
|
||||
FENAfterMove: commit.NewFEN, PlayerColor: currentTurn.String(), MoveTimeUTC: at,
|
||||
},
|
||||
ClaimColor: currentTurn.String(),
|
||||
ClaimedBy: claimUserID,
|
||||
Result: result,
|
||||
EndTimeUTC: g.EndTimeUTC(),
|
||||
}
|
||||
if claimUserID == "" {
|
||||
persistence.ClaimColor = ""
|
||||
}
|
||||
if err := s.store.RecordMove(persistence); err != nil {
|
||||
slog.Error("failed to queue move persistence",
|
||||
"game_id", gameID, "move_number", len(g.Moves()), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()), commit.State)
|
||||
slog.Debug("game move applied",
|
||||
"game_id", gameID,
|
||||
"move_number", len(g.Moves()),
|
||||
"move", commit.MoveUCI,
|
||||
"state", commit.State.String(),
|
||||
"slot_claimed", claimUserID != "",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -169,12 +283,21 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
|
||||
g.SetState(state)
|
||||
previousState := g.State()
|
||||
now := time.Now().UTC()
|
||||
g.SetStateAt(state, now)
|
||||
if s.store != nil && state.IsTerminal() && !previousState.IsTerminal() {
|
||||
result, _ := state.Result()
|
||||
if err := s.store.RecordGameResult(gameID, result, now); err != nil {
|
||||
slog.Error("failed to queue game result persistence", "game_id", gameID, "error", err)
|
||||
}
|
||||
}
|
||||
// Notify unconditionally; the registry decides.
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()), state)
|
||||
slog.Debug("game state updated", "game_id", gameID, "from", previousState.String(), "to", state.String())
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -186,7 +309,7 @@ func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) erro
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
|
||||
g.SetLastResult(result)
|
||||
@@ -200,7 +323,10 @@ func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
if g.State() == core.StatePending {
|
||||
return errors.New("cannot undo while computer move is in progress")
|
||||
}
|
||||
|
||||
originalMoveCount := len(g.Moves())
|
||||
@@ -215,8 +341,11 @@ func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
// Delete undone moves from storage if enabled
|
||||
if s.store != nil {
|
||||
remainingMoves := originalMoveCount - count
|
||||
s.store.DeleteUndoneMoves(gameID, remainingMoves)
|
||||
if err := s.store.RewindGame(gameID, remainingMoves); err != nil {
|
||||
slog.Error("failed to queue game rewind persistence", "game_id", gameID, "error", err)
|
||||
}
|
||||
}
|
||||
slog.Debug("game moves undone", "game_id", gameID, "count", count, "remaining_moves", len(g.Moves()))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -228,7 +357,10 @@ func (s *Service) DeleteGame(gameID string) error {
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
if g.State() == core.StatePending {
|
||||
return errors.New("cannot delete game while computer move is in progress")
|
||||
}
|
||||
|
||||
// Decrement computer game count if applicable
|
||||
@@ -240,6 +372,16 @@ func (s *Service) DeleteGame(gameID string) error {
|
||||
s.waiter.RemoveGame(gameID)
|
||||
|
||||
delete(s.games, gameID)
|
||||
slog.Debug("game unloaded from memory", "game_id", gameID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func playerRecord(player *core.Player) storage.PlayerRecord {
|
||||
return storage.PlayerRecord{
|
||||
PlayerID: player.ID,
|
||||
Type: int(player.Type),
|
||||
Level: player.Level,
|
||||
SearchTime: player.SearchTime,
|
||||
ClaimedBy: player.ClaimedBy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/game"
|
||||
"chess/internal/server/storage"
|
||||
)
|
||||
|
||||
func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC)
|
||||
commit := MoveCommit{
|
||||
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
|
||||
ActorUserID: "user-1", MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended,
|
||||
Result: &game.MoveResult{Move: "e2e4", PlayerColor: core.ColorWhite, GameState: core.StateWhiteWins},
|
||||
}
|
||||
if err := svc.ApplyMoveWithState("game-1", commit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view, err := svc.GetGameView("game-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.WhitePlayer.ClaimedBy != "user-1" || view.State != core.StateWhiteWins {
|
||||
t.Fatalf("in-memory move did not settle atomically: %+v", view)
|
||||
}
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ClaimedBy != "user-1" || history.Result != "white_wins" || len(history.Moves) != 1 {
|
||||
t.Fatalf("history did not settle atomically: %+v", history)
|
||||
}
|
||||
|
||||
if err := svc.ApplyMoveWithState("game-1", commit); !errors.Is(err, ErrGameChanged) {
|
||||
t.Fatalf("stale move error = %v, want ErrGameChanged", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndoClearsDurableTerminalResult(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.ApplyMoveWithState("game-1", MoveCommit{
|
||||
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
|
||||
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.UndoMoves("game-1", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Result != "" || history.EndTimeUTC != nil || len(history.Moves) != 0 {
|
||||
t.Fatalf("undo left terminal persistence: %+v", history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
white.ID = "user-1"
|
||||
white.ClaimedBy = "user-1"
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
replacementWhite := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
replacementBlack := core.NewPlayer(
|
||||
core.PlayerConfig{Type: core.PlayerComputer, Level: 12, SearchTime: 500}, core.ColorBlack,
|
||||
)
|
||||
if err := svc.UpdatePlayers("game-1", replacementWhite, replacementBlack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ID != "user-1" || history.Players.White.ClaimedBy != "user-1" {
|
||||
t.Fatalf("white ownership was replaced: %+v", history.Players.White)
|
||||
}
|
||||
if history.Players.Black.Type != core.PlayerComputer || history.Players.Black.Level != 12 {
|
||||
t.Fatalf("black configuration was not persisted: %+v", history.Players.Black)
|
||||
}
|
||||
|
||||
computerWhite := core.NewPlayer(
|
||||
core.PlayerConfig{Type: core.PlayerComputer, Level: 8, SearchTime: 300}, core.ColorWhite,
|
||||
)
|
||||
if err := svc.UpdatePlayers("game-1", computerWhite, replacementBlack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history, err = svc.GetGameHistory("game-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ClaimedBy != "user-1" {
|
||||
t.Fatalf("player-type change discarded historical ownership: %+v", history.Players.White)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
svc.SetFinishedGameTTL(time.Nanosecond)
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "terminal", core.ColorWhite, core.StateStalemate,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc.cleanupFinishedGames(time.Now().UTC().Add(time.Second))
|
||||
if _, err := svc.GetGameView("game-1"); !errors.Is(err, ErrGameNotFound) {
|
||||
t.Fatalf("terminal game remains in memory: %v", err)
|
||||
}
|
||||
if history, err := svc.GetGameHistory("game-1"); err != nil || history.Result != "stalemate" {
|
||||
t.Fatalf("durable history was removed: history=%+v err=%v", history, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newPersistentTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
store, err := storage.NewStore(filepath.Join(t.TempDir(), "chess.db"), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.InitDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(store, []byte("test-secret-test-secret-test-secret"))
|
||||
t.Cleanup(func() {
|
||||
if err := svc.Shutdown(time.Second); err != nil {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
}
|
||||
})
|
||||
return svc
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/storage"
|
||||
)
|
||||
|
||||
// GetGameHistory returns a durable replay even after the live game has been
|
||||
// evicted from memory or the server has restarted.
|
||||
func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, error) {
|
||||
if s.store == nil {
|
||||
return nil, ErrStorageDisabled
|
||||
}
|
||||
record, moves, err := s.store.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
if storage.IsGameNotFound(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
if isStorageUnavailable(err) {
|
||||
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return nil, fmt.Errorf("get game history: %w", err)
|
||||
}
|
||||
|
||||
history := &core.GameHistoryResponse{
|
||||
GameID: record.GameID,
|
||||
InitialFEN: record.InitialFEN,
|
||||
Result: record.Result,
|
||||
StartTimeUTC: record.StartTimeUTC,
|
||||
EndTimeUTC: record.EndTimeUTC,
|
||||
Players: playersResponse(*record),
|
||||
Moves: make([]core.HistoryMove, 0, len(moves)),
|
||||
}
|
||||
for _, move := range moves {
|
||||
history.Moves = append(history.Moves, core.HistoryMove{
|
||||
MoveNumber: move.MoveNumber,
|
||||
MoveUCI: move.MoveUCI,
|
||||
FENAfterMove: move.FENAfterMove,
|
||||
PlayerColor: move.PlayerColor,
|
||||
MoveTimeUTC: move.MoveTimeUTC,
|
||||
})
|
||||
}
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// GetUserGames returns a bounded page of games associated either at creation
|
||||
// or by a later slot claim.
|
||||
func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameListResponse, error) {
|
||||
if s.store == nil {
|
||||
return nil, ErrStorageDisabled
|
||||
}
|
||||
records, err := s.store.QueryGamesForUser(userID, limit+1, offset)
|
||||
if err != nil {
|
||||
if isStorageUnavailable(err) {
|
||||
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return nil, fmt.Errorf("get user games: %w", err)
|
||||
}
|
||||
|
||||
hasNext := len(records) > limit
|
||||
if hasNext {
|
||||
records = records[:limit]
|
||||
}
|
||||
response := &core.GameListResponse{
|
||||
Games: make([]core.GameSummary, 0, len(records)),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
if hasNext {
|
||||
next := offset + limit
|
||||
response.NextOffset = &next
|
||||
}
|
||||
for _, record := range records {
|
||||
response.Games = append(response.Games, core.GameSummary{
|
||||
GameID: record.GameID,
|
||||
InitialFEN: record.InitialFEN,
|
||||
Result: record.Result,
|
||||
StartTimeUTC: record.StartTimeUTC,
|
||||
EndTimeUTC: record.EndTimeUTC,
|
||||
MoveCount: record.MoveCount,
|
||||
Players: playersResponse(record.GameRecord),
|
||||
})
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func isStorageUnavailable(err error) bool {
|
||||
return errors.Is(err, storage.ErrStorageDegraded) ||
|
||||
errors.Is(err, storage.ErrStoreClosed) ||
|
||||
errors.Is(err, storage.ErrWriteQueueFull) ||
|
||||
errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
func playersResponse(record storage.GameRecord) core.PlayersResponse {
|
||||
return core.PlayersResponse{
|
||||
White: &core.Player{
|
||||
ID: record.WhitePlayerID, Color: core.ColorWhite, Type: core.PlayerType(record.WhiteType),
|
||||
Level: record.WhiteLevel, SearchTime: record.WhiteSearchTime, ClaimedBy: record.WhiteClaimedBy,
|
||||
},
|
||||
Black: &core.Player{
|
||||
ID: record.BlackPlayerID, Color: core.ColorBlack, Type: core.PlayerType(record.BlackType),
|
||||
Level: record.BlackLevel, SearchTime: record.BlackSearchTime, ClaimedBy: record.BlackClaimedBy,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -20,28 +21,41 @@ const (
|
||||
TempUserTTL = 24 * time.Hour
|
||||
SessionTTL = 7 * 24 * time.Hour
|
||||
CleanupJobInterval = 1 * time.Hour
|
||||
FinishedGameTTL = 1 * time.Hour
|
||||
)
|
||||
|
||||
// Service coordinates game state, user management, and storage
|
||||
type Service struct {
|
||||
games map[string]*game.Game
|
||||
mu sync.RWMutex
|
||||
userMu sync.Mutex
|
||||
store *storage.Store
|
||||
jwtSecret []byte
|
||||
waiter *WaitRegistry
|
||||
computerGames atomic.Int32 // Active games with computer players
|
||||
finishedTTL time.Duration
|
||||
}
|
||||
|
||||
// New creates a new service instance with optional storage
|
||||
func New(store *storage.Store, jwtSecret []byte) *Service {
|
||||
return &Service{
|
||||
games: make(map[string]*game.Game),
|
||||
store: store,
|
||||
jwtSecret: jwtSecret,
|
||||
waiter: NewWaitRegistry(),
|
||||
games: make(map[string]*game.Game),
|
||||
store: store,
|
||||
jwtSecret: jwtSecret,
|
||||
waiter: NewWaitRegistry(),
|
||||
finishedTTL: FinishedGameTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// SetFinishedGameTTL configures how long terminal games remain in memory.
|
||||
// Durable rows and moves are never removed by this cleanup. A non-positive
|
||||
// duration disables terminal-game eviction.
|
||||
func (s *Service) SetFinishedGameTTL(ttl time.Duration) {
|
||||
s.mu.Lock()
|
||||
s.finishedTTL = ttl
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetStorageHealth returns the storage component status
|
||||
func (s *Service) GetStorageHealth() string {
|
||||
if s.store == nil {
|
||||
@@ -55,6 +69,13 @@ func (s *Service) GetStorageHealth() string {
|
||||
|
||||
// RegisterWait registers a client to wait for game state changes
|
||||
func (s *Service) RegisterWait(gameID string, moveCount int, ctx context.Context) <-chan struct{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, ok := s.games[gameID]; !ok {
|
||||
notify := make(chan struct{})
|
||||
close(notify)
|
||||
return notify
|
||||
}
|
||||
return s.waiter.RegisterWait(gameID, moveCount, ctx)
|
||||
}
|
||||
|
||||
@@ -80,6 +101,9 @@ func (s *Service) GetComputerGameCount() int32 {
|
||||
|
||||
// ClaimGameSlot claims a player slot for a user
|
||||
func (s *Service) ClaimGameSlot(gameID string, color core.Color, userID string) error {
|
||||
if userID == "" {
|
||||
return errors.New("claimant user ID is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -88,7 +112,17 @@ func (s *Service) ClaimGameSlot(gameID string, color core.Color, userID string)
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
return g.ClaimSlot(color, userID)
|
||||
if err := g.ClaimSlot(color, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.store != nil {
|
||||
if err := s.store.RecordSlotClaim(gameID, color.String(), userID); err != nil {
|
||||
slog.Error("failed to queue slot claim persistence",
|
||||
"game_id", gameID, "color", color.String(), "error", err)
|
||||
}
|
||||
}
|
||||
slog.Debug("game slot claimed", "game_id", gameID, "color", color.String(), "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSlotOwner returns the user who claimed a slot
|
||||
@@ -113,9 +147,8 @@ func (s *Service) Shutdown(timeout time.Duration) error {
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.games = make(map[string]*game.Game)
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.store != nil {
|
||||
if err := s.store.Close(); err != nil {
|
||||
@@ -128,6 +161,7 @@ func (s *Service) Shutdown(timeout time.Duration) error {
|
||||
|
||||
// RunCleanupJob runs periodic cleanup of expired users and sessions
|
||||
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
|
||||
s.cleanupExpired()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -142,22 +176,49 @@ func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
|
||||
}
|
||||
|
||||
func (s *Service) cleanupExpired() {
|
||||
if s.store == nil {
|
||||
if s.store != nil {
|
||||
if deleted, err := s.store.DeleteExpiredTempUsers(); err != nil {
|
||||
slog.Error("cleanup failed to delete expired users", "error", err)
|
||||
} else if deleted > 0 {
|
||||
slog.Info("cleanup deleted expired temporary users", "count", deleted)
|
||||
}
|
||||
|
||||
if deleted, err := s.store.DeleteExpiredSessions(); err != nil {
|
||||
slog.Error("cleanup failed to delete expired sessions", "error", err)
|
||||
} else if deleted > 0 {
|
||||
slog.Info("cleanup deleted expired sessions", "count", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
s.cleanupFinishedGames(time.Now().UTC())
|
||||
}
|
||||
|
||||
func (s *Service) cleanupFinishedGames(now time.Time) {
|
||||
s.mu.Lock()
|
||||
if s.finishedTTL <= 0 {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Cleanup expired temp users
|
||||
if deleted, err := s.store.DeleteExpiredTempUsers(); err != nil {
|
||||
// Log but don't fail
|
||||
fmt.Printf("cleanup: failed to delete expired users: %v\n", err)
|
||||
} else if deleted > 0 {
|
||||
fmt.Printf("cleanup: deleted %d expired temp users\n", deleted)
|
||||
cutoff := now.Add(-s.finishedTTL)
|
||||
removed := make([]string, 0)
|
||||
for gameID, g := range s.games {
|
||||
ended := g.EndTimeUTC()
|
||||
if !g.State().IsTerminal() || ended == nil || ended.After(cutoff) {
|
||||
continue
|
||||
}
|
||||
if g.HasComputerPlayer() {
|
||||
s.computerGames.Add(-1)
|
||||
}
|
||||
delete(s.games, gameID)
|
||||
removed = append(removed, gameID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Cleanup expired sessions
|
||||
if deleted, err := s.store.DeleteExpiredSessions(); err != nil {
|
||||
fmt.Printf("cleanup: failed to delete expired sessions: %v\n", err)
|
||||
} else if deleted > 0 {
|
||||
fmt.Printf("cleanup: deleted %d expired sessions\n", deleted)
|
||||
for _, gameID := range removed {
|
||||
s.waiter.RemoveGame(gameID)
|
||||
}
|
||||
}
|
||||
if len(removed) > 0 {
|
||||
slog.Info("cleanup evicted terminal games from memory",
|
||||
"count", len(removed), "retention", s.finishedTTL)
|
||||
}
|
||||
}
|
||||
|
||||
+100
-59
@@ -1,8 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
|
||||
var (
|
||||
ErrStorageDisabled = errors.New("storage disabled")
|
||||
ErrStorageUnavailable = errors.New("storage unavailable")
|
||||
ErrAtCapacity = errors.New("at capacity")
|
||||
ErrPermanentSlotsFull = errors.New("permanent slots full")
|
||||
)
|
||||
@@ -30,14 +33,25 @@ type User struct {
|
||||
|
||||
// CreateUser creates new user with registration limits enforcement
|
||||
func (s *Service) CreateUser(username, email, password string, permanent bool) (*User, error) {
|
||||
if s.store == nil {
|
||||
return nil, ErrStorageDisabled
|
||||
}
|
||||
user, _, err := s.createUser(username, email, password, permanent, false)
|
||||
return user, err
|
||||
}
|
||||
|
||||
// Check registration limits
|
||||
total, permCount, _, err := s.store.GetUserCounts()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user count: %w", err)
|
||||
// RegisterUser creates the account and its initial session in one SQLite
|
||||
// transaction, so a successful registration always returns a usable account.
|
||||
func (s *Service) RegisterUser(username, email, password string, permanent bool) (*User, string, error) {
|
||||
return s.createUser(username, email, password, permanent, true)
|
||||
}
|
||||
|
||||
func (s *Service) createUser(
|
||||
username, email, password string,
|
||||
permanent, withSession bool,
|
||||
) (*User, string, error) {
|
||||
s.userMu.Lock()
|
||||
defer s.userMu.Unlock()
|
||||
|
||||
if s.store == nil {
|
||||
return nil, "", ErrStorageDisabled
|
||||
}
|
||||
|
||||
// Determine account type
|
||||
@@ -45,32 +59,22 @@ func (s *Service) CreateUser(username, email, password string, permanent bool) (
|
||||
var expiresAt *time.Time
|
||||
|
||||
if permanent {
|
||||
if permCount >= PermanentSlots {
|
||||
return nil, fmt.Errorf("%w (%d/%d)", ErrPermanentSlotsFull, permCount, PermanentSlots)
|
||||
}
|
||||
accountType = "permanent"
|
||||
} else {
|
||||
expiry := time.Now().UTC().Add(TempUserTTL)
|
||||
expiresAt = &expiry
|
||||
}
|
||||
|
||||
// Handle capacity - remove oldest temp user if at max
|
||||
if total >= MaxUsers {
|
||||
if err := s.removeOldestTempUser(); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrAtCapacity, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
return nil, "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Generate unique user ID
|
||||
userID, err := s.generateUniqueUserID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate unique ID: %w", err)
|
||||
return nil, "", fmt.Errorf("failed to generate unique ID: %w", err)
|
||||
}
|
||||
|
||||
// Create user record
|
||||
@@ -93,35 +97,46 @@ func (s *Service) CreateUser(username, email, password string, permanent bool) (
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
if err = s.store.CreateUser(record); err != nil {
|
||||
return nil, err
|
||||
var sessionID string
|
||||
var session *storage.SessionRecord
|
||||
if withSession {
|
||||
sessionID = uuid.New().String()
|
||||
session = &storage.SessionRecord{
|
||||
SessionID: sessionID,
|
||||
UserID: userID,
|
||||
CreatedAt: user.CreatedAt,
|
||||
ExpiresAt: user.CreatedAt.Add(SessionTTL),
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// removeOldestTempUser removes the oldest temporary user to make room
|
||||
func (s *Service) removeOldestTempUser() error {
|
||||
oldest, err := s.store.GetOldestTempUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("no temp users to remove: %w", err)
|
||||
limits := storage.UserLimits{
|
||||
MaxUsers: MaxUsers,
|
||||
PermanentSlots: PermanentSlots,
|
||||
}
|
||||
|
||||
// Delete their session first
|
||||
_ = s.store.DeleteSessionByUserID(oldest.UserID)
|
||||
|
||||
// Delete the user
|
||||
if err := s.store.DeleteUserByID(oldest.UserID); err != nil {
|
||||
return fmt.Errorf("failed to remove oldest user: %w", err)
|
||||
if err = s.store.CreateUserWithinLimits(record, session, limits); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrUserAlreadyExists):
|
||||
return nil, "", fmt.Errorf("username or email already exists: %w", err)
|
||||
case errors.Is(err, storage.ErrPermanentCapacity):
|
||||
return nil, "", fmt.Errorf("%w (%d maximum)", ErrPermanentSlotsFull, PermanentSlots)
|
||||
case errors.Is(err, storage.ErrUserCapacity):
|
||||
return nil, "", fmt.Errorf("%w: no temporary account can be replaced", ErrAtCapacity)
|
||||
default:
|
||||
return nil, "", fmt.Errorf("%w: create user: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
}
|
||||
slog.Debug("user created",
|
||||
"user_id", userID,
|
||||
"account_type", accountType,
|
||||
"initial_session", withSession,
|
||||
)
|
||||
|
||||
return nil
|
||||
return user, sessionID, nil
|
||||
}
|
||||
|
||||
// AuthenticateUser verifies credentials and creates a new session
|
||||
func (s *Service) AuthenticateUser(identifier, password string) (*User, string, error) {
|
||||
if s.store == nil {
|
||||
return nil, "", fmt.Errorf("storage disabled")
|
||||
return nil, "", ErrStorageDisabled
|
||||
}
|
||||
|
||||
var userRecord *storage.UserRecord
|
||||
@@ -136,6 +151,9 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
|
||||
|
||||
if err != nil {
|
||||
auth.HashPassword(password) // Timing attack prevention
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
@@ -161,11 +179,14 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
|
||||
}
|
||||
|
||||
if err := s.store.CreateSession(sessionRecord); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create session: %w", err)
|
||||
return nil, "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
slog.Debug("user authenticated", "user_id", userRecord.UserID)
|
||||
|
||||
// Update last login
|
||||
_ = s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC())
|
||||
if err := s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC()); err != nil {
|
||||
slog.Warn("failed to record user login time", "user_id", userRecord.UserID, "error", err)
|
||||
}
|
||||
|
||||
return &User{
|
||||
UserID: userRecord.UserID,
|
||||
@@ -180,28 +201,38 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
|
||||
// ValidateSession checks if a session is valid
|
||||
func (s *Service) ValidateSession(sessionID string) (bool, error) {
|
||||
if s.store == nil {
|
||||
return false, fmt.Errorf("storage disabled")
|
||||
return false, ErrStorageDisabled
|
||||
}
|
||||
return s.store.IsSessionValid(sessionID)
|
||||
valid, err := s.store.IsSessionValid(sessionID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%w: validate session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
// InvalidateSession removes a session (logout)
|
||||
func (s *Service) InvalidateSession(sessionID string) error {
|
||||
if s.store == nil {
|
||||
return fmt.Errorf("storage disabled")
|
||||
return ErrStorageDisabled
|
||||
}
|
||||
return s.store.DeleteSession(sessionID)
|
||||
if err := s.store.DeleteSession(sessionID); err != nil {
|
||||
return fmt.Errorf("%w: invalidate session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves user information by user ID
|
||||
func (s *Service) GetUserByID(userID string) (*User, error) {
|
||||
if s.store == nil {
|
||||
return nil, fmt.Errorf("storage disabled")
|
||||
return nil, ErrStorageDisabled
|
||||
}
|
||||
|
||||
userRecord, err := s.store.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
return nil, fmt.Errorf("%w: get user: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
|
||||
return &User{
|
||||
@@ -237,12 +268,19 @@ func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Validate session is still active
|
||||
if sessionID, ok := claims["session_id"].(string); ok && s.store != nil {
|
||||
valid, err := s.store.IsSessionValid(sessionID)
|
||||
if err != nil || !valid {
|
||||
return "", nil, fmt.Errorf("session invalidated")
|
||||
}
|
||||
if s.store == nil {
|
||||
return "", nil, ErrStorageDisabled
|
||||
}
|
||||
sessionID, ok := claims["session_id"].(string)
|
||||
if !ok || sessionID == "" {
|
||||
return "", nil, fmt.Errorf("token has no persisted session")
|
||||
}
|
||||
valid, err := s.store.IsSessionValidForUser(sessionID, userID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("%w: validate token session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
if !valid {
|
||||
return "", nil, fmt.Errorf("session invalidated")
|
||||
}
|
||||
|
||||
return userID, claims, nil
|
||||
@@ -254,19 +292,22 @@ func (s *Service) generateUniqueUserID() (string, error) {
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
id := uuid.New().String()
|
||||
if _, err := s.store.GetUserByID(id); err != nil {
|
||||
if _, err := s.store.GetUserByID(id); errors.Is(err, sql.ErrNoRows) {
|
||||
return id, nil
|
||||
} else if err != nil {
|
||||
return "", fmt.Errorf("%w: check generated user ID: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to generate unique user ID")
|
||||
}
|
||||
|
||||
// CreateUserSession creates a session for a user without re-authenticating
|
||||
// Used after registration to avoid redundant password hashing
|
||||
// CreateUserSession creates a session for a trusted internal caller without
|
||||
// re-authenticating. Public registration uses RegisterUser so account and
|
||||
// initial session creation remain atomic.
|
||||
func (s *Service) CreateUserSession(userID string) (string, error) {
|
||||
if s.store == nil {
|
||||
return "", fmt.Errorf("storage disabled")
|
||||
return "", ErrStorageDisabled
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
@@ -278,9 +319,9 @@ func (s *Service) CreateUserSession(userID string) (string, error) {
|
||||
}
|
||||
|
||||
if err := s.store.CreateSession(sessionRecord); err != nil {
|
||||
return "", fmt.Errorf("failed to create session: %w", err)
|
||||
return "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
slog.Debug("user session created", "user_id", userID)
|
||||
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
user, sessionID, err := svc.RegisterUser("alice", "", "Password1", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
validToken, err := svc.GenerateUserToken(user.UserID, sessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotUserID, _, err := svc.ValidateToken(validToken); err != nil || gotUserID != user.UserID {
|
||||
t.Fatalf("valid token rejected: user=%q err=%v", gotUserID, err)
|
||||
}
|
||||
|
||||
missingSession, err := auth.GenerateHS256Token(svc.jwtSecret, user.UserID, nil, SessionTTL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(missingSession); err == nil {
|
||||
t.Fatal("token without a persisted session was accepted")
|
||||
}
|
||||
|
||||
wrongSubject, err := auth.GenerateHS256Token(svc.jwtSecret, "other-user", map[string]any{
|
||||
"session_id": sessionID,
|
||||
}, SessionTTL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(wrongSubject); err == nil {
|
||||
t.Fatal("session was accepted for a different JWT subject")
|
||||
}
|
||||
|
||||
if err := svc.InvalidateSession(sessionID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(validToken); err == nil {
|
||||
t.Fatal("invalidated session was accepted")
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,12 @@ const (
|
||||
|
||||
// WaitRegistry manages clients waiting for game state changes via long-polling
|
||||
type WaitRegistry struct {
|
||||
mu sync.RWMutex
|
||||
waiters map[string][]*WaitRequest // gameID → waiting clients
|
||||
shutdown chan struct{}
|
||||
wg sync.WaitGroup
|
||||
mu sync.RWMutex
|
||||
waiters map[string][]*WaitRequest // gameID → waiting clients
|
||||
shutdown chan struct{}
|
||||
wg sync.WaitGroup
|
||||
closed bool
|
||||
shutdownOnce sync.Once
|
||||
}
|
||||
|
||||
// WaitRequest represents a single client waiting for game updates
|
||||
@@ -31,6 +33,8 @@ type WaitRequest struct {
|
||||
Timer *time.Timer // Timeout timer
|
||||
Context context.Context // Client connection context
|
||||
GameID string // Game being watched
|
||||
done chan struct{}
|
||||
finish sync.Once
|
||||
}
|
||||
|
||||
// NewWaitRegistry creates a new wait registry
|
||||
@@ -44,7 +48,12 @@ func NewWaitRegistry() *WaitRegistry {
|
||||
// RegisterWait registers a client to wait for game state changes
|
||||
func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Context) <-chan struct{} {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
notify := make(chan struct{})
|
||||
close(notify)
|
||||
return notify
|
||||
}
|
||||
|
||||
// Create wait request
|
||||
req := &WaitRequest{
|
||||
@@ -52,11 +61,12 @@ func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Co
|
||||
Notify: make(chan struct{}, WaitChannelBuffer),
|
||||
Context: ctx,
|
||||
GameID: gameID,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Setup timeout timer
|
||||
req.Timer = time.AfterFunc(WaitTimeout, func() {
|
||||
w.handleTimeout(req)
|
||||
w.complete(req)
|
||||
})
|
||||
|
||||
// Add to waiters map
|
||||
@@ -64,20 +74,15 @@ func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Co
|
||||
|
||||
// Setup cleanup on context cancellation
|
||||
w.wg.Add(1)
|
||||
w.mu.Unlock()
|
||||
go func() {
|
||||
defer w.wg.Done()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Client disconnected
|
||||
w.removeWaiter(gameID, req)
|
||||
case <-req.Notify:
|
||||
// Notification received
|
||||
req.Timer.Stop()
|
||||
w.removeWaiter(gameID, req)
|
||||
w.complete(req)
|
||||
case <-w.shutdown:
|
||||
// Server shutting down
|
||||
req.Timer.Stop()
|
||||
close(req.Notify)
|
||||
w.complete(req)
|
||||
case <-req.done:
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -87,7 +92,7 @@ func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Co
|
||||
// NotifyGame notifies all clients waiting on a game about state change
|
||||
func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int, state core.State) {
|
||||
w.mu.RLock()
|
||||
waitList := w.waiters[gameID]
|
||||
waitList := append([]*WaitRequest(nil), w.waiters[gameID]...)
|
||||
w.mu.RUnlock()
|
||||
if len(waitList) == 0 {
|
||||
return
|
||||
@@ -95,33 +100,31 @@ func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int, state cor
|
||||
settled := state != core.StateOngoing && state != core.StatePending
|
||||
for _, req := range waitList {
|
||||
if settled || req.MoveCount != currentMoveCount {
|
||||
select {
|
||||
case req.Notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
w.complete(req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveGame removes all waiters for a game (called before game deletion)
|
||||
func (w *WaitRegistry) RemoveGame(gameID string) {
|
||||
w.mu.Lock()
|
||||
waitList := w.waiters[gameID]
|
||||
delete(w.waiters, gameID)
|
||||
w.mu.Unlock()
|
||||
w.mu.RLock()
|
||||
waitList := append([]*WaitRequest(nil), w.waiters[gameID]...)
|
||||
w.mu.RUnlock()
|
||||
|
||||
// Notify all waiters that game is gone
|
||||
for _, req := range waitList {
|
||||
select {
|
||||
case req.Notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
w.complete(req)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the wait registry
|
||||
func (w *WaitRegistry) Shutdown(timeout time.Duration) error {
|
||||
close(w.shutdown)
|
||||
w.shutdownOnce.Do(func() {
|
||||
w.mu.Lock()
|
||||
w.closed = true
|
||||
close(w.shutdown)
|
||||
w.mu.Unlock()
|
||||
})
|
||||
|
||||
// Wait for all goroutines with timeout
|
||||
done := make(chan struct{})
|
||||
@@ -138,36 +141,27 @@ func (w *WaitRegistry) Shutdown(timeout time.Duration) error {
|
||||
}
|
||||
}
|
||||
|
||||
// handleTimeout handles wait request timeout
|
||||
func (w *WaitRegistry) handleTimeout(req *WaitRequest) {
|
||||
// Send timeout notification
|
||||
select {
|
||||
case req.Notify <- struct{}{}:
|
||||
// Timeout notification sent
|
||||
default:
|
||||
// Channel full or closed
|
||||
}
|
||||
}
|
||||
// complete removes a waiter and closes its notification channel exactly once.
|
||||
// The registry never consumes Notify itself: the HTTP handler is its sole
|
||||
// consumer, so a state-change signal cannot be lost to a cleanup goroutine.
|
||||
func (w *WaitRegistry) complete(req *WaitRequest) {
|
||||
req.finish.Do(func() {
|
||||
req.Timer.Stop()
|
||||
|
||||
// removeWaiter removes a specific waiter from the registry
|
||||
func (w *WaitRegistry) removeWaiter(gameID string, req *WaitRequest) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
waitList := w.waiters[gameID]
|
||||
for i, waiter := range waitList {
|
||||
if waiter == req {
|
||||
// Remove from slice
|
||||
w.waiters[gameID] = append(waitList[:i], waitList[i+1:]...)
|
||||
break
|
||||
w.mu.Lock()
|
||||
waitList := w.waiters[req.GameID]
|
||||
for i, waiter := range waitList {
|
||||
if waiter == req {
|
||||
w.waiters[req.GameID] = append(waitList[:i], waitList[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(w.waiters[req.GameID]) == 0 {
|
||||
delete(w.waiters, req.GameID)
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
// Clean up empty entries
|
||||
if len(w.waiters[gameID]) == 0 {
|
||||
delete(w.waiters, gameID)
|
||||
}
|
||||
|
||||
// Stop timer if still running
|
||||
req.Timer.Stop()
|
||||
close(req.done)
|
||||
close(req.Notify)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
)
|
||||
|
||||
func TestWaitRegistryDeliversNotificationToCallerAndRemovesWaiter(t *testing.T) {
|
||||
registry := NewWaitRegistry()
|
||||
notify := registry.RegisterWait("game-1", 0, context.Background())
|
||||
|
||||
registry.NotifyGame("game-1", 1, core.StateOngoing)
|
||||
select {
|
||||
case <-notify:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("game update was consumed before reaching the caller")
|
||||
}
|
||||
select {
|
||||
case _, ok := <-notify:
|
||||
if ok {
|
||||
t.Fatal("completed notification channel returned another value")
|
||||
}
|
||||
default:
|
||||
t.Fatal("completed notification channel was not closed")
|
||||
}
|
||||
|
||||
registry.mu.RLock()
|
||||
remaining := len(registry.waiters["game-1"])
|
||||
registry.mu.RUnlock()
|
||||
if remaining != 0 {
|
||||
t.Fatalf("completed waiter remains registered: %d", remaining)
|
||||
}
|
||||
if err := registry.Shutdown(time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitRegistryCompletionAndShutdownAreConcurrentAndIdempotent(t *testing.T) {
|
||||
registry := NewWaitRegistry()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
notify := registry.RegisterWait("game-1", 0, ctx)
|
||||
|
||||
var callers sync.WaitGroup
|
||||
callers.Add(3)
|
||||
go func() {
|
||||
defer callers.Done()
|
||||
registry.NotifyGame("game-1", 1, core.StateOngoing)
|
||||
}()
|
||||
go func() {
|
||||
defer callers.Done()
|
||||
registry.RemoveGame("game-1")
|
||||
}()
|
||||
go func() {
|
||||
defer callers.Done()
|
||||
cancel()
|
||||
}()
|
||||
callers.Wait()
|
||||
|
||||
select {
|
||||
case <-notify:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("concurrent completion did not notify caller")
|
||||
}
|
||||
if err := registry.Shutdown(time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Shutdown(time.Second); err != nil {
|
||||
t.Fatalf("second shutdown failed: %v", err)
|
||||
}
|
||||
|
||||
afterShutdown := registry.RegisterWait("game-2", 0, context.Background())
|
||||
select {
|
||||
case <-afterShutdown:
|
||||
default:
|
||||
t.Fatal("registration after shutdown did not return a closed signal")
|
||||
}
|
||||
}
|
||||
+426
-86
@@ -1,137 +1,477 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RecordNewGame asynchronously records a new game
|
||||
const gameSelectColumns = `
|
||||
g.game_id, g.initial_fen,
|
||||
g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by,
|
||||
g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by,
|
||||
g.result, g.start_time_utc, g.end_time_utc`
|
||||
|
||||
// RecordNewGame asynchronously records a new game. Terminal custom-FEN games
|
||||
// include their result in this insert rather than relying on a second write.
|
||||
func (s *Store) RecordNewGame(record GameRecord) error {
|
||||
if !s.healthStatus.Load() {
|
||||
return nil // Silently drop if degraded
|
||||
if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" {
|
||||
return errors.New("game ID, initial FEN, and player IDs are required")
|
||||
}
|
||||
if err := validateResultTime(record.Result, record.EndTimeUTC); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case s.writeChan <- func(tx *sql.Tx) error {
|
||||
query := `INSERT INTO games (
|
||||
game_id, initial_fen,
|
||||
white_player_id, white_type, white_level, white_search_time,
|
||||
black_player_id, black_type, black_level, black_search_time,
|
||||
start_time_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
return s.enqueue("record_game", record.GameID, func(tx *sql.Tx) error {
|
||||
const query = `INSERT INTO games (
|
||||
game_id, initial_fen,
|
||||
white_player_id, white_type, white_level, white_search_time, white_claimed_by,
|
||||
black_player_id, black_type, black_level, black_search_time, black_claimed_by,
|
||||
start_time_utc, result, end_time_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
_, err := tx.Exec(query,
|
||||
record.GameID, record.InitialFEN,
|
||||
record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime,
|
||||
nullableString(record.WhiteClaimedBy),
|
||||
record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime,
|
||||
record.StartTimeUTC,
|
||||
nullableString(record.BlackClaimedBy),
|
||||
record.StartTimeUTC, nullableString(record.Result), record.EndTimeUTC,
|
||||
)
|
||||
return err
|
||||
}:
|
||||
return nil
|
||||
default:
|
||||
// Channel full, drop write
|
||||
log.Printf("Storage write queue full, dropping game record")
|
||||
return nil
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// RecordMove asynchronously records a move
|
||||
func (s *Store) RecordMove(record MoveRecord) error {
|
||||
if !s.healthStatus.Load() {
|
||||
return nil // Silently drop if degraded
|
||||
// RecordMove atomically persists an accepted move and any first-move claim or
|
||||
// terminal result caused by that move.
|
||||
func (s *Store) RecordMove(record MovePersistence) error {
|
||||
if record.Move.GameID == "" || record.Move.MoveNumber < 1 ||
|
||||
record.Move.MoveUCI == "" || record.Move.FENAfterMove == "" {
|
||||
return errors.New("move game ID, positive move number, UCI, and resulting FEN are required")
|
||||
}
|
||||
if record.Move.PlayerColor != "w" && record.Move.PlayerColor != "b" {
|
||||
return fmt.Errorf("invalid move color %q", record.Move.PlayerColor)
|
||||
}
|
||||
if record.ClaimColor != "" && record.ClaimColor != "w" && record.ClaimColor != "b" {
|
||||
return fmt.Errorf("invalid claim color %q", record.ClaimColor)
|
||||
}
|
||||
if (record.ClaimColor == "") != (record.ClaimedBy == "") {
|
||||
return errors.New("claim color and claimant must be provided together")
|
||||
}
|
||||
if err := validateResultTime(record.Result, record.EndTimeUTC); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case s.writeChan <- func(tx *sql.Tx) error {
|
||||
query := `INSERT INTO moves (
|
||||
return s.enqueue("record_move", record.Move.GameID, func(tx *sql.Tx) error {
|
||||
const insertMove = `INSERT INTO moves (
|
||||
game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
if _, err := tx.Exec(insertMove,
|
||||
record.Move.GameID,
|
||||
record.Move.MoveNumber,
|
||||
record.Move.MoveUCI,
|
||||
record.Move.FENAfterMove,
|
||||
record.Move.PlayerColor,
|
||||
record.Move.MoveTimeUTC,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := tx.Exec(query,
|
||||
record.GameID, record.MoveNumber, record.MoveUCI,
|
||||
record.FENAfterMove, record.PlayerColor, record.MoveTimeUTC,
|
||||
if record.ClaimedBy != "" {
|
||||
column := "white_claimed_by"
|
||||
if record.ClaimColor == "b" {
|
||||
column = "black_claimed_by"
|
||||
}
|
||||
query := `UPDATE games SET ` + column + ` = ?
|
||||
WHERE game_id = ? AND (` + column + ` IS NULL OR ` + column + ` = '' OR ` + column + ` = ?)`
|
||||
result, err := tx.Exec(query, record.ClaimedBy, record.Move.GameID, record.ClaimedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireOneGame(result, record.Move.GameID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if record.Result != "" {
|
||||
result, err := tx.Exec(
|
||||
`UPDATE games SET result = ?, end_time_utc = ? WHERE game_id = ?`,
|
||||
record.Result, record.EndTimeUTC, record.Move.GameID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(result, record.Move.GameID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RecordGameResult persists a terminal transition not accompanied by a move,
|
||||
// such as a no-legal-moves engine response.
|
||||
func (s *Store) RecordGameResult(gameID, result string, at time.Time) error {
|
||||
if gameID == "" {
|
||||
return errors.New("game ID is required")
|
||||
}
|
||||
if !isValidResult(result) {
|
||||
return fmt.Errorf("invalid game result %q", result)
|
||||
}
|
||||
if at.IsZero() {
|
||||
return errors.New("game result time is required")
|
||||
}
|
||||
return s.enqueue("record_game_result", gameID, func(tx *sql.Tx) error {
|
||||
res, err := tx.Exec(
|
||||
`UPDATE games SET result = ?, end_time_utc = ? WHERE game_id = ?`,
|
||||
result, at.UTC(), gameID,
|
||||
)
|
||||
return err
|
||||
}:
|
||||
return nil
|
||||
default:
|
||||
// Channel full, drop write
|
||||
log.Printf("Storage write queue full, dropping move record")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteUndoneMoves asynchronously deletes moves after undo
|
||||
func (s *Store) DeleteUndoneMoves(gameID string, afterMoveNumber int) error {
|
||||
if !s.healthStatus.Load() {
|
||||
return nil // Silently drop if degraded
|
||||
// RecordSlotClaim persists a claim made independently from a move.
|
||||
func (s *Store) RecordSlotClaim(gameID, color, userID string) error {
|
||||
if gameID == "" || userID == "" {
|
||||
return errors.New("game ID and claimant are required")
|
||||
}
|
||||
|
||||
select {
|
||||
case s.writeChan <- func(tx *sql.Tx) error {
|
||||
query := `DELETE FROM moves WHERE game_id = ? AND move_number > ?`
|
||||
_, err := tx.Exec(query, gameID, afterMoveNumber)
|
||||
return err
|
||||
}:
|
||||
return nil
|
||||
default:
|
||||
// Channel full, drop write
|
||||
log.Printf("Storage write queue full, dropping undo operation")
|
||||
return nil
|
||||
if color != "w" && color != "b" {
|
||||
return fmt.Errorf("invalid claim color %q", color)
|
||||
}
|
||||
column := "white_claimed_by"
|
||||
if color == "b" {
|
||||
column = "black_claimed_by"
|
||||
}
|
||||
return s.enqueue("record_slot_claim", gameID, func(tx *sql.Tx) error {
|
||||
query := `UPDATE games SET ` + column + ` = ?
|
||||
WHERE game_id = ? AND (` + column + ` IS NULL OR ` + column + ` = '' OR ` + column + ` = ?)`
|
||||
res, err := tx.Exec(query, userID, gameID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// QueryGames retrieves games with optional filtering
|
||||
// RecordPlayers keeps persisted player configuration aligned with in-memory
|
||||
// configuration changes.
|
||||
func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error {
|
||||
if gameID == "" || white.PlayerID == "" || black.PlayerID == "" {
|
||||
return errors.New("game ID and player IDs are required")
|
||||
}
|
||||
return s.enqueue("record_players", gameID, func(tx *sql.Tx) error {
|
||||
const query = `UPDATE games SET
|
||||
white_player_id = ?, white_type = ?, white_level = ?, white_search_time = ?, white_claimed_by = ?,
|
||||
black_player_id = ?, black_type = ?, black_level = ?, black_search_time = ?, black_claimed_by = ?
|
||||
WHERE game_id = ?`
|
||||
res, err := tx.Exec(query,
|
||||
white.PlayerID, white.Type, white.Level, white.SearchTime, nullableString(white.ClaimedBy),
|
||||
black.PlayerID, black.Type, black.Level, black.SearchTime, nullableString(black.ClaimedBy),
|
||||
gameID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// PlayerRecord is the persistence subset of a player configuration.
|
||||
type PlayerRecord struct {
|
||||
PlayerID string
|
||||
Type int
|
||||
Level int
|
||||
SearchTime int
|
||||
ClaimedBy string
|
||||
}
|
||||
|
||||
// RewindGame atomically removes undone moves and clears a previously terminal
|
||||
// result so replay readers never observe an ongoing line with a stale outcome.
|
||||
func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
|
||||
if gameID == "" || afterMoveNumber < 0 {
|
||||
return errors.New("game ID and a non-negative move number are required")
|
||||
}
|
||||
return s.enqueue("rewind_game", gameID, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM moves WHERE game_id = ? AND move_number > ?`,
|
||||
gameID, afterMoveNumber,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(
|
||||
`UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = ?`,
|
||||
gameID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// QueryGames retrieves games with optional filtering. A player filter matches
|
||||
// both creation-time player IDs and claims made after game creation.
|
||||
func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
|
||||
query := `SELECT
|
||||
game_id, initial_fen,
|
||||
white_player_id, white_type, white_level, white_search_time,
|
||||
black_player_id, black_type, black_level, black_search_time,
|
||||
start_time_utc
|
||||
FROM games WHERE 1=1`
|
||||
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
started := time.Now()
|
||||
query := `SELECT ` + gameSelectColumns + ` FROM games g WHERE 1=1`
|
||||
var args []any
|
||||
|
||||
// Handle gameID filtering
|
||||
if gameID != "" && gameID != "*" {
|
||||
query += " AND game_id = ?"
|
||||
query += " AND g.game_id = ?"
|
||||
args = append(args, gameID)
|
||||
}
|
||||
|
||||
// Handle playerID filtering
|
||||
if playerID != "" && playerID != "*" {
|
||||
query += " AND (white_player_id = ? OR black_player_id = ?)"
|
||||
args = append(args, playerID, playerID)
|
||||
query += ` AND (g.white_player_id = ? OR g.black_player_id = ?
|
||||
OR g.white_claimed_by = ? OR g.black_claimed_by = ?)`
|
||||
args = append(args, playerID, playerID, playerID, playerID)
|
||||
}
|
||||
|
||||
query += " ORDER BY start_time_utc DESC"
|
||||
query += " ORDER BY g.start_time_utc DESC, g.game_id DESC"
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query failed: %w", err)
|
||||
return nil, fmt.Errorf("query games: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var games []GameRecord
|
||||
games := make([]GameRecord, 0)
|
||||
for rows.Next() {
|
||||
var g GameRecord
|
||||
err := rows.Scan(
|
||||
&g.GameID, &g.InitialFEN,
|
||||
&g.WhitePlayerID, &g.WhiteType, &g.WhiteLevel, &g.WhiteSearchTime,
|
||||
&g.BlackPlayerID, &g.BlackType, &g.BlackLevel, &g.BlackSearchTime,
|
||||
&g.StartTimeUTC,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan failed: %w", err)
|
||||
var record GameRecord
|
||||
if err := scanGame(rows, &record); err != nil {
|
||||
return nil, fmt.Errorf("scan game: %w", err)
|
||||
}
|
||||
games = append(games, g)
|
||||
games = append(games, record)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration failed: %w", err)
|
||||
return nil, fmt.Errorf("iterate games: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("storage games queried", "count", len(games), "duration", time.Since(started))
|
||||
return games, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) GetGameRecord(gameID string) (*GameRecord, error) {
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return getGameRecord(s.db, gameID)
|
||||
}
|
||||
|
||||
type gameQueryer interface {
|
||||
Query(query string, args ...any) (*sql.Rows, error)
|
||||
QueryRow(query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
func getGameRecord(queryer gameQueryer, gameID string) (*GameRecord, error) {
|
||||
var record GameRecord
|
||||
row := queryer.QueryRow(`SELECT `+gameSelectColumns+` FROM games g WHERE g.game_id = ?`, gameID)
|
||||
if err := scanGame(row, &record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// GetMovesForGame returns the complete, undo-consistent replay line.
|
||||
func (s *Store) GetMovesForGame(gameID string) ([]MoveRecord, error) {
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return getMovesForGame(s.db, gameID)
|
||||
}
|
||||
|
||||
func getMovesForGame(queryer gameQueryer, gameID string) ([]MoveRecord, error) {
|
||||
const query = `SELECT move_id, game_id, move_number, move_uci,
|
||||
fen_after_move, player_color, move_time_utc
|
||||
FROM moves WHERE game_id = ? ORDER BY move_number ASC`
|
||||
rows, err := queryer.Query(query, gameID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query game moves: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
moves := make([]MoveRecord, 0)
|
||||
for rows.Next() {
|
||||
var move MoveRecord
|
||||
if err := rows.Scan(
|
||||
&move.MoveID, &move.GameID, &move.MoveNumber, &move.MoveUCI,
|
||||
&move.FENAfterMove, &move.PlayerColor, &move.MoveTimeUTC,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan game move: %w", err)
|
||||
}
|
||||
moves = append(moves, move)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate game moves: %w", err)
|
||||
}
|
||||
return moves, nil
|
||||
}
|
||||
|
||||
// GetGameHistory uses one write barrier and one read transaction for a
|
||||
// consistent game-and-moves snapshot.
|
||||
func (s *Store) GetGameHistory(gameID string) (*GameRecord, []MoveRecord, error) {
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
started := time.Now()
|
||||
tx, err := s.db.BeginTx(context.Background(), &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("begin game history read: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
record, err := getGameRecord(tx, gameID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
moves, err := getMovesForGame(tx, gameID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, nil, fmt.Errorf("finish game history read: %w", err)
|
||||
}
|
||||
slog.Debug("storage game history queried",
|
||||
"game_id", gameID, "move_count", len(moves), "duration", time.Since(started))
|
||||
return record, moves, nil
|
||||
}
|
||||
|
||||
func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSummaryRecord, error) {
|
||||
if userID == "" || limit < 1 || limit > 101 || offset < 0 {
|
||||
return nil, errors.New("user ID, limit from 1 to 101, and non-negative offset are required")
|
||||
}
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
started := time.Now()
|
||||
query := `SELECT ` + gameSelectColumns + `,
|
||||
(SELECT COUNT(*) FROM moves m WHERE m.game_id = g.game_id) AS move_count
|
||||
FROM games g
|
||||
WHERE g.white_player_id = ? OR g.black_player_id = ?
|
||||
OR g.white_claimed_by = ? OR g.black_claimed_by = ?
|
||||
ORDER BY g.start_time_utc DESC, g.game_id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
rows, err := s.db.Query(query, userID, userID, userID, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query user games: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
games := make([]GameSummaryRecord, 0)
|
||||
for rows.Next() {
|
||||
var summary GameSummaryRecord
|
||||
if err := scanGameSummary(rows, &summary); err != nil {
|
||||
return nil, fmt.Errorf("scan user game: %w", err)
|
||||
}
|
||||
games = append(games, summary)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate user games: %w", err)
|
||||
}
|
||||
slog.Debug("storage user games queried",
|
||||
"user_id", userID,
|
||||
"count", len(games),
|
||||
"limit", limit,
|
||||
"offset", offset,
|
||||
"duration", time.Since(started),
|
||||
)
|
||||
return games, nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanGame(scanner rowScanner, record *GameRecord) error {
|
||||
var whiteClaimed, blackClaimed, result sql.NullString
|
||||
var endTime sql.NullTime
|
||||
if err := scanner.Scan(
|
||||
&record.GameID, &record.InitialFEN,
|
||||
&record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime, &whiteClaimed,
|
||||
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime, &blackClaimed,
|
||||
&result, &record.StartTimeUTC, &endTime,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
record.WhiteClaimedBy = whiteClaimed.String
|
||||
record.BlackClaimedBy = blackClaimed.String
|
||||
record.Result = result.String
|
||||
if endTime.Valid {
|
||||
ended := endTime.Time
|
||||
record.EndTimeUTC = &ended
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanGameSummary(scanner rowScanner, summary *GameSummaryRecord) error {
|
||||
var whiteClaimed, blackClaimed, result sql.NullString
|
||||
var endTime sql.NullTime
|
||||
if err := scanner.Scan(
|
||||
&summary.GameID, &summary.InitialFEN,
|
||||
&summary.WhitePlayerID, &summary.WhiteType, &summary.WhiteLevel, &summary.WhiteSearchTime, &whiteClaimed,
|
||||
&summary.BlackPlayerID, &summary.BlackType, &summary.BlackLevel, &summary.BlackSearchTime, &blackClaimed,
|
||||
&result, &summary.StartTimeUTC, &endTime, &summary.MoveCount,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
summary.WhiteClaimedBy = whiteClaimed.String
|
||||
summary.BlackClaimedBy = blackClaimed.String
|
||||
summary.Result = result.String
|
||||
if endTime.Valid {
|
||||
ended := endTime.Time
|
||||
summary.EndTimeUTC = &ended
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireOneGame(result sql.Result, gameID string) error {
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows != 1 {
|
||||
return fmt.Errorf("game %s was not updated", gameID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func isValidResult(result string) bool {
|
||||
switch result {
|
||||
case "white_wins", "black_wins", "draw", "stalemate":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateResultTime(result string, ended *time.Time) error {
|
||||
if result == "" {
|
||||
if ended != nil {
|
||||
return errors.New("end time requires a game result")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isValidResult(result) {
|
||||
return fmt.Errorf("invalid game result %q", result)
|
||||
}
|
||||
if ended == nil || ended.IsZero() {
|
||||
return errors.New("terminal game result requires an end time")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsGameNotFound keeps callers independent from database/sql details.
|
||||
func IsGameNotFound(err error) bool {
|
||||
return errors.Is(err, sql.ErrNoRows)
|
||||
}
|
||||
|
||||
@@ -24,17 +24,26 @@ type SessionRecord struct {
|
||||
|
||||
// GameRecord represents a row in the games table
|
||||
type GameRecord struct {
|
||||
GameID string `db:"game_id"`
|
||||
InitialFEN string `db:"initial_fen"`
|
||||
WhitePlayerID string `db:"white_player_id"`
|
||||
WhiteType int `db:"white_type"`
|
||||
WhiteLevel int `db:"white_level"`
|
||||
WhiteSearchTime int `db:"white_search_time"`
|
||||
BlackPlayerID string `db:"black_player_id"`
|
||||
BlackType int `db:"black_type"`
|
||||
BlackLevel int `db:"black_level"`
|
||||
BlackSearchTime int `db:"black_search_time"`
|
||||
StartTimeUTC time.Time `db:"start_time_utc"`
|
||||
GameID string `db:"game_id"`
|
||||
InitialFEN string `db:"initial_fen"`
|
||||
WhitePlayerID string `db:"white_player_id"`
|
||||
WhiteType int `db:"white_type"`
|
||||
WhiteLevel int `db:"white_level"`
|
||||
WhiteSearchTime int `db:"white_search_time"`
|
||||
WhiteClaimedBy string `db:"white_claimed_by"`
|
||||
BlackPlayerID string `db:"black_player_id"`
|
||||
BlackType int `db:"black_type"`
|
||||
BlackLevel int `db:"black_level"`
|
||||
BlackSearchTime int `db:"black_search_time"`
|
||||
BlackClaimedBy string `db:"black_claimed_by"`
|
||||
Result string `db:"result"`
|
||||
StartTimeUTC time.Time `db:"start_time_utc"`
|
||||
EndTimeUTC *time.Time `db:"end_time_utc"`
|
||||
}
|
||||
|
||||
type GameSummaryRecord struct {
|
||||
GameRecord
|
||||
MoveCount int `db:"move_count"`
|
||||
}
|
||||
|
||||
// MoveRecord represents a row in the moves table
|
||||
@@ -48,7 +57,19 @@ type MoveRecord struct {
|
||||
MoveTimeUTC time.Time `db:"move_time_utc"`
|
||||
}
|
||||
|
||||
// Schema defines the SQLite database structure
|
||||
// MovePersistence groups changes caused by one accepted move so the move,
|
||||
// first-move slot claim, and terminal result commit in one transaction.
|
||||
type MovePersistence struct {
|
||||
Move MoveRecord
|
||||
ClaimColor string
|
||||
ClaimedBy string
|
||||
Result string
|
||||
EndTimeUTC *time.Time
|
||||
}
|
||||
|
||||
// Schema defines tables only. Indexes are applied after legacy column
|
||||
// migrations so upgrading an older games table never references a missing
|
||||
// column.
|
||||
const Schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
@@ -61,12 +82,6 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
last_login_at DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_account_type ON users(account_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_expires_at ON users(expires_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(email) WHERE email IS NOT NULL AND email != '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
@@ -75,9 +90,6 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
game_id TEXT PRIMARY KEY,
|
||||
initial_fen TEXT NOT NULL,
|
||||
@@ -89,7 +101,11 @@ CREATE TABLE IF NOT EXISTS games (
|
||||
black_type INTEGER NOT NULL,
|
||||
black_level INTEGER NOT NULL DEFAULT 0,
|
||||
black_search_time INTEGER NOT NULL DEFAULT 1000,
|
||||
start_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
start_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
result TEXT CHECK(result IS NULL OR result IN ('white_wins', 'black_wins', 'draw', 'stalemate')),
|
||||
end_time_utc DATETIME,
|
||||
white_claimed_by TEXT,
|
||||
black_claimed_by TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS moves (
|
||||
@@ -103,8 +119,20 @@ CREATE TABLE IF NOT EXISTS moves (
|
||||
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
|
||||
UNIQUE(game_id, move_number)
|
||||
);
|
||||
`
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_moves_game_id ON moves(game_id);
|
||||
const Indexes = `
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
|
||||
ON users(email) WHERE email IS NOT NULL AND email != '';
|
||||
CREATE INDEX IF NOT EXISTS idx_users_temp_created_at
|
||||
ON users(created_at) WHERE account_type = 'temp';
|
||||
CREATE INDEX IF NOT EXISTS idx_users_temp_expires_at
|
||||
ON users(expires_at) WHERE account_type = 'temp' AND expires_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_white_player ON games(white_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_black_player ON games(black_player_id);
|
||||
`
|
||||
CREATE INDEX IF NOT EXISTS idx_games_white_claimed ON games(white_claimed_by)
|
||||
WHERE white_claimed_by IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_games_black_claimed ON games(black_claimed_by)
|
||||
WHERE black_claimed_by IS NOT NULL;
|
||||
`
|
||||
|
||||
@@ -2,30 +2,23 @@ package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateSession creates or replaces the session for a user (single session per user)
|
||||
func (s *Store) CreateSession(record SessionRecord) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete any existing session for this user
|
||||
deleteQuery := `DELETE FROM sessions WHERE user_id = ?`
|
||||
if _, err := tx.Exec(deleteQuery, record.UserID); err != nil {
|
||||
return fmt.Errorf("failed to delete existing session: %w", err)
|
||||
}
|
||||
|
||||
// Insert new session
|
||||
insertQuery := `INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`
|
||||
if _, err := tx.Exec(insertQuery, record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt); err != nil {
|
||||
const query = `INSERT INTO sessions (session_id, user_id, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
created_at = excluded.created_at,
|
||||
expires_at = excluded.expires_at`
|
||||
if _, err := s.db.Exec(query, record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt); err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
slog.Debug("storage session created", "user_id", record.UserID, "expires_at", record.ExpiresAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSession retrieves a session by ID
|
||||
@@ -60,6 +53,9 @@ func (s *Store) GetSessionByUserID(userID string) (*SessionRecord, error) {
|
||||
func (s *Store) DeleteSession(sessionID string) error {
|
||||
query := `DELETE FROM sessions WHERE session_id = ?`
|
||||
_, err := s.db.Exec(query, sessionID)
|
||||
if err == nil {
|
||||
slog.Debug("storage session deleted")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -67,6 +63,9 @@ func (s *Store) DeleteSession(sessionID string) error {
|
||||
func (s *Store) DeleteSessionByUserID(userID string) error {
|
||||
query := `DELETE FROM sessions WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, userID)
|
||||
if err == nil {
|
||||
slog.Debug("storage user sessions deleted", "user_id", userID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -77,16 +76,38 @@ func (s *Store) DeleteExpiredSessions() (int64, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
deleted, err := result.RowsAffected()
|
||||
if err == nil && deleted > 0 {
|
||||
slog.Debug("storage expired sessions deleted", "count", deleted)
|
||||
}
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
// IsSessionValid checks if a session exists and is not expired
|
||||
func (s *Store) IsSessionValid(sessionID string) (bool, error) {
|
||||
var count int
|
||||
query := `SELECT COUNT(*) FROM sessions WHERE session_id = ? AND expires_at > ?`
|
||||
err := s.db.QueryRow(query, sessionID, time.Now().UTC()).Scan(&count)
|
||||
var valid bool
|
||||
const query = `SELECT EXISTS(
|
||||
SELECT 1 FROM sessions WHERE session_id = ? AND expires_at > ?
|
||||
)`
|
||||
err := s.db.QueryRow(query, sessionID, time.Now().UTC()).Scan(&valid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
// IsSessionValidForUser verifies both expiry and the binding between a JWT
|
||||
// subject and its persisted session. Checking only the session ID would allow
|
||||
// a malformed server-issued token to authenticate as the wrong subject.
|
||||
func (s *Store) IsSessionValidForUser(sessionID, userID string) (bool, error) {
|
||||
var valid bool
|
||||
const query = `SELECT EXISTS(
|
||||
SELECT 1 FROM sessions
|
||||
WHERE session_id = ? AND user_id = ? AND expires_at > ?
|
||||
)`
|
||||
err := s.db.QueryRow(query, sessionID, userID, time.Now().UTC()).Scan(&valid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -13,48 +15,67 @@ import (
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const (
|
||||
writeQueueCapacity = 1000
|
||||
flushTimeout = 5 * time.Second
|
||||
schemaVersion = 2
|
||||
)
|
||||
|
||||
var memoryStoreCounter atomic.Uint64
|
||||
|
||||
var (
|
||||
ErrStorageDegraded = errors.New("storage is degraded")
|
||||
ErrStoreClosed = errors.New("storage is closed")
|
||||
ErrWriteQueueFull = errors.New("storage write queue is full")
|
||||
)
|
||||
|
||||
type writeRequest struct {
|
||||
operation string
|
||||
gameID string
|
||||
run func(*sql.Tx) error
|
||||
barrier chan error
|
||||
}
|
||||
|
||||
// Store handles SQLite database operations with async writes for games and sync writes for auth
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
writeChan chan func(*sql.Tx) error
|
||||
writeChan chan writeRequest
|
||||
healthStatus atomic.Bool
|
||||
writeFailed atomic.Bool
|
||||
closed atomic.Bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
enqueueMu sync.RWMutex
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// NewStore creates a new storage instance with async writer
|
||||
func NewStore(dataSourceName string, devMode bool) (*Store, error) {
|
||||
db, err := sql.Open("sqlite3", dataSourceName)
|
||||
dsn := sqliteDSN(dataSourceName)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// Enable WAL mode in development for better concurrency
|
||||
if devMode {
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to enable WAL mode: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Enable foreign keys
|
||||
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Configure connection pool
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
// SQLite benefits from a small pool. WAL and busy_timeout are configured in
|
||||
// the DSN for every connection, unlike connection-local PRAGMA calls.
|
||||
db.SetMaxOpenConns(8)
|
||||
db.SetMaxIdleConns(4)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
s := &Store{
|
||||
db: db,
|
||||
path: dataSourceName,
|
||||
writeChan: make(chan func(*sql.Tx) error, 1000), // Buffered for async writes
|
||||
writeChan: make(chan writeRequest, writeQueueCapacity),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
@@ -65,10 +86,34 @@ func NewStore(dataSourceName string, devMode bool) (*Store, error) {
|
||||
// Start async writer
|
||||
s.wg.Add(1)
|
||||
go s.writerLoop()
|
||||
slog.Debug("storage opened",
|
||||
"path", dataSourceName,
|
||||
"dev_mode", devMode,
|
||||
"write_queue_capacity", writeQueueCapacity,
|
||||
"max_open_connections", 8,
|
||||
)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func sqliteDSN(dataSourceName string) string {
|
||||
if dataSourceName == ":memory:" {
|
||||
// Each Store needs a private shared-cache database: shared cache keeps
|
||||
// that Store's pooled connections on one database, while the unique name
|
||||
// prevents independent in-memory stores from leaking into each other.
|
||||
dataSourceName = fmt.Sprintf(
|
||||
"file:chess-memory-%d?mode=memory&cache=shared",
|
||||
memoryStoreCounter.Add(1),
|
||||
)
|
||||
}
|
||||
separator := "?"
|
||||
if strings.Contains(dataSourceName, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
return dataSourceName + separator +
|
||||
"_foreign_keys=on&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL"
|
||||
}
|
||||
|
||||
// IsHealthy returns true if the storage is operational
|
||||
func (s *Store) IsHealthy() bool {
|
||||
return s.healthStatus.Load()
|
||||
@@ -77,96 +122,291 @@ func (s *Store) IsHealthy() bool {
|
||||
// writerLoop processes async write operations
|
||||
func (s *Store) writerLoop() {
|
||||
defer s.wg.Done()
|
||||
slog.Debug("storage writer started")
|
||||
defer slog.Debug("storage writer stopped")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
// Drain remaining writes with timeout
|
||||
deadline := time.After(2 * time.Second)
|
||||
// Every accepted operation is drained before shutdown. The queue is
|
||||
// bounded, so shutdown remains bounded by actual database work rather
|
||||
// than an arbitrary timer that can discard replay history.
|
||||
for {
|
||||
select {
|
||||
case fn := <-s.writeChan:
|
||||
if s.healthStatus.Load() {
|
||||
s.executeWrite(fn)
|
||||
}
|
||||
case <-deadline:
|
||||
return
|
||||
case req := <-s.writeChan:
|
||||
s.handleWrite(req)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case fn := <-s.writeChan:
|
||||
// Skip if already degraded
|
||||
if !s.healthStatus.Load() {
|
||||
continue
|
||||
}
|
||||
s.executeWrite(fn)
|
||||
case req := <-s.writeChan:
|
||||
s.handleWrite(req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeWrite runs a transactional write operation
|
||||
func (s *Store) executeWrite(fn func(*sql.Tx) error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
log.Printf("Storage degraded: failed to begin transaction: %v", err)
|
||||
s.healthStatus.Store(false)
|
||||
func (s *Store) handleWrite(req writeRequest) {
|
||||
if req.run == nil {
|
||||
if req.barrier != nil {
|
||||
var err error
|
||||
if !s.healthStatus.Load() {
|
||||
err = ErrStorageDegraded
|
||||
}
|
||||
req.barrier <- err
|
||||
close(req.barrier)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
tx.Rollback()
|
||||
log.Printf("Storage degraded: write operation failed: %v", err)
|
||||
s.healthStatus.Store(false)
|
||||
if s.writeFailed.Load() {
|
||||
slog.Error("storage write skipped after earlier transaction failure",
|
||||
"operation", req.operation, "game_id", req.gameID)
|
||||
if req.barrier != nil {
|
||||
req.barrier <- ErrStorageDegraded
|
||||
close(req.barrier)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err := s.executeWrite(req)
|
||||
if req.barrier != nil {
|
||||
req.barrier <- err
|
||||
close(req.barrier)
|
||||
}
|
||||
}
|
||||
|
||||
// executeWrite runs a transactional write operation
|
||||
func (s *Store) executeWrite(req writeRequest) error {
|
||||
started := time.Now()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: failed to begin transaction",
|
||||
"operation", req.operation, "game_id", req.gameID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := req.run(tx); err != nil {
|
||||
rollbackErr := tx.Rollback()
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: write operation failed",
|
||||
"operation", req.operation,
|
||||
"game_id", req.gameID,
|
||||
"error", err,
|
||||
"rollback_error", rollbackErr,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("Storage degraded: failed to commit: %v", err)
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
return
|
||||
slog.Error("storage degraded: failed to commit",
|
||||
"operation", req.operation, "game_id", req.gameID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("storage write committed",
|
||||
"operation", req.operation,
|
||||
"game_id", req.gameID,
|
||||
"duration", time.Since(started),
|
||||
"queue_depth", len(s.writeChan),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) enqueue(operation, gameID string, fn func(*sql.Tx) error) error {
|
||||
s.enqueueMu.RLock()
|
||||
defer s.enqueueMu.RUnlock()
|
||||
|
||||
if s.closed.Load() {
|
||||
return ErrStoreClosed
|
||||
}
|
||||
if !s.healthStatus.Load() {
|
||||
return ErrStorageDegraded
|
||||
}
|
||||
|
||||
select {
|
||||
case s.writeChan <- writeRequest{operation: operation, gameID: gameID, run: fn}:
|
||||
slog.Debug("storage write queued",
|
||||
"operation", operation,
|
||||
"game_id", gameID,
|
||||
"queue_depth", len(s.writeChan),
|
||||
)
|
||||
return nil
|
||||
default:
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: write queue full",
|
||||
"operation", operation,
|
||||
"game_id", gameID,
|
||||
"queue_capacity", cap(s.writeChan),
|
||||
)
|
||||
return ErrWriteQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
// Flush waits until every write queued before this call has completed. Replay
|
||||
// reads use this barrier to provide read-after-write consistency while normal
|
||||
// gameplay retains the low-latency async write path.
|
||||
func (s *Store) Flush(ctx context.Context) error {
|
||||
s.enqueueMu.RLock()
|
||||
if s.closed.Load() {
|
||||
s.enqueueMu.RUnlock()
|
||||
return ErrStoreClosed
|
||||
}
|
||||
if !s.healthStatus.Load() {
|
||||
s.enqueueMu.RUnlock()
|
||||
return ErrStorageDegraded
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
select {
|
||||
case s.writeChan <- writeRequest{operation: "flush", barrier: done}:
|
||||
s.enqueueMu.RUnlock()
|
||||
case <-ctx.Done():
|
||||
s.enqueueMu.RUnlock()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) flushBeforeRead() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), flushTimeout)
|
||||
defer cancel()
|
||||
return s.Flush(ctx)
|
||||
}
|
||||
|
||||
// Close gracefully closes the database connection
|
||||
func (s *Store) Close() error {
|
||||
// Signal writer to stop
|
||||
s.cancel()
|
||||
s.closeOnce.Do(func() {
|
||||
s.enqueueMu.Lock()
|
||||
s.closed.Store(true)
|
||||
s.cancel()
|
||||
s.enqueueMu.Unlock()
|
||||
|
||||
// Wait for writer with timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Writer finished cleanly
|
||||
case <-time.After(2 * time.Second):
|
||||
log.Printf("Warning: storage writer shutdown timeout, some writes may be lost")
|
||||
}
|
||||
|
||||
if s.db != nil {
|
||||
return s.db.Close()
|
||||
}
|
||||
return nil
|
||||
if s.db != nil {
|
||||
s.closeErr = s.db.Close()
|
||||
}
|
||||
})
|
||||
return s.closeErr
|
||||
}
|
||||
|
||||
// InitDB creates the database schema
|
||||
func (s *Store) InitDB() error {
|
||||
started := time.Now()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var currentVersion int
|
||||
if err := tx.QueryRow("PRAGMA user_version").Scan(¤tVersion); err != nil {
|
||||
return fmt.Errorf("failed to read schema version: %w", err)
|
||||
}
|
||||
if currentVersion > schemaVersion {
|
||||
return fmt.Errorf(
|
||||
"database schema version %d is newer than supported version %d",
|
||||
currentVersion,
|
||||
schemaVersion,
|
||||
)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(Schema); err != nil {
|
||||
return fmt.Errorf("failed to create schema: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
columns := []struct {
|
||||
name string
|
||||
definition string
|
||||
}{
|
||||
{"result", "TEXT CHECK(result IS NULL OR result IN ('white_wins', 'black_wins', 'draw', 'stalemate'))"},
|
||||
{"end_time_utc", "DATETIME"},
|
||||
{"white_claimed_by", "TEXT"},
|
||||
{"black_claimed_by", "TEXT"},
|
||||
}
|
||||
for _, column := range columns {
|
||||
if err := ensureColumn(tx, "games", column.name, column.definition); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// These indexes duplicate UNIQUE constraints or are superseded by targeted
|
||||
// partial/composite indexes. Drop them during upgrades as well as omitting
|
||||
// them from new databases.
|
||||
for _, name := range []string{
|
||||
"idx_users_username",
|
||||
"idx_users_email",
|
||||
"idx_users_account_type",
|
||||
"idx_users_expires_at",
|
||||
"idx_sessions_user_id",
|
||||
"idx_moves_game_id",
|
||||
"idx_games_finished_end_time",
|
||||
} {
|
||||
if _, err := tx.Exec("DROP INDEX IF EXISTS " + name); err != nil {
|
||||
return fmt.Errorf("failed to remove redundant index %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(Indexes); err != nil {
|
||||
return fmt.Errorf("failed to create indexes: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", schemaVersion)); err != nil {
|
||||
return fmt.Errorf("failed to record schema version: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit schema: %w", err)
|
||||
}
|
||||
slog.Debug("storage schema ready", "version", schemaVersion, "duration", time.Since(started))
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureColumn(tx *sql.Tx, table, column, definition string) error {
|
||||
rows, err := tx.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to inspect %s schema: %w", table, err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, columnType string
|
||||
var notNull, primaryKey int
|
||||
var defaultValue any
|
||||
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("failed to inspect %s column: %w", table, err)
|
||||
}
|
||||
if name == column {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close %s schema rows: %w", table, err)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("failed to inspect %s schema: %w", table, err)
|
||||
}
|
||||
if found {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := tx.Exec("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition); err != nil {
|
||||
return fmt.Errorf("failed to add %s.%s: %w", table, column, err)
|
||||
}
|
||||
slog.Debug("storage schema column added", "table", table, "column", column)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteDB removes the database file
|
||||
@@ -182,4 +422,4 @@ func (s *Store) DeleteDB() error {
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func TestInitDBMigratesLegacySchemaAndRemovesRedundantIndexes(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "legacy.db")
|
||||
db, err := sql.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const legacy = `
|
||||
CREATE TABLE users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
||||
email TEXT COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
account_type TEXT NOT NULL DEFAULT 'temp',
|
||||
created_at DATETIME NOT NULL,
|
||||
expires_at DATETIME,
|
||||
last_login_at DATETIME
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE games (
|
||||
game_id TEXT PRIMARY KEY,
|
||||
initial_fen TEXT NOT NULL,
|
||||
white_player_id TEXT NOT NULL,
|
||||
white_type INTEGER NOT NULL,
|
||||
white_level INTEGER NOT NULL DEFAULT 0,
|
||||
white_search_time INTEGER NOT NULL DEFAULT 1000,
|
||||
black_player_id TEXT NOT NULL,
|
||||
black_type INTEGER NOT NULL,
|
||||
black_level INTEGER NOT NULL DEFAULT 0,
|
||||
black_search_time INTEGER NOT NULL DEFAULT 1000,
|
||||
start_time_utc DATETIME NOT NULL
|
||||
);
|
||||
CREATE TABLE moves (
|
||||
move_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_id TEXT NOT NULL,
|
||||
move_number INTEGER NOT NULL,
|
||||
move_uci TEXT NOT NULL,
|
||||
fen_after_move TEXT NOT NULL,
|
||||
player_color TEXT NOT NULL,
|
||||
move_time_utc DATETIME NOT NULL,
|
||||
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
|
||||
UNIQUE(game_id, move_number)
|
||||
);
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_account_type ON users(account_type);
|
||||
CREATE INDEX idx_users_expires_at ON users(expires_at);
|
||||
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX idx_moves_game_id ON moves(game_id);`
|
||||
if _, err := db.Exec(legacy); err != nil {
|
||||
t.Fatalf("create legacy schema: %v", err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store, err := NewStore(path, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if err := store.InitDB(); err != nil {
|
||||
t.Fatalf("migrate schema: %v", err)
|
||||
}
|
||||
|
||||
columns := tableColumns(t, store.db, "games")
|
||||
for _, column := range []string{"result", "end_time_utc", "white_claimed_by", "black_claimed_by"} {
|
||||
if !columns[column] {
|
||||
t.Errorf("migration did not add games.%s", column)
|
||||
}
|
||||
}
|
||||
|
||||
indexes := schemaIndexes(t, store.db)
|
||||
for _, obsolete := range []string{
|
||||
"idx_users_username", "idx_users_email", "idx_users_account_type",
|
||||
"idx_users_expires_at", "idx_sessions_user_id", "idx_moves_game_id",
|
||||
"idx_games_finished_end_time",
|
||||
} {
|
||||
if indexes[obsolete] {
|
||||
t.Errorf("redundant index %s remains", obsolete)
|
||||
}
|
||||
}
|
||||
for _, required := range []string{
|
||||
"idx_users_email_unique", "idx_users_temp_created_at", "idx_users_temp_expires_at",
|
||||
"idx_sessions_expires_at", "idx_games_white_player", "idx_games_black_player",
|
||||
"idx_games_white_claimed", "idx_games_black_claimed",
|
||||
} {
|
||||
if !indexes[required] {
|
||||
t.Errorf("required index %s is missing", required)
|
||||
}
|
||||
}
|
||||
|
||||
var version, foreignKeys int
|
||||
if err := store.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != 2 {
|
||||
t.Errorf("schema version = %d, want 2", version)
|
||||
}
|
||||
if err := store.db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if foreignKeys != 1 {
|
||||
t.Errorf("foreign_keys = %d, want 1", foreignKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
started := time.Date(2026, 9, 7, 1, 2, 3, 0, time.UTC)
|
||||
ended := started.Add(5 * time.Minute)
|
||||
|
||||
if err := store.RecordNewGame(GameRecord{
|
||||
GameID: "game-1", InitialFEN: "initial",
|
||||
WhitePlayerID: "anonymous-white", WhiteType: 1,
|
||||
BlackPlayerID: "black", BlackType: 1,
|
||||
StartTimeUTC: started,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.RecordMove(MovePersistence{
|
||||
Move: MoveRecord{
|
||||
GameID: "game-1", MoveNumber: 1, MoveUCI: "e2e4",
|
||||
FENAfterMove: "after-e2e4", PlayerColor: "w", MoveTimeUTC: ended,
|
||||
},
|
||||
ClaimColor: "w", ClaimedBy: "user-1",
|
||||
Result: "white_wins", EndTimeUTC: &ended,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// GetGameHistory must observe both queued writes without sleeps or polling.
|
||||
gameRecord, moves, err := store.GetGameHistory("game-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gameRecord.WhiteClaimedBy != "user-1" || gameRecord.Result != "white_wins" || gameRecord.EndTimeUTC == nil {
|
||||
t.Fatalf("durable game mutation incomplete: %+v", gameRecord)
|
||||
}
|
||||
if len(moves) != 1 || moves[0].MoveNumber != 1 || moves[0].FENAfterMove != "after-e2e4" {
|
||||
t.Fatalf("moves = %+v", moves)
|
||||
}
|
||||
|
||||
owned, err := store.QueryGamesForUser("user-1", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(owned) != 1 || owned[0].MoveCount != 1 {
|
||||
t.Fatalf("claimed game lookup = %+v", owned)
|
||||
}
|
||||
|
||||
if err := store.RewindGame("game-1", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gameRecord, moves, err = store.GetGameHistory("game-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gameRecord.Result != "" || gameRecord.EndTimeUTC != nil || len(moves) != 0 {
|
||||
t.Fatalf("rewind left stale replay data: game=%+v moves=%+v", gameRecord, moves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionSettingsApplyAcrossPool(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
connections := make([]*sql.Conn, 0, 8)
|
||||
defer func() {
|
||||
for _, connection := range connections {
|
||||
_ = connection.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// Keep each connection checked out so the pool must create eight distinct
|
||||
// SQLite connections, then verify connection-local PRAGMAs on every one.
|
||||
for range 8 {
|
||||
connection, err := store.db.Conn(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
connections = append(connections, connection)
|
||||
}
|
||||
for i, connection := range connections {
|
||||
var foreignKeys, busyTimeout, synchronous int
|
||||
var journalMode string
|
||||
if err := connection.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := connection.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busyTimeout); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := connection.QueryRowContext(ctx, "PRAGMA synchronous").Scan(&synchronous); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := connection.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journalMode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if foreignKeys != 1 || busyTimeout != 5000 || synchronous != 1 || !strings.EqualFold(journalMode, "wal") {
|
||||
t.Errorf(
|
||||
"connection %d settings: foreign_keys=%d busy_timeout=%d synchronous=%d journal_mode=%s",
|
||||
i, foreignKeys, busyTimeout, synchronous, journalMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptedWriteCommitsAfterQueueAdmissionFailureMarksHealthDegraded(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
store.healthStatus.Store(false) // Queue saturation rejects new work but accepted work must drain.
|
||||
done := make(chan error, 1)
|
||||
store.handleWrite(writeRequest{
|
||||
operation: "accepted_before_saturation",
|
||||
run: func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`INSERT INTO users
|
||||
(user_id, username, password_hash, account_type, created_at)
|
||||
VALUES ('user-1', 'alice', 'hash', 'permanent', ?)`, time.Now().UTC())
|
||||
return err
|
||||
},
|
||||
barrier: done,
|
||||
})
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("accepted write was discarded: %v", err)
|
||||
}
|
||||
if _, err := store.GetUserByID("user-1"); err != nil {
|
||||
t.Fatalf("accepted write was not committed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritesAfterTransactionFailureAreSkippedExplicitly(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
failed := make(chan error, 1)
|
||||
store.handleWrite(writeRequest{
|
||||
operation: "forced_failure",
|
||||
run: func(*sql.Tx) error { return errors.New("forced failure") },
|
||||
barrier: failed,
|
||||
})
|
||||
if err := <-failed; err == nil {
|
||||
t.Fatal("forced transaction failure was not reported")
|
||||
}
|
||||
|
||||
ran := false
|
||||
skipped := make(chan error, 1)
|
||||
store.handleWrite(writeRequest{
|
||||
operation: "after_failure",
|
||||
run: func(*sql.Tx) error {
|
||||
ran = true
|
||||
return nil
|
||||
},
|
||||
barrier: skipped,
|
||||
})
|
||||
if err := <-skipped; !errors.Is(err, ErrStorageDegraded) {
|
||||
t.Fatalf("skipped write error = %v, want ErrStorageDegraded", err)
|
||||
}
|
||||
if ran {
|
||||
t.Fatal("write ran after an earlier transaction broke ordering")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewerSchemaVersionIsRejected(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if _, err := store.db.Exec("PRAGMA user_version = 3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.InitDB(); err == nil || !strings.Contains(err.Error(), "newer than supported") {
|
||||
t.Fatalf("InitDB error = %v, want newer-version rejection", err)
|
||||
}
|
||||
var version int
|
||||
if err := store.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != 3 {
|
||||
t.Fatalf("newer schema version was overwritten: %d", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryPlansUseOnlyPurposeBuiltOrConstraintIndexes(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
args []any
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "email partial uniqueness",
|
||||
query: `SELECT user_id FROM users
|
||||
WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != ''`,
|
||||
args: []any{"alice@example.com"}, want: []string{"idx_users_email_unique"},
|
||||
},
|
||||
{
|
||||
name: "temporary expiry cleanup",
|
||||
query: `SELECT user_id FROM users
|
||||
WHERE account_type = 'temp' AND expires_at IS NOT NULL AND expires_at < ?`,
|
||||
args: []any{now}, want: []string{"idx_users_temp_expires_at"},
|
||||
},
|
||||
{
|
||||
name: "oldest temporary account",
|
||||
query: `SELECT user_id FROM users
|
||||
WHERE account_type = 'temp' ORDER BY created_at ASC LIMIT 1`,
|
||||
want: []string{"idx_users_temp_created_at"},
|
||||
},
|
||||
{
|
||||
name: "ordered moves use composite unique constraint",
|
||||
query: `SELECT move_uci FROM moves
|
||||
WHERE game_id = ? ORDER BY move_number ASC`,
|
||||
args: []any{"game-1"}, want: []string{"sqlite_autoindex_moves_1"},
|
||||
},
|
||||
{
|
||||
name: "all user association branches",
|
||||
query: `SELECT game_id,
|
||||
(SELECT COUNT(*) FROM moves m WHERE m.game_id = games.game_id)
|
||||
FROM games WHERE white_player_id = ? OR black_player_id = ?
|
||||
OR white_claimed_by = ? OR black_claimed_by = ?
|
||||
ORDER BY start_time_utc DESC, game_id DESC LIMIT ? OFFSET ?`,
|
||||
args: []any{"user-1", "user-1", "user-1", "user-1", 50, 0},
|
||||
want: []string{
|
||||
"idx_games_white_player", "idx_games_black_player",
|
||||
"idx_games_white_claimed", "idx_games_black_claimed",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
plan := explainQueryPlan(t, store.db, test.query, test.args...)
|
||||
for _, index := range test.want {
|
||||
if !strings.Contains(plan, index) {
|
||||
t.Errorf("query plan does not use %s:\n%s", index, plan)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignKeyCascadeAppliesToSessions(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
now := time.Now().UTC()
|
||||
if err := store.CreateUser(UserRecord{
|
||||
UserID: "user-1", Username: "user1", PasswordHash: "hash",
|
||||
AccountType: "permanent", CreatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.CreateSession(SessionRecord{
|
||||
SessionID: "session-1", UserID: "user-1", CreatedAt: now, ExpiresAt: now.Add(time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.DeleteUser("user-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.GetSession("session-1"); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("session survived user cascade: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitedUserCreationIsAtomicWithInitialSession(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
now := time.Now().UTC()
|
||||
limits := UserLimits{MaxUsers: 1, PermanentSlots: 1}
|
||||
|
||||
first := UserRecord{
|
||||
UserID: "user-1", Username: "alice", PasswordHash: "hash",
|
||||
AccountType: "temp", CreatedAt: now, ExpiresAt: timePointer(now.Add(time.Hour)),
|
||||
}
|
||||
firstSession := SessionRecord{
|
||||
SessionID: "session-1", UserID: first.UserID,
|
||||
CreatedAt: now, ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
if err := store.CreateUserWithinLimits(first, &firstSession, limits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
duplicate := first
|
||||
duplicate.UserID = "user-duplicate"
|
||||
if err := store.CreateUserWithinLimits(duplicate, nil, limits); !errors.Is(err, ErrUserAlreadyExists) {
|
||||
t.Fatalf("duplicate error = %v, want ErrUserAlreadyExists", err)
|
||||
}
|
||||
if _, err := store.GetUserByID(first.UserID); err != nil {
|
||||
t.Fatalf("duplicate registration evicted existing user: %v", err)
|
||||
}
|
||||
|
||||
second := UserRecord{
|
||||
UserID: "user-2", Username: "bob", PasswordHash: "hash",
|
||||
AccountType: "temp", CreatedAt: now.Add(time.Minute),
|
||||
ExpiresAt: timePointer(now.Add(2 * time.Hour)),
|
||||
}
|
||||
secondSession := SessionRecord{
|
||||
SessionID: "session-2", UserID: second.UserID,
|
||||
CreatedAt: now.Add(time.Minute), ExpiresAt: now.Add(2 * time.Hour),
|
||||
}
|
||||
if err := store.CreateUserWithinLimits(second, &secondSession, limits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.GetUserByID(first.UserID); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("oldest temporary user was not replaced: %v", err)
|
||||
}
|
||||
if _, err := store.GetSession(firstSession.SessionID); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("evicted user's session survived cascade: %v", err)
|
||||
}
|
||||
if _, err := store.GetSession(secondSession.SessionID); err != nil {
|
||||
t.Fatalf("initial session not committed with user: %v", err)
|
||||
}
|
||||
|
||||
third := UserRecord{
|
||||
UserID: "user-3", Username: "charlie", PasswordHash: "hash",
|
||||
AccountType: "temp", CreatedAt: now.Add(2 * time.Minute),
|
||||
}
|
||||
conflictingSession := SessionRecord{
|
||||
SessionID: secondSession.SessionID, UserID: third.UserID,
|
||||
CreatedAt: now, ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
wideLimits := UserLimits{MaxUsers: 10, PermanentSlots: 2}
|
||||
if err := store.CreateUserWithinLimits(third, &conflictingSession, wideLimits); err == nil {
|
||||
t.Fatal("expected duplicate session failure")
|
||||
}
|
||||
if _, err := store.GetUserByID(third.UserID); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("session failure did not roll back user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func timePointer(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "chess.db"), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if err := store.InitDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func tableColumns(t *testing.T, db *sql.DB, table string) map[string]bool {
|
||||
t.Helper()
|
||||
rows, err := db.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
columns := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var cid, notNull, primaryKey int
|
||||
var name, columnType string
|
||||
var defaultValue any
|
||||
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
columns[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
func schemaIndexes(t *testing.T, db *sql.DB) map[string]bool {
|
||||
t.Helper()
|
||||
rows, err := db.Query(`SELECT name FROM sqlite_schema WHERE type = 'index'`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
indexes := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
indexes[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return indexes
|
||||
}
|
||||
|
||||
func explainQueryPlan(t *testing.T, db *sql.DB, query string, args ...any) string {
|
||||
t.Helper()
|
||||
rows, err := db.Query("EXPLAIN QUERY PLAN "+query, args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var details []string
|
||||
for rows.Next() {
|
||||
var id, parent, notUsed int
|
||||
var detail string
|
||||
if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
details = append(details, detail)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return strings.Join(details, "\n")
|
||||
}
|
||||
@@ -2,16 +2,22 @@ package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserAlreadyExists = errors.New("username or email already exists")
|
||||
ErrUserCapacity = errors.New("user capacity reached")
|
||||
ErrPermanentCapacity = errors.New("permanent user capacity reached")
|
||||
)
|
||||
|
||||
// UserLimits defines registration constraints
|
||||
type UserLimits struct {
|
||||
MaxUsers int
|
||||
PermanentSlots int
|
||||
TempTTL time.Duration
|
||||
}
|
||||
|
||||
// DefaultUserLimits returns default POC limits
|
||||
@@ -19,7 +25,6 @@ func DefaultUserLimits() UserLimits {
|
||||
return UserLimits{
|
||||
MaxUsers: 100,
|
||||
PermanentSlots: 10,
|
||||
TempTTL: 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,16 +64,38 @@ func (s *Store) GetOldestTempUser() (*UserRecord, error) {
|
||||
|
||||
// DeleteExpiredTempUsers removes temporary users past their expiry
|
||||
func (s *Store) DeleteExpiredTempUsers() (int64, error) {
|
||||
query := `DELETE FROM users WHERE account_type = 'temp' AND expires_at < ?`
|
||||
query := `DELETE FROM users
|
||||
WHERE account_type = 'temp' AND expires_at IS NOT NULL AND expires_at < ?`
|
||||
result, err := s.db.Exec(query, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
deleted, err := result.RowsAffected()
|
||||
if err == nil && deleted > 0 {
|
||||
slog.Debug("storage expired temporary users deleted", "count", deleted)
|
||||
}
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
// CreateUser creates user with transaction isolation to prevent race conditions
|
||||
// CreateUser creates an administratively managed user without applying the
|
||||
// public-registration capacity policy.
|
||||
func (s *Store) CreateUser(record UserRecord) error {
|
||||
return s.createUser(record, nil, nil)
|
||||
}
|
||||
|
||||
// CreateUserWithinLimits atomically applies registration limits, evicts the
|
||||
// oldest temporary account when required, creates the user, and optionally
|
||||
// creates its initial session. No account is evicted on a duplicate request,
|
||||
// and a session failure rolls back the user and eviction together.
|
||||
func (s *Store) CreateUserWithinLimits(
|
||||
record UserRecord,
|
||||
session *SessionRecord,
|
||||
limits UserLimits,
|
||||
) error {
|
||||
return s.createUser(record, session, &limits)
|
||||
}
|
||||
|
||||
func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *UserLimits) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
@@ -81,7 +108,37 @@ func (s *Store) CreateUser(record UserRecord) error {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return fmt.Errorf("username or email already exists")
|
||||
return ErrUserAlreadyExists
|
||||
}
|
||||
|
||||
if limits != nil {
|
||||
var total, permanent int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*),
|
||||
COUNT(CASE WHEN account_type = 'permanent' THEN 1 END)
|
||||
FROM users`).Scan(&total, &permanent); err != nil {
|
||||
return fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
if record.AccountType == "permanent" && permanent >= limits.PermanentSlots {
|
||||
return ErrPermanentCapacity
|
||||
}
|
||||
if total >= limits.MaxUsers {
|
||||
result, err := tx.Exec(`DELETE FROM users WHERE user_id = (
|
||||
SELECT user_id FROM users
|
||||
WHERE account_type = 'temp'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evict oldest temporary user: %w", err)
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect temporary user eviction: %w", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
return ErrUserCapacity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert user
|
||||
@@ -96,14 +153,36 @@ func (s *Store) CreateUser(record UserRecord) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if session != nil {
|
||||
if session.UserID != record.UserID {
|
||||
return errors.New("initial session user does not match new user")
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
session.SessionID, session.UserID, session.CreatedAt, session.ExpiresAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("create initial session: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Debug("storage user created",
|
||||
"user_id", record.UserID,
|
||||
"account_type", record.AccountType,
|
||||
"initial_session", session != nil,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUserByID removes a user by ID (synchronous, for replacement logic)
|
||||
func (s *Store) DeleteUserByID(userID string) error {
|
||||
query := `DELETE FROM users WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, userID)
|
||||
if err == nil {
|
||||
slog.Debug("storage user deleted", "user_id", userID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -121,7 +200,9 @@ func (s *Store) userExists(tx *sql.Tx, username, email string) (bool, error) {
|
||||
args := []any{username}
|
||||
|
||||
if email != "" {
|
||||
query = `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE OR email = ? COLLATE NOCASE`
|
||||
query = `SELECT COUNT(*) FROM users
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
OR (email = ? COLLATE NOCASE AND email IS NOT NULL AND email != '')`
|
||||
args = append(args, email)
|
||||
}
|
||||
|
||||
@@ -217,7 +298,7 @@ func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var emailNull sql.NullString
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users WHERE email = ? COLLATE NOCASE`
|
||||
FROM users WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != ''`
|
||||
|
||||
err := s.db.QueryRow(query, email).Scan(
|
||||
&user.UserID, &user.Username, &emailNull,
|
||||
@@ -250,21 +331,8 @@ func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a user from the database (async)
|
||||
// DeleteUser removes a user synchronously. Account operations are consistency
|
||||
// sensitive and should not be reported successful before SQLite commits them.
|
||||
func (s *Store) DeleteUser(userID string) error {
|
||||
if !s.healthStatus.Load() {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case s.writeChan <- func(tx *sql.Tx) error {
|
||||
query := `DELETE FROM users WHERE user_id = ?`
|
||||
_, err := tx.Exec(query, userID)
|
||||
return err
|
||||
}:
|
||||
return nil
|
||||
default:
|
||||
log.Printf("Storage write queue full, dropping user deletion")
|
||||
return nil
|
||||
}
|
||||
return s.DeleteUserByID(userID)
|
||||
}
|
||||
|
||||
@@ -357,7 +357,20 @@ function authFetch(url, options = {}) {
|
||||
}
|
||||
|
||||
async function getConfig() {
|
||||
return { apiUrl: '/chess' };
|
||||
try {
|
||||
const response = await fetch('./config', { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`config returned ${response.status}`);
|
||||
const config = await response.json();
|
||||
if (typeof config.apiUrl !== 'string' || !config.apiUrl.trim()) {
|
||||
throw new Error('config is missing apiUrl');
|
||||
}
|
||||
return { apiUrl: config.apiUrl.replace(/\/+$/, '') };
|
||||
} catch (error) {
|
||||
// Static production hosting currently reverse-proxies the API here.
|
||||
// The embedded server supplies /config and does not use this fallback.
|
||||
console.debug('Using default API route:', error.message);
|
||||
return { apiUrl: '/chess' };
|
||||
}
|
||||
}
|
||||
|
||||
function startHealthCheck() {
|
||||
@@ -835,7 +848,7 @@ async function pollOnce() {
|
||||
gameState.pollController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authFetch(
|
||||
`${gameState.apiUrl}/api/v1/games/${gameState.gameId}?wait=true&moveCount=${moveCount}`,
|
||||
{ signal: gameState.pollController.signal }
|
||||
);
|
||||
@@ -895,7 +908,7 @@ async function undoMoves() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/undo`, {
|
||||
const response = await authFetch(`${gameState.apiUrl}/api/v1/games/${gameState.gameId}/undo`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count: 2 })
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
var webFS embed.FS
|
||||
|
||||
// Start initializes and starts the web UI server
|
||||
func Start(host string, port int, apiURL string) error {
|
||||
func Start(host string, port int, apiURL string, logRequests bool) error {
|
||||
app := fiber.New(fiber.Config{
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
@@ -24,13 +24,17 @@ func Start(host string, port int, apiURL string) error {
|
||||
})
|
||||
|
||||
// Middleware
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} WEB ${status} ${method} ${path} ${latency}\n",
|
||||
}))
|
||||
if logRequests {
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} WEB ${status} ${method} ${path} ${latency}\n",
|
||||
TimeFormat: time.RFC3339,
|
||||
TimeZone: "UTC",
|
||||
}))
|
||||
}
|
||||
app.Use(cors.New())
|
||||
|
||||
// Create a sub-filesystem that points to the 'web' directory
|
||||
webContent, err := fs.Sub(webFS, "web")
|
||||
// Create a sub-filesystem rooted at the embedded web client.
|
||||
webContent, err := fs.Sub(webFS, "chess-client-web")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create web sub-filesystem: %w", err)
|
||||
}
|
||||
@@ -42,7 +46,7 @@ func Start(host string, port int, apiURL string) error {
|
||||
})
|
||||
})
|
||||
|
||||
// Serve static files from the embedded 'web' directory
|
||||
// Serve static files from the embedded client directory.
|
||||
app.Get("*", func(c *fiber.Ctx) error {
|
||||
path := c.Path()
|
||||
|
||||
@@ -84,4 +88,4 @@ func Start(host string, port int, apiURL string) error {
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
return app.Listen(addr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package webserver
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEmbeddedWebClientRoot(t *testing.T) {
|
||||
content, err := fs.Sub(webFS, "chess-client-web")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"index.html", "app.js", "style.css"} {
|
||||
data, err := fs.ReadFile(content, name)
|
||||
if err != nil {
|
||||
t.Errorf("read embedded %s: %v", name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Errorf("embedded %s is empty", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user