v0.11.0 harden persistence and prepare game replays

This commit is contained in:
2026-09-07 14:39:00 -04:00
parent 21dea47694
commit 5d2be4abb2
42 changed files with 3591 additions and 693 deletions
+205 -63
View File
@@ -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,
}
}
+164
View File
@@ -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
}
+109
View File
@@ -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,
},
}
}
+82 -21
View File
@@ -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
View File
@@ -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
}
+48
View File
@@ -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")
}
}
+52 -58
View File
@@ -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)
})
}
+81
View File
@@ -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")
}
}