Files

240 lines
6.8 KiB
Go

package service
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/lixenwraith/chess/internal/server/replay"
"github.com/lixenwraith/auth"
)
// IntegrityMode selects what the periodic integrity sweep does.
type IntegrityMode int
const (
// IntegrityOff skips the sweep.
IntegrityOff IntegrityMode = iota
// IntegrityReport logs what the sweep finds and changes nothing.
IntegrityReport
// IntegrityDelete also unloads and deletes what it finds.
IntegrityDelete
)
// integrityBatch bounds how many stored games one sweep replays against the
// rules; the sweep resumes after the last one next time, so every game is
// checked in turn at a bounded cost per run.
const integrityBatch = 200
// ParseIntegrityMode accepts "off", "report", or "delete".
func ParseIntegrityMode(s string) (IntegrityMode, error) {
switch s {
case "", "off":
return IntegrityOff, nil
case "report":
return IntegrityReport, nil
case "delete":
return IntegrityDelete, nil
}
return IntegrityOff, fmt.Errorf("invalid integrity mode %q (want off, report, or delete)", s)
}
func (m IntegrityMode) String() string {
switch m {
case IntegrityReport:
return "report"
case IntegrityDelete:
return "delete"
}
return "off"
}
// SetIntegrityMode configures the sweep run with every cleanup job.
func (s *Service) SetIntegrityMode(mode IntegrityMode) {
s.mu.Lock()
s.integrityMode = mode
s.mu.Unlock()
}
// integrityFindings is what one sweep found. Keys are IDs, values reasons.
type integrityFindings struct {
vanished map[string]string // loaded games without a database row
games map[string]string // stored games to delete
users map[string]string // accounts to delete
loaded map[string]bool
}
// checkIntegrity finds data that the server cannot use and, in delete mode,
// removes it:
//
// - loaded games whose row was deleted by hand (unloaded from memory);
// - games with missing plies, when move rows were deleted by hand
// (unloaded if loaded, then deleted);
// - stored games that fail replay.Verify, integrityBatch per run
// (loaded games are skipped: their rows may trail memory);
// - games whose registered players were all deleted, once idle past the
// anonymous-game retention, like games that never had one;
// - accounts whose password hash is not a usable Argon2id PHC string, so
// nobody can sign in to them.
//
// Every finding is logged at WARN with its reason.
func (s *Service) checkIntegrity(now time.Time) {
s.mu.RLock()
mode, anonymousTTL := s.integrityMode, s.anonymousTTL
loaded := make(map[string]bool, len(s.games))
for gameID := range s.games {
loaded[gameID] = true
}
s.mu.RUnlock()
if mode == IntegrityOff || s.store == nil {
return
}
findings, err := s.findIntegrityProblems(now, loaded, anonymousTTL)
if err != nil {
slog.Error("integrity sweep failed", "error", err)
return
}
for gameID, reason := range findings.vanished {
slog.Warn("integrity: loaded game has no database row", "game_id", gameID, "reason", reason)
}
for gameID, reason := range findings.games {
slog.Warn("integrity: invalid game", "game_id", gameID, "reason", reason)
}
for userID, reason := range findings.users {
slog.Warn("integrity: unusable account", "user_id", userID, "reason", reason)
}
summary := []any{"mode", mode.String(), "vanished_games", len(findings.vanished),
"invalid_games", len(findings.games), "unusable_accounts", len(findings.users)}
if mode != IntegrityDelete {
if len(findings.vanished)+len(findings.games)+len(findings.users) > 0 {
slog.Info("integrity sweep found problems; nothing changed (report mode)", summary...)
}
return
}
for gameID := range findings.vanished {
s.evictMissingGame(gameID)
}
var games []string
for gameID := range findings.games {
if findings.loaded[gameID] {
s.evictMissingGame(gameID) // unload first: no further writes for it
}
games = append(games, gameID)
}
if err := s.store.DeleteGames(games); err != nil {
slog.Error("integrity sweep failed to queue game deletion", "error", err)
}
var users []string
for userID := range findings.users {
users = append(users, userID)
}
if err := s.store.DeleteUsers(users); err != nil {
slog.Error("integrity sweep failed to delete accounts", "error", err)
}
if len(findings.vanished)+len(games)+len(users) > 0 {
slog.Info("integrity sweep removed invalid data", summary...)
}
}
func (s *Service) findIntegrityProblems(now time.Time, loaded map[string]bool,
anonymousTTL time.Duration) (*integrityFindings, error) {
found := &integrityFindings{
vanished: map[string]string{},
games: map[string]string{},
users: map[string]string{},
loaded: loaded,
}
live := make([]string, 0, len(loaded))
for gameID := range loaded {
live = append(live, gameID)
}
// Every loaded game's insert was queued before the snapshot was taken, so
// after this flush a missing row means the row was deleted.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.store.Flush(ctx); err != nil {
return nil, err
}
exists, err := s.store.ExistingGames(live)
if err != nil {
return nil, err
}
for _, gameID := range live {
if !exists[gameID] {
found.vanished[gameID] = "row deleted outside the server"
}
}
gaps, err := s.store.GamesWithMoveGaps()
if err != nil {
return nil, err
}
for _, gameID := range gaps {
found.games[gameID] = "missing plies: move rows were deleted"
}
ids, err := s.store.GameIDsAfter(s.integrityCursor, integrityBatch)
if err != nil {
return nil, err
}
s.integrityCursor = ""
if len(ids) == integrityBatch {
s.integrityCursor = ids[len(ids)-1]
}
for _, gameID := range ids {
if loaded[gameID] || found.games[gameID] != "" {
continue
}
record, moves, err := s.store.GetGameHistory(gameID)
if err != nil {
continue // deleted since the listing
}
if problems := replay.Verify(record, moves); len(problems) > 0 {
found.games[gameID] = problems[0]
}
}
if anonymousTTL > 0 {
orphans, err := s.store.OrphanedGames(now.Add(-anonymousTTL), live)
if err != nil {
return nil, err
}
for _, gameID := range orphans {
if found.games[gameID] == "" {
found.games[gameID] = "all registered players were deleted"
}
}
}
users, err := s.store.GetAllUsers()
if err != nil {
return nil, err
}
for _, user := range users {
if err := auth.ValidatePHCHashFormat(user.PasswordHash); err != nil {
found.users[user.UserID] = fmt.Sprintf("%s: %v", user.Username, err)
}
}
return found, nil
}
// evictMissingGame unloads a game whose database row disappeared, typically
// deleted by hand. It runs when a write for the game finds the row gone.
func (s *Service) evictMissingGame(gameID string) {
s.mu.Lock()
_, ok := s.games[gameID]
if ok {
delete(s.games, gameID)
}
s.mu.Unlock()
if ok {
s.waiter.RemoveGame(gameID)
slog.Warn("game unloaded: its database row no longer exists", "game_id", gameID)
}
}