mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
74 lines
2.6 KiB
Go
74 lines
2.6 KiB
Go
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 db == nil || playerID == "" || seasonID == "" || now.IsZero() {
|
|
return domain.RankedProfile{}, false, fmt.Errorf("invalid season rollover arguments")
|
|
}
|
|
// The caller's profile is only a validation-compatible hint. The durable
|
|
// row is authoritative because a result update may have committed after the
|
|
// caller read its snapshot but before this transaction acquired the lock.
|
|
_ = profile
|
|
var updated domain.RankedProfile
|
|
applied := false
|
|
err := RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
|
|
var locked domain.RankedProfile
|
|
var revision int64
|
|
if err := tx.QueryRowContext(ctx, SeasonRatingLockSQL, playerID).Scan(&locked.Value, &locked.RD, &locked.Volatility, &locked.RankedGames, &revision); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
updated, _, err = domain.ApplySeasonRollover(locked, seasonID)
|
|
if 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 {
|
|
updated = locked
|
|
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
|
|
}
|