v0.12.0 migrate sqlite3 to psql18, updated auth, deploy docs, account and replay updates
test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-26 20:58:19 -04:00
1 parent 5d2be4abb2
commit d0326b059e
66 files changed
+4016 -2433

No files matched your search

+85 -84
View File
@@ -1,7 +1,6 @@
package service
import (
"chess/internal/server/core"
"context"
"errors"
"fmt"
@@ -10,41 +9,62 @@ import (
"sync/atomic"
"time"
"chess/internal/server/core"
"chess/internal/server/game"
"chess/internal/server/storage"
"github.com/lixenwraith/auth"
)
const (
MaxComputerGames = 10
MaxUsers = 100
PermanentSlots = 10
TempUserTTL = 24 * time.Hour
DefaultMaxUsers = 100
SessionTTL = 7 * 24 * time.Hour
CleanupJobInterval = 1 * time.Hour
FinishedGameTTL = 1 * time.Hour
// AnonymousGameTTL is how long a game no registered user claimed survives
// after its last activity, in memory and in the database.
AnonymousGameTTL = 24 * 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
jwt *auth.JWT
kdf chan struct{} // Argon2id concurrency slots
waiter *WaitRegistry
computerGames atomic.Int32 // Active games with computer players
finishedTTL time.Duration
anonymousTTL time.Duration
maxUsers atomic.Int64
}
// 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(),
finishedTTL: FinishedGameTTL,
// New creates a service with optional storage. jwtSecret signs session tokens
// and must hold at least 32 bytes of key material.
func New(store *storage.Store, jwtSecret []byte) (*Service, error) {
manager, err := auth.NewJWT(jwtSecret,
auth.WithIssuer(JWTIssuer),
auth.WithAudience([]string{JWTAudience}),
auth.WithTokenLifetime(SessionTTL),
// Tokens are minted and verified by this process: no clock skew.
auth.WithLeeway(0),
)
if err != nil {
return nil, fmt.Errorf("configure JWT: %w", err)
}
s := &Service{
games: make(map[string]*game.Game),
store: store,
jwt: manager,
kdf: make(chan struct{}, MaxConcurrentKDF),
waiter: NewWaitRegistry(),
finishedTTL: FinishedGameTTL,
anonymousTTL: AnonymousGameTTL,
}
s.maxUsers.Store(DefaultMaxUsers)
return s, nil
}
// SetFinishedGameTTL configures how long terminal games remain in memory.
@@ -56,6 +76,22 @@ func (s *Service) SetFinishedGameTTL(ttl time.Duration) {
s.mu.Unlock()
}
// SetAnonymousGameTTL configures how long games without a registered player
// survive after their last activity. Such games are unloaded from memory and
// deleted from the database once idle this long. A non-positive duration
// keeps them indefinitely.
func (s *Service) SetAnonymousGameTTL(ttl time.Duration) {
s.mu.Lock()
s.anonymousTTL = ttl
s.mu.Unlock()
}
// SetMaxUsers caps the number of accounts public registration may create;
// zero removes the cap. Accounts created through the CLI are not limited.
func (s *Service) SetMaxUsers(limit int) {
s.maxUsers.Store(int64(max(limit, 0)))
}
// GetStorageHealth returns the storage component status
func (s *Service) GetStorageHealth() string {
if s.store == nil {
@@ -84,60 +120,11 @@ func (s *Service) CanCreateComputerGame() bool {
return s.computerGames.Load() < MaxComputerGames
}
// IncrementComputerGames increments the computer game counter
func (s *Service) IncrementComputerGames() {
s.computerGames.Add(1)
}
// DecrementComputerGames decrements the computer game counter
func (s *Service) DecrementComputerGames() {
s.computerGames.Add(-1)
}
// GetComputerGameCount returns current computer game count
func (s *Service) GetComputerGameCount() int32 {
return s.computerGames.Load()
}
// 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()
g, ok := s.games[gameID]
if !ok {
return fmt.Errorf("game not found: %s", gameID)
}
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
func (s *Service) GetSlotOwner(gameID string, color core.Color) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
g, ok := s.games[gameID]
if !ok {
return "", fmt.Errorf("game not found: %s", gameID)
}
return g.GetSlotOwner(color), nil
}
// Shutdown gracefully shuts down the service
func (s *Service) Shutdown(timeout time.Duration) error {
var errs []error
@@ -159,7 +146,8 @@ func (s *Service) Shutdown(timeout time.Duration) error {
return errors.Join(errs...)
}
// RunCleanupJob runs periodic cleanup of expired users and sessions
// RunCleanupJob periodically removes expired sessions, unloads idle games, and
// deletes anonymous games past their retention.
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
s.cleanupExpired()
ticker := time.NewTicker(interval)
@@ -176,49 +164,62 @@ func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
}
func (s *Service) cleanupExpired() {
now := time.Now().UTC()
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())
s.cleanupGames(now)
}
func (s *Service) cleanupFinishedGames(now time.Time) {
// cleanupGames unloads terminal games after the finished-game TTL and
// unclaimed games idle past the anonymous TTL, then queues deletion of
// anonymous games idle past that TTL. The set of games still loaded is taken
// under the same lock as the eviction and excluded from deletion, so no live
// game can lose its row while writes for it are still possible.
func (s *Service) cleanupGames(now time.Time) {
s.mu.Lock()
if s.finishedTTL <= 0 {
s.mu.Unlock()
return
}
cutoff := now.Add(-s.finishedTTL)
removed := make([]string, 0)
finishedCutoff := now.Add(-s.finishedTTL)
anonymousTTL := s.anonymousTTL
anonymousCutoff := now.Add(-anonymousTTL)
var finished, idle []string
live := make([]string, 0, len(s.games))
for gameID, g := range s.games {
ended := g.EndTimeUTC()
if !g.State().IsTerminal() || ended == nil || ended.After(cutoff) {
switch {
case s.finishedTTL > 0 && g.State().IsTerminal() && ended != nil && ended.Before(finishedCutoff):
finished = append(finished, gameID)
case anonymousTTL > 0 && !g.IsClaimed() && g.State() != core.StatePending &&
g.LastActivity().Before(anonymousCutoff):
idle = append(idle, gameID)
default:
live = append(live, gameID)
continue
}
if g.HasComputerPlayer() {
s.computerGames.Add(-1)
}
delete(s.games, gameID)
removed = append(removed, gameID)
}
s.mu.Unlock()
for _, gameID := range removed {
for _, gameID := range append(finished, idle...) {
s.waiter.RemoveGame(gameID)
}
if len(removed) > 0 {
if len(finished) > 0 {
slog.Info("cleanup evicted terminal games from memory",
"count", len(removed), "retention", s.finishedTTL)
"count", len(finished), "retention", s.finishedTTL)
}
if len(idle) > 0 {
slog.Info("cleanup evicted idle anonymous games from memory",
"count", len(idle), "retention", anonymousTTL)
}
if s.store != nil && anonymousTTL > 0 {
if err := s.store.DeleteAnonymousGames(anonymousCutoff, live); err != nil {
slog.Error("cleanup failed to queue anonymous game deletion", "error", err)
}
}
}