v0.13.0 web and terminal client enhance, replay api, linux server deploy, doc and dependency update
test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-28 22:35:52 -04:00
1 parent 52ac0fe31a
commit f1ac4cbbe7
71 files changed
+7677 -537

No files matched your search

+237 -73
View File
@@ -8,13 +8,15 @@ import (
"log/slog"
"strings"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
const gameSelectColumns = `
g.game_id, g.initial_fen,
g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name,
g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, g.black_name,
g.result, g.start_time_utc, g.end_time_utc`
g.result, g.termination, g.start_time_utc, g.end_time_utc`
// claimantName snapshots a claimant's username inside the writing
// transaction; NULL when the slot is unclaimed or the account is gone.
@@ -33,7 +35,7 @@ func (s *Store) RecordNewGame(record GameRecord) error {
if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" {
return errors.New("game ID, initial FEN, and player IDs are required")
}
if err := validateResultTime(record.Result, record.EndTimeUTC); err != nil {
if err := validateOutcome(record.Result, record.Termination, record.EndTimeUTC); err != nil {
return err
}
@@ -42,9 +44,9 @@ func (s *Store) RecordNewGame(record GameRecord) error {
game_id, initial_fen,
white_player_id, white_type, white_level, white_search_time, white_claimed_by, white_name,
black_player_id, black_type, black_level, black_search_time, black_claimed_by, black_name,
start_time_utc, result, end_time_utc
start_time_utc, result, termination, end_time_utc
) VALUES ($1, $2, $3, $4, $5, $6, $7, ` + claimantName("$7") + `,
$8, $9, $10, $11, $12, ` + claimantName("$12") + `, $13, $14, $15)`
$8, $9, $10, $11, $12, ` + claimantName("$12") + `, $13, $14, $15, $16)`
_, err := tx.ExecContext(ctx, query,
record.GameID, record.InitialFEN,
@@ -52,7 +54,8 @@ func (s *Store) RecordNewGame(record GameRecord) error {
nullableString(record.WhiteClaimedBy),
record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime,
nullableString(record.BlackClaimedBy),
record.StartTimeUTC, nullableString(record.Result), record.EndTimeUTC,
record.StartTimeUTC, nullableString(record.Result), nullableString(record.Termination),
record.EndTimeUTC,
)
return err
})
@@ -74,7 +77,7 @@ func (s *Store) RecordMove(record MovePersistence) error {
if (record.ClaimColor == "") != (record.ClaimedBy == "") {
return errors.New("claim color and claimant must be provided together")
}
if err := validateResultTime(record.Result, record.EndTimeUTC); err != nil {
if err := validateOutcome(record.Result, record.Termination, record.EndTimeUTC); err != nil {
return err
}
@@ -90,65 +93,88 @@ func (s *Store) RecordMove(record MovePersistence) error {
record.Move.PlayerColor,
record.Move.MoveTimeUTC,
); err != nil {
return err
return gameMissingOr(err, record.Move.GameID)
}
if record.ClaimedBy == "" && record.Result == "" {
return nil
}
// One UPDATE applies the optional claim and optional result. A claim may
// only fill an empty slot or repeat the same claimant.
claimColumn, nameColumn := "white_claimed_by", "white_name"
if record.ClaimColor == "b" {
claimColumn, nameColumn = "black_claimed_by", "black_name"
}
var set []string
args := []any{record.Move.GameID}
where := "game_id = $1"
if record.ClaimedBy != "" {
args = append(args, record.ClaimedBy)
param := fmt.Sprintf("$%d", len(args))
set = append(set, fmt.Sprintf("%s = %s, %s = COALESCE(%s, %s)",
claimColumn, param, nameColumn, nameColumn, claimantName(param)))
where += fmt.Sprintf(" AND (%s IS NULL OR %s = %s)", claimColumn, claimColumn, param)
}
if record.Result != "" {
args = append(args, record.Result, record.EndTimeUTC)
set = append(set, fmt.Sprintf("result = $%d, end_time_utc = $%d", len(args)-1, len(args)))
}
result, err := tx.ExecContext(ctx,
"UPDATE games SET "+strings.Join(set, ", ")+" WHERE "+where, args...)
if err != nil {
return err
}
return requireOneGame(result, record.Move.GameID)
return updateGameEnd(ctx, tx, record.Move.GameID, record.ClaimColor, record.ClaimedBy,
record.Result, record.Termination, record.EndTimeUTC)
})
}
// RecordGameResult persists a terminal transition not accompanied by a move,
// such as a no-legal-moves engine response.
func (s *Store) RecordGameResult(gameID, result string, at time.Time) error {
if gameID == "" {
// GameEnd is a terminal transition not accompanied by a move: a resignation,
// a draw by agreement, or an engine that found no legal move. ClaimColor and
// ClaimedBy optionally record the acting user's claim of an empty slot.
type GameEnd struct {
GameID string
Result string
Termination string
EndTimeUTC time.Time
ClaimColor string
ClaimedBy string
}
// RecordGameEnd persists the result, how it was reached, and any claim in one
// transaction.
func (s *Store) RecordGameEnd(end GameEnd) error {
if end.GameID == "" {
return errors.New("game ID is required")
}
if !isValidResult(result) {
return fmt.Errorf("invalid game result %q", result)
if end.EndTimeUTC.IsZero() {
return errors.New("game end time is required")
}
if at.IsZero() {
return errors.New("game result time is required")
ended := end.EndTimeUTC.UTC()
if err := validateOutcome(end.Result, end.Termination, &ended); err != nil {
return err
}
return s.enqueue("record_game_result", gameID, func(ctx context.Context, tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE games SET result = $2, end_time_utc = $3 WHERE game_id = $1`,
gameID, result, at.UTC(),
)
if err != nil {
return err
}
return requireOneGame(res, gameID)
if end.Result == "" {
return errors.New("game result is required")
}
if end.ClaimColor != "" && end.ClaimColor != "w" && end.ClaimColor != "b" {
return fmt.Errorf("invalid claim color %q", end.ClaimColor)
}
if (end.ClaimColor == "") != (end.ClaimedBy == "") {
return errors.New("claim color and claimant must be provided together")
}
return s.enqueue("record_game_end", end.GameID, func(ctx context.Context, tx *sql.Tx) error {
return updateGameEnd(ctx, tx, end.GameID, end.ClaimColor, end.ClaimedBy,
end.Result, end.Termination, &ended)
})
}
// updateGameEnd applies an optional claim and an optional result in one
// UPDATE. A claim may only fill an empty slot or repeat the same claimant.
func updateGameEnd(ctx context.Context, tx *sql.Tx, gameID, claimColor, claimedBy,
result, termination string, ended *time.Time) error {
claimColumn, nameColumn := "white_claimed_by", "white_name"
if claimColor == "b" {
claimColumn, nameColumn = "black_claimed_by", "black_name"
}
var set []string
args := []any{gameID}
where := "game_id = $1"
if claimedBy != "" {
args = append(args, claimedBy)
param := fmt.Sprintf("$%d", len(args))
set = append(set, fmt.Sprintf("%s = %s, %s = COALESCE(%s, %s)",
claimColumn, param, nameColumn, nameColumn, claimantName(param)))
where += fmt.Sprintf(" AND (%s IS NULL OR %s = %s)", claimColumn, claimColumn, param)
}
if result != "" {
args = append(args, result, termination, ended)
set = append(set, fmt.Sprintf("result = $%d, termination = $%d, end_time_utc = $%d",
len(args)-2, len(args)-1, len(args)))
}
res, err := tx.ExecContext(ctx,
"UPDATE games SET "+strings.Join(set, ", ")+" WHERE "+where, args...)
if err != nil {
return err
}
return requireOneGame(ctx, tx, res, gameID)
}
// PlayerRecord is the persistence subset of a player configuration.
type PlayerRecord struct {
PlayerID string
@@ -177,7 +203,7 @@ func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error {
if err != nil {
return err
}
return requireOneGame(res, gameID)
return requireOneGame(ctx, tx, res, gameID)
})
}
@@ -195,13 +221,13 @@ func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
return err
}
res, err := tx.ExecContext(ctx,
`UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = $1`,
`UPDATE games SET result = NULL, termination = NULL, end_time_utc = NULL WHERE game_id = $1`,
gameID,
)
if err != nil {
return err
}
return requireOneGame(res, gameID)
return requireOneGame(ctx, tx, res, gameID)
})
}
@@ -339,23 +365,70 @@ func (s *Store) GetGameHistory(gameID string) (*GameRecord, []MoveRecord, error)
return &record, moves, nil
}
// QueryGamesForUser returns a page of games claimed by userID in either color,
// newest first. The last move is read through a LATERAL probe of the moves
// primary key, so the summary costs one index lookup per game rather than a
// move count over the whole line.
func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSummaryRecord, error) {
if userID == "" || limit < 1 || limit > MaxUserGamesPage || offset < 0 {
// UserGamesQuery selects a page of a user's games, newest first. After, when
// set, continues strictly below that (start time, game ID) key; Offset
// remains for clients that page by position and must be 0 with After.
type UserGamesQuery struct {
Limit int
Offset int
After *GameCursor
Color string // "" either, "w" or "b": the color the user claimed
Status string // "" any, "ongoing", or "finished"
}
// GameCursor is a keyset position in a newest-first game listing.
type GameCursor struct {
StartTimeUTC time.Time
GameID string
}
// QueryGamesForUser returns a page of games claimed by userID, newest first.
// The last move is read through a LATERAL probe of the moves primary key, so
// the summary costs one index lookup per game rather than a move count over
// the whole line.
func (s *Store) QueryGamesForUser(userID string, q UserGamesQuery) ([]GameSummaryRecord, error) {
if userID == "" || q.Limit < 1 || q.Limit > MaxUserGamesPage || q.Offset < 0 {
return nil, fmt.Errorf("user ID, limit from 1 to %d, and non-negative offset are required",
MaxUserGamesPage)
}
if q.After != nil && (q.Offset != 0 || !validUUID(q.After.GameID)) {
return nil, errors.New("a cursor requires a valid game ID and offset 0")
}
if !validUUID(userID) {
return []GameSummaryRecord{}, nil
}
args := []any{userID, q.Limit, q.Offset}
var where string
switch q.Color {
case "":
where = "(g.white_claimed_by = $1 OR g.black_claimed_by = $1)"
case "w":
where = "g.white_claimed_by = $1"
case "b":
where = "g.black_claimed_by = $1"
default:
return nil, fmt.Errorf("invalid color filter %q", q.Color)
}
switch q.Status {
case "":
case "ongoing":
where += " AND g.result IS NULL"
case "finished":
where += " AND g.result IS NOT NULL"
default:
return nil, fmt.Errorf("invalid status filter %q", q.Status)
}
if q.After != nil {
args = append(args, q.After.StartTimeUTC, q.After.GameID)
where += " AND (g.start_time_utc, g.game_id) < ($4, $5)"
}
if err := s.flushBeforeRead(); err != nil {
return nil, err
}
started := time.Now()
const query = `SELECT ` + gameSelectColumns + `,
query := `SELECT ` + gameSelectColumns + `,
COALESCE(last.move_number, 0), COALESCE(last.fen_after_move, g.initial_fen)
FROM games g
LEFT JOIN LATERAL (
@@ -364,12 +437,12 @@ func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSumma
ORDER BY m.move_number DESC
LIMIT 1
) last ON true
WHERE g.white_claimed_by = $1 OR g.black_claimed_by = $1
WHERE ` + where + `
ORDER BY g.start_time_utc DESC, g.game_id DESC
LIMIT $2 OFFSET $3`
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx, query, userID, limit, offset)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query user games: %w", err)
}
@@ -389,20 +462,63 @@ func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSumma
slog.Debug("storage user games queried",
"user_id", userID,
"count", len(games),
"limit", limit,
"offset", offset,
"limit", q.Limit,
"offset", q.Offset,
"cursor", q.After != nil,
"duration", time.Since(started),
)
return games, nil
}
// ResolveGameID expands a game ID prefix of at least 8 hexadecimal digits,
// as printed by administrative listings, to the unique full ID.
func (s *Store) ResolveGameID(prefix string) (string, error) {
prefix = strings.ToLower(prefix)
if validUUID(prefix) {
return prefix, nil
}
if len(prefix) < 8 || len(prefix) > 36 || strings.Trim(prefix, "0123456789abcdef-") != "" {
return "", fmt.Errorf("game ID or a prefix of at least 8 hex digits required, got %q", prefix)
}
if err := s.flushBeforeRead(); err != nil {
return "", err
}
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx,
`SELECT game_id FROM games WHERE game_id::text LIKE $1 || '%' ORDER BY game_id LIMIT 2`, prefix)
if err != nil {
return "", fmt.Errorf("resolve game ID: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return "", fmt.Errorf("resolve game ID: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return "", fmt.Errorf("resolve game ID: %w", err)
}
switch len(ids) {
case 0:
return "", sql.ErrNoRows
case 1:
return ids[0], nil
default:
return "", fmt.Errorf("game ID prefix %q is ambiguous", prefix)
}
}
type rowScanner interface {
Scan(dest ...any) error
}
// scanGame scans gameSelectColumns followed by any extra destinations.
func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
var whiteClaimed, whiteName, blackClaimed, blackName, result sql.NullString
var whiteClaimed, whiteName, blackClaimed, blackName, result, termination sql.NullString
var endTime sql.NullTime
dest := []any{
&record.GameID, &record.InitialFEN,
@@ -410,7 +526,7 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
&whiteClaimed, &whiteName,
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime,
&blackClaimed, &blackName,
&result, &record.StartTimeUTC, &endTime,
&result, &termination, &record.StartTimeUTC, &endTime,
}
if err := scanner.Scan(append(dest, extra...)...); err != nil {
return err
@@ -420,20 +536,47 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
record.BlackClaimedBy = blackClaimed.String
record.BlackName = blackName.String
record.Result = result.String
record.Termination = termination.String
record.StartTimeUTC = record.StartTimeUTC.UTC()
record.EndTimeUTC = utcPointer(endTime)
return nil
}
func requireOneGame(result sql.Result, gameID string) error {
// ErrGameMissing reports a write for a game whose row no longer exists,
// typically deleted by hand while the server still held the game. The writer
// drops such a write without degrading storage; see SetGameMissingHandler.
var ErrGameMissing = errors.New("game row no longer exists")
// requireOneGame checks that an UPDATE matched its game, telling a missing
// row apart from a row whose guard (such as a claim) did not match.
func requireOneGame(ctx context.Context, tx *sql.Tx, result sql.Result, gameID string) error {
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows != 1 {
return fmt.Errorf("game %s was not updated", gameID)
if rows == 1 {
return nil
}
return nil
var exists bool
if err := tx.QueryRowContext(ctx,
`SELECT EXISTS (SELECT 1 FROM games WHERE game_id = $1)`, gameID,
).Scan(&exists); err != nil {
return err
}
if !exists {
return fmt.Errorf("%w: %s", ErrGameMissing, gameID)
}
return fmt.Errorf("game %s was not updated", gameID)
}
// gameMissingOr maps the moves-to-games foreign-key violation, raised when a
// move is inserted for a deleted game, to ErrGameMissing.
func gameMissingOr(err error, gameID string) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" && pgErr.ConstraintName == "moves_game_id_fkey" {
return fmt.Errorf("%w: %s", ErrGameMissing, gameID)
}
return err
}
func isValidResult(result string) bool {
@@ -445,16 +588,37 @@ func isValidResult(result string) bool {
}
}
func validateResultTime(result string, ended *time.Time) error {
// validTermination mirrors the games_termination_check constraint.
func validTermination(result, termination string) bool {
switch result {
case "white_wins", "black_wins":
return termination == "checkmate" || termination == "resignation"
case "stalemate":
return termination == "stalemate"
case "draw":
switch termination {
case "insufficient_material", "threefold_repetition", "fifty_move_rule", "agreement":
return true
}
}
return false
}
// validateOutcome checks that a result, its termination, and the end time are
// all present and consistent, or all absent.
func validateOutcome(result, termination string, ended *time.Time) error {
if result == "" {
if ended != nil {
return errors.New("end time requires a game result")
if termination != "" || ended != nil {
return errors.New("termination and end time require a game result")
}
return nil
}
if !isValidResult(result) {
return fmt.Errorf("invalid game result %q", result)
}
if !validTermination(result, termination) {
return fmt.Errorf("termination %q cannot produce result %q", termination, result)
}
if ended == nil || ended.IsZero() {
return errors.New("terminal game result requires an end time")
}
+144
View File
@@ -0,0 +1,144 @@
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
)
// Queries for the integrity sweep. None of them change data except the two
// Delete methods.
// ExistingGames reports which of ids have a games row. Flush first when ids
// include games whose insert may still be queued.
func (s *Store) ExistingGames(ids []string) (map[string]bool, error) {
exists := make(map[string]bool, len(ids))
if len(ids) == 0 {
return exists, nil
}
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx,
`SELECT game_id FROM games WHERE game_id = ANY($1::uuid[])`, ids)
if err != nil {
return nil, fmt.Errorf("query existing games: %w", err)
}
return exists, collectIDs(rows, func(id string) { exists[id] = true })
}
// GamesWithMoveGaps returns games whose moves are not numbered 1..n: a move
// row was deleted from the start or the middle of the line.
func (s *Store) GamesWithMoveGaps() ([]string, error) {
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx, `SELECT game_id FROM moves
GROUP BY game_id HAVING count(*) <> max(move_number) ORDER BY game_id`)
if err != nil {
return nil, fmt.Errorf("query move gaps: %w", err)
}
var ids []string
return ids, collectIDs(rows, func(id string) { ids = append(ids, id) })
}
// GameIDsAfter returns up to limit game IDs above after, in ID order; an
// empty after starts at the lowest.
func (s *Store) GameIDsAfter(after string, limit int) ([]string, error) {
if after == "" {
after = "00000000-0000-0000-0000-000000000000"
} else if !validUUID(after) {
return nil, fmt.Errorf("invalid game ID %q", after)
}
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx,
`SELECT game_id FROM games WHERE game_id > $1 ORDER BY game_id LIMIT $2`, after, limit)
if err != nil {
return nil, fmt.Errorf("query game IDs: %w", err)
}
var ids []string
return ids, collectIDs(rows, func(id string) { ids = append(ids, id) })
}
// OrphanedGames returns games that registered users claimed but whose
// claimants were all deleted, idle since cutoff by the anonymous-game rule
// (start, last move, and end all before it), excluding live games.
func (s *Store) OrphanedGames(cutoff time.Time, live []string) ([]string, error) {
if live == nil {
live = []string{}
}
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx, `SELECT g.game_id FROM games g
WHERE (g.white_claimed_by IS NOT NULL OR g.black_claimed_by IS NOT NULL)
AND NOT EXISTS (
SELECT 1 FROM users u
WHERE u.user_id IN (g.white_claimed_by, g.black_claimed_by)
)
AND g.start_time_utc < $1
AND (g.end_time_utc IS NULL OR g.end_time_utc < $1)
AND NOT EXISTS (
SELECT 1 FROM moves m
WHERE m.game_id = g.game_id AND m.move_time_utc >= $1
)
AND NOT (g.game_id = ANY($2::uuid[]))
ORDER BY g.game_id`, cutoff.UTC(), live)
if err != nil {
return nil, fmt.Errorf("query orphaned games: %w", err)
}
var ids []string
return ids, collectIDs(rows, func(id string) { ids = append(ids, id) })
}
// DeleteGames queues deletion of games and, by cascade, their moves. It runs
// through the ordered writer, after every write queued before it.
func (s *Store) DeleteGames(ids []string) error {
if len(ids) == 0 {
return nil
}
return s.enqueue("delete_games", "", func(ctx context.Context, tx *sql.Tx) error {
result, err := tx.ExecContext(ctx, `DELETE FROM games WHERE game_id = ANY($1::uuid[])`, ids)
if err != nil {
return err
}
if deleted, err := result.RowsAffected(); err == nil {
slog.Info("storage games deleted", "count", deleted)
}
return nil
})
}
// DeleteUsers deletes accounts and, by cascade, their sessions. Their games
// keep the claims and name snapshots.
func (s *Store) DeleteUsers(ids []string) error {
if len(ids) == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
defer cancel()
result, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE user_id = ANY($1::uuid[])`, ids)
if err != nil {
return fmt.Errorf("delete users: %w", err)
}
if deleted, err := result.RowsAffected(); err == nil {
slog.Info("storage users deleted", "count", deleted)
}
return nil
}
func collectIDs(rows *sql.Rows, add func(string)) error {
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
add(id)
}
if err := rows.Err(); err != nil {
return errors.Join(errors.New("iterate game IDs"), err)
}
return nil
}
+27 -6
View File
@@ -38,6 +38,7 @@ type GameRecord struct {
BlackClaimedBy string
BlackName string
Result string
Termination string // how Result was reached; empty while unfinished
StartTimeUTC time.Time
EndTimeUTC *time.Time
}
@@ -63,17 +64,18 @@ type MoveRecord struct {
// MovePersistence groups changes caused by one accepted move so the move,
// first-move slot claim, and terminal result commit in one transaction.
type MovePersistence struct {
Move MoveRecord
ClaimColor string
ClaimedBy string
Result string
EndTimeUTC *time.Time
Move MoveRecord
ClaimColor string
ClaimedBy string
Result string
Termination string
EndTimeUTC *time.Time
}
// schemaVersion is the newest schema this binary understands. Migrations are
// applied in order inside one transaction; PostgreSQL DDL is transactional, so
// a failed upgrade leaves the previous version intact.
const schemaVersion = 1
const schemaVersion = 2
// migrations[v-1] upgrades the schema from version v-1 to v. Never edit a
// released migration; append a new one.
@@ -161,6 +163,25 @@ CREATE INDEX games_white_claimed_idx ON games (white_claimed_by, start_time_utc
CREATE INDEX games_black_claimed_idx ON games (black_claimed_by, start_time_utc DESC, game_id DESC)
WHERE black_claimed_by IS NOT NULL;
`,
// v2: how a result was reached. Results before v2 came only from
// checkmate and stalemate (the server never produced "draw"), so the
// backfill is exact for them; a hand-written draw becomes "agreement".
`
ALTER TABLE games ADD COLUMN termination text;
UPDATE games SET termination = CASE result
WHEN 'stalemate' THEN 'stalemate'
WHEN 'draw' THEN 'agreement'
ELSE 'checkmate' END
WHERE result IS NOT NULL;
ALTER TABLE games
ADD CONSTRAINT games_result_termination_check CHECK ((result IS NULL) = (termination IS NULL)),
ADD CONSTRAINT games_termination_check CHECK (
(result IN ('white_wins', 'black_wins') AND termination IN ('checkmate', 'resignation'))
OR (result = 'stalemate' AND termination = 'stalemate')
OR (result = 'draw' AND termination IN
('insufficient_material', 'threefold_repetition', 'fifty_move_rule', 'agreement')));
`,
}
// ownedTables lists every table created by migrations, children first.
+17
View File
@@ -68,6 +68,15 @@ type Store struct {
enqueueMu sync.RWMutex
closeOnce sync.Once
closeErr error
gameMissing atomic.Pointer[func(gameID string)]
}
// SetGameMissingHandler registers the callback run, on its own goroutine,
// when a write finds its game row gone. Such a write is dropped without
// degrading storage: later writes for that game fail the same way and write
// nothing, so no other game's history is affected.
func (s *Store) SetGameMissingHandler(handler func(gameID string)) {
s.gameMissing.Store(&handler)
}
// NewStore connects to PostgreSQL and starts the async writer. dsn is a
@@ -203,6 +212,14 @@ func (s *Store) executeWrite(req writeRequest) error {
)
return nil
}
if errors.Is(err, ErrGameMissing) {
slog.Warn("storage write dropped: game row no longer exists",
"operation", req.operation, "game_id", req.gameID)
if handler := s.gameMissing.Load(); handler != nil && req.gameID != "" {
go (*handler)(req.gameID)
}
return err
}
if committing || attempt >= writeAttempts || !isTransient(err) {
break
}
+229 -5
View File
@@ -116,7 +116,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
FENAfterMove: "after-e2e4", PlayerColor: "w", MoveTimeUTC: ended,
},
ClaimColor: "w", ClaimedBy: user,
Result: "white_wins", EndTimeUTC: &ended,
Result: "white_wins", Termination: "checkmate", EndTimeUTC: &ended,
}); err != nil {
t.Fatal(err)
}
@@ -137,7 +137,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
t.Fatalf("moves = %+v", moves)
}
owned, err := store.QueryGamesForUser(user, 10, 0)
owned, err := store.QueryGamesForUser(user, UserGamesQuery{Limit: 10})
if err != nil {
t.Fatal(err)
}
@@ -155,7 +155,7 @@ func TestReplayPersistenceIsAtomicAndReadAfterWriteConsistent(t *testing.T) {
if record.Result != "" || record.EndTimeUTC != nil || len(moves) != 0 {
t.Fatalf("rewind left stale replay data: game=%+v moves=%+v", record, moves)
}
owned, err = store.QueryGamesForUser(user, 10, 0)
owned, err = store.QueryGamesForUser(user, UserGamesQuery{Limit: 10})
if err != nil {
t.Fatal(err)
}
@@ -188,6 +188,8 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) {
record.WhiteClaimedBy = user
case 1:
record.BlackClaimedBy = user
end := record.StartTimeUTC.Add(time.Minute)
record.Result, record.Termination, record.EndTimeUTC = "white_wins", "resignation", &end
case 2:
record.WhiteClaimedBy, record.BlackClaimedBy = user, user // self-play
case 3:
@@ -205,7 +207,7 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) {
var got []string
for offset := 0; ; offset += 2 {
page, err := store.QueryGamesForUser(user, 2, offset)
page, err := store.QueryGamesForUser(user, UserGamesQuery{Limit: 2, Offset: offset})
if err != nil {
t.Fatal(err)
}
@@ -219,9 +221,88 @@ func TestUserGamesArePagedNewestFirstWithoutDuplicates(t *testing.T) {
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("user games = %v, want %v", got, want)
}
if games, err := store.QueryGamesForUser("not-a-uuid", 10, 0); err != nil || len(games) != 0 {
if games, err := store.QueryGamesForUser("not-a-uuid", UserGamesQuery{Limit: 10}); err != nil || len(games) != 0 {
t.Fatalf("malformed user ID = %v, %v; want empty", games, err)
}
// Keyset pages match offset pages, and a game inserted at the head
// between pages does not shift the next page.
got = nil
var after *GameCursor
for page := 0; ; page++ {
games, err := store.QueryGamesForUser(user, UserGamesQuery{Limit: 2, After: after})
if err != nil {
t.Fatal(err)
}
for _, game := range games {
got = append(got, game.GameID)
}
if len(games) < 2 {
break
}
last := games[len(games)-1]
after = &GameCursor{StartTimeUTC: last.StartTimeUTC, GameID: last.GameID}
if page == 0 {
newer := GameRecord{
GameID: uuid.NewString(), InitialFEN: "initial",
WhitePlayerID: user, WhiteType: 1, WhiteClaimedBy: user,
BlackPlayerID: uuid.NewString(), BlackType: 1,
StartTimeUTC: base.Add(time.Hour),
}
if err := store.RecordNewGame(newer); err != nil {
t.Fatal(err)
}
}
}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("keyset pages = %v, want %v", got, want)
}
count := func(q UserGamesQuery) int {
t.Helper()
q.Limit = 50
games, err := store.QueryGamesForUser(user, q)
if err != nil {
t.Fatal(err)
}
return len(games)
}
// Five games claimed by user: indexes 0, 1, 2, 4 and the newer one.
for _, tc := range []struct {
q UserGamesQuery
want int
}{
{UserGamesQuery{}, 5},
{UserGamesQuery{Color: "w"}, 3},
{UserGamesQuery{Color: "b"}, 3},
{UserGamesQuery{Status: "finished"}, 1},
{UserGamesQuery{Status: "ongoing"}, 4},
{UserGamesQuery{Color: "b", Status: "finished"}, 1},
} {
if got := count(tc.q); got != tc.want {
t.Errorf("QueryGamesForUser(%+v) = %d games, want %d", tc.q, got, tc.want)
}
}
for _, bad := range []UserGamesQuery{
{Limit: 1, Color: "x"}, {Limit: 1, Status: "x"},
{Limit: 1, Offset: 1, After: &GameCursor{GameID: user}},
{Limit: 1, After: &GameCursor{GameID: "x"}},
} {
if _, err := store.QueryGamesForUser(user, bad); err == nil {
t.Errorf("QueryGamesForUser(%+v) succeeded", bad)
}
}
prefix := want[0][:8]
if id, err := store.ResolveGameID(prefix); err != nil || id != want[0] {
t.Errorf("ResolveGameID(%s) = %q, %v; want %s", prefix, id, err, want[0])
}
if _, err := store.ResolveGameID("0000000"); err == nil {
t.Error("ResolveGameID accepted a 7-digit prefix")
}
if _, err := store.ResolveGameID("ffffffff-ffff"); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("ResolveGameID(unknown) error = %v, want sql.ErrNoRows", err)
}
}
func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) {
@@ -242,6 +323,14 @@ func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) {
args: []any{userID},
want: []string{"games_white_claimed_idx", "games_black_claimed_idx"},
},
{
name: "keyset page of one color uses its claim index",
query: `SELECT game_id FROM games g
WHERE g.white_claimed_by = $1 AND (g.start_time_utc, g.game_id) < ($2, $3)
ORDER BY g.start_time_utc DESC, g.game_id DESC LIMIT 50`,
args: []any{userID, time.Now(), userID},
want: []string{"games_white_claimed_idx"},
},
{
name: "last move probe uses the moves primary key",
query: `SELECT move_number FROM moves WHERE game_id = $1
@@ -621,6 +710,12 @@ func TestAnonymousGamesArePurgedAfterInactivity(t *testing.T) {
BlackPlayerID: uuid.NewString(), BlackType: 2, BlackLevel: 5, BlackSearchTime: 100,
StartTimeUTC: started, Result: result, EndTimeUTC: ended,
}
switch result {
case "stalemate":
record.Termination = "stalemate"
case "white_wins", "black_wins":
record.Termination = "checkmate"
}
if err := store.RecordNewGame(record); err != nil {
t.Fatal(err)
}
@@ -792,3 +887,132 @@ func explain(t *testing.T, db *sql.DB, query string, args ...any) string {
}
return strings.Join(lines, "\n")
}
func TestMigrationV2BackfillsTermination(t *testing.T) {
dsn := pgtest.DSN(t)
store, err := NewStore(dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
// A version 1 database with one game of each stored result.
if _, err := store.db.Exec(migrations[0]); err != nil {
t.Fatal(err)
}
if _, err := store.db.Exec(`INSERT INTO schema_version (version) VALUES (1)`); err != nil {
t.Fatal(err)
}
ids := map[string]string{}
for _, result := range []string{"", "white_wins", "black_wins", "stalemate", "draw"} {
id := uuid.NewString()
ids[result] = id
var end any
if result != "" {
end = time.Now()
}
if _, err := store.db.Exec(`INSERT INTO games (game_id, initial_fen, white_player_id, white_type,
black_player_id, black_type, result, end_time_utc) VALUES ($1, 'initial', $2, 1, $3, 1, $4, $5)`,
id, uuid.NewString(), uuid.NewString(), nullableString(result), end); err != nil {
t.Fatal(err)
}
}
if err := store.InitDB(); err != nil {
t.Fatal(err)
}
for result, want := range map[string]string{
"": "", "white_wins": "checkmate", "black_wins": "checkmate", "stalemate": "stalemate", "draw": "agreement",
} {
var termination sql.NullString
if err := store.db.QueryRow(`SELECT termination FROM games WHERE game_id = $1`, ids[result]).
Scan(&termination); err != nil {
t.Fatal(err)
}
if termination.String != want {
t.Errorf("result %q: termination %q, want %q", result, termination.String, want)
}
}
// The constraints reject a termination that cannot produce the result.
if _, err := store.db.Exec(`UPDATE games SET termination = 'agreement' WHERE game_id = $1`,
ids["white_wins"]); err == nil {
t.Error("games_termination_check accepted a win by agreement")
}
if _, err := store.db.Exec(`UPDATE games SET termination = 'resignation' WHERE game_id = $1`,
ids[""]); err == nil {
t.Error("games_result_termination_check accepted a termination without a result")
}
}
func TestWriteForDeletedGameIsDroppedWithoutDegrading(t *testing.T) {
store := openStore(t, pgtest.DSN(t))
missing := make(chan string, 4)
store.SetGameMissingHandler(func(gameID string) { missing <- gameID })
gameID := uuid.NewString()
if err := store.RecordNewGame(GameRecord{
GameID: gameID, InitialFEN: "initial",
WhitePlayerID: uuid.NewString(), WhiteType: 1,
BlackPlayerID: uuid.NewString(), BlackType: 1,
StartTimeUTC: time.Now(),
}); err != nil {
t.Fatal(err)
}
if err := store.Flush(context.Background()); err != nil {
t.Fatal(err)
}
// An administrator deletes the game while the server still holds it.
if _, err := store.db.Exec(`DELETE FROM games WHERE game_id = $1`, gameID); err != nil {
t.Fatal(err)
}
ended := time.Now()
writes := []func() error{
func() error {
return store.RecordMove(MovePersistence{Move: MoveRecord{
GameID: gameID, MoveNumber: 1, MoveUCI: "e2e4", FENAfterMove: "after",
PlayerColor: "w", MoveTimeUTC: ended,
}})
},
func() error {
return store.RecordGameEnd(GameEnd{
GameID: gameID, Result: "black_wins", Termination: "resignation", EndTimeUTC: ended,
})
},
func() error { return store.RewindGame(gameID, 0) },
}
for i, write := range writes {
if err := write(); err != nil {
t.Fatalf("write %d not queued: %v", i, err)
}
if err := store.Flush(context.Background()); err != nil {
t.Fatalf("write %d degraded storage: %v", i, err)
}
select {
case id := <-missing:
if id != gameID {
t.Fatalf("handler got %s, want %s", id, gameID)
}
case <-time.After(5 * time.Second):
t.Fatalf("write %d: missing-game handler not called", i)
}
}
if !store.IsHealthy() {
t.Fatal("storage degraded by writes for a deleted game")
}
// Other games are unaffected.
other := uuid.NewString()
if err := store.RecordNewGame(GameRecord{
GameID: other, InitialFEN: "initial",
WhitePlayerID: uuid.NewString(), WhiteType: 1,
BlackPlayerID: uuid.NewString(), BlackType: 1,
StartTimeUTC: time.Now(),
}); err != nil {
t.Fatal(err)
}
if _, _, err := store.GetGameHistory(other); err != nil {
t.Fatalf("other game: %v", err)
}
}