v0.13.0 web and terminal client enhance, replay api, linux server deploy, doc and dependency update
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
52ac0fe31a
commit
f1ac4cbbe7
71 files changed
+7677
-537
No files matched your search
@@ -0,0 +1,239 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user