v0.12.0 migrate sqlite3 to psql18, updated auth, deploy docs, account and replay updates
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
5d2be4abb2
commit
d0326b059e
66 files changed
+4016
-2433
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user