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
+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)
}
}