mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat: persist ranked season rollovers
This commit is contained in:
@@ -112,6 +112,17 @@ CREATE TABLE seasons (
|
||||
rolled_over_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE ranked_season_rollovers (
|
||||
player_id TEXT NOT NULL REFERENCES identities(player_id),
|
||||
season_id TEXT NOT NULL REFERENCES seasons(season_id),
|
||||
rating DOUBLE PRECISION NOT NULL,
|
||||
deviation DOUBLE PRECISION NOT NULL,
|
||||
volatility DOUBLE PRECISION NOT NULL,
|
||||
ranked_games INTEGER NOT NULL CHECK (ranked_games >= 0),
|
||||
rolled_over_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (player_id, season_id)
|
||||
);
|
||||
|
||||
CREATE TABLE penalties (
|
||||
penalty_id TEXT PRIMARY KEY,
|
||||
player_id TEXT NOT NULL REFERENCES identities(player_id),
|
||||
|
||||
@@ -38,6 +38,8 @@ class MigrationTest(unittest.TestCase):
|
||||
def test_seasons_are_ranked_only_and_penalties_are_durable(self):
|
||||
self.assertIn("CHECK (playlist = 'ranked')", SQL)
|
||||
self.assertIn("CREATE TABLE penalties", SQL)
|
||||
self.assertIn("CREATE TABLE ranked_season_rollovers", SQL)
|
||||
self.assertIn("PRIMARY KEY (player_id, season_id)", SQL)
|
||||
self.assertIn("REFERENCES identities(player_id)", SQL)
|
||||
self.assertIn("REFERENCES matches(match_id)", SQL)
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
)
|
||||
|
||||
const SeasonRatingLockSQL = `SELECT player_id, rating, deviation, volatility, ranked_games, revision
|
||||
FROM ratings
|
||||
WHERE player_id = $1
|
||||
FOR UPDATE`
|
||||
|
||||
const SeasonRolloverInsertSQL = `INSERT INTO ranked_season_rollovers
|
||||
(player_id, season_id, rating, deviation, volatility, ranked_games, rolled_over_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (player_id, season_id) DO NOTHING`
|
||||
|
||||
const SeasonRatingUpdateSQL = `UPDATE ratings
|
||||
SET rating = $2, deviation = $3, volatility = $4, revision = revision + 1, updated_at = $5
|
||||
WHERE player_id = $1`
|
||||
|
||||
// ApplyRankedSeasonRollover persists the domain rollover exactly once. The
|
||||
// marker insert and rating update share one SERIALIZABLE transaction, so a
|
||||
// retry after a worker failure cannot apply compression twice or leave a
|
||||
// marker without its corresponding rating snapshot.
|
||||
func ApplyRankedSeasonRollover(ctx context.Context, db *sql.DB, playerID, seasonID string, profile domain.RankedProfile, now time.Time) (domain.RankedProfile, bool, error) {
|
||||
if playerID == "" {
|
||||
return domain.RankedProfile{}, false, fmt.Errorf("player ID is required")
|
||||
}
|
||||
updated, _, err := domain.ApplySeasonRollover(profile, seasonID)
|
||||
if err != nil {
|
||||
return domain.RankedProfile{}, false, err
|
||||
}
|
||||
applied := false
|
||||
err = RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, SeasonRatingLockSQL, playerID); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, SeasonRolloverInsertSQL, playerID, seasonID, updated.Value, updated.RD, updated.Volatility, updated.RankedGames, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, SeasonRatingUpdateSQL, playerID, updated.Value, updated.RD, updated.Volatility, now); err != nil {
|
||||
return err
|
||||
}
|
||||
applied = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.RankedProfile{}, false, err
|
||||
}
|
||||
return updated, applied, nil
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func TestRetryableRecognisesPostgresSerializationAndDeadlockErrors(t *testing.T)
|
||||
}
|
||||
|
||||
func TestClaimSQLContainsDurableOwnershipFences(t *testing.T) {
|
||||
for _, fragment := range []string{"FOR UPDATE SKIP LOCKED", "state = 'QUEUED'", "proposal_participants", "revision = revision + 1", "INSERT INTO proposals"} {
|
||||
for _, fragment := range []string{"FOR UPDATE SKIP LOCKED", "state = 'QUEUED'", "proposal_participants", "revision = revision + 1", "INSERT INTO proposals", "ranked_season_rollovers", "ON CONFLICT (player_id, season_id) DO NOTHING"} {
|
||||
if !containsAnySQL(fragment) {
|
||||
t.Fatalf("claim boundary missing %q", fragment)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func TestClaimSQLContainsDurableOwnershipFences(t *testing.T) {
|
||||
}
|
||||
|
||||
func containsAnySQL(fragment string) bool {
|
||||
return index(CandidateClaimSQL, fragment) >= 0 || index(ProposalParticipantInsertSQL, fragment) >= 0 || index(QueueTicketProposeSQL, fragment) >= 0 || index(ProposalInsertSQL, fragment) >= 0
|
||||
return index(CandidateClaimSQL, fragment) >= 0 || index(ProposalParticipantInsertSQL, fragment) >= 0 || index(QueueTicketProposeSQL, fragment) >= 0 || index(ProposalInsertSQL, fragment) >= 0 || index(SeasonRolloverInsertSQL, fragment) >= 0
|
||||
}
|
||||
|
||||
func index(s, fragment string) int {
|
||||
|
||||
Reference in New Issue
Block a user