1040 lines
31 KiB
Go
1040 lines
31 KiB
Go
// Package racenamedir implements the PostgreSQL-backed adapter for
|
|
// `ports.RaceNameDirectory`.
|
|
//
|
|
// One row in the `race_names` table backs one of three bindings on a
|
|
// canonical key: a registered name (one per canonical_key, immutable
|
|
// holder), a per-game reservation, or a pending_registration created by a
|
|
// capable game finish. The composite primary key (canonical_key, game_id)
|
|
// matches the existing two-tier semantics, where the same user may hold
|
|
// reservations on the same canonical key across multiple active games.
|
|
// Registered rows store game_id = '' and keep the source game in
|
|
// source_game_id, so the per-canonical uniqueness rule reduces to a
|
|
// partial UNIQUE index. Cross-user collisions on canonical_key are
|
|
// arbitrated by serialising every write transaction with
|
|
// pg_advisory_xact_lock(hashtextextended(canonical_key, 0)).
|
|
//
|
|
// PG_PLAN.md §6B introduces this adapter; see
|
|
// `galaxy/lobby/docs/postgres-migration.md` for the full decision record.
|
|
package racenamedir
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"galaxy/lobby/internal/adapters/postgres/internal/sqlx"
|
|
pgtable "galaxy/lobby/internal/adapters/postgres/jet/lobby/table"
|
|
"galaxy/lobby/internal/domain/common"
|
|
"galaxy/lobby/internal/domain/racename"
|
|
"galaxy/lobby/internal/ports"
|
|
|
|
pg "github.com/go-jet/jet/v2/postgres"
|
|
)
|
|
|
|
// Binding kind values stored verbatim in `race_names.binding_kind`. They
|
|
// equal the corresponding `ports.Kind*` constants so adapter methods can
|
|
// surface them at the port boundary without translation.
|
|
const (
|
|
bindingRegistered = ports.KindRegistered // "registered"
|
|
bindingReservation = ports.KindReservation // "reservation"
|
|
bindingPending = ports.KindPendingRegistration // "pending_registration"
|
|
)
|
|
|
|
// registeredGameID is the sentinel value stored in the (canonical_key,
|
|
// game_id) primary key for binding_kind = 'registered' rows. The actual
|
|
// source game is kept in source_game_id; the empty `game_id` keeps
|
|
// registered rows distinct from per-game reservations under the same
|
|
// canonical_key.
|
|
const registeredGameID = ""
|
|
|
|
// Config configures one PostgreSQL-backed Race Name Directory adapter.
|
|
// The adapter does not own the underlying *sql.DB lifecycle: the caller
|
|
// (typically the service runtime) opens, instruments, migrates, and
|
|
// closes the pool.
|
|
type Config struct {
|
|
// DB is the connection pool the directory uses for every query.
|
|
DB *sql.DB
|
|
|
|
// OperationTimeout bounds one operation. Read-only methods derive a
|
|
// single context from it; write methods reuse the same bound across
|
|
// the BEGIN ... COMMIT transaction.
|
|
OperationTimeout time.Duration
|
|
|
|
// Policy supplies the canonical-key derivation and ValidateName
|
|
// rules; the adapter owns no race-name policy of its own.
|
|
Policy *racename.Policy
|
|
|
|
// Clock supplies wall-clock time used to stamp reserved_at_ms,
|
|
// registered_at_ms and the cutoff passed to
|
|
// ExpirePendingRegistrations.Defaults to time.Now when nil.
|
|
Clock func() time.Time
|
|
}
|
|
|
|
// Directory persists Race Name Directory bindings in PostgreSQL.
|
|
type Directory struct {
|
|
db *sql.DB
|
|
operationTimeout time.Duration
|
|
policy *racename.Policy
|
|
nowFn func() time.Time
|
|
}
|
|
|
|
// New constructs one PostgreSQL-backed Race Name Directory from cfg.
|
|
func New(cfg Config) (*Directory, error) {
|
|
if cfg.DB == nil {
|
|
return nil, errors.New("new postgres race name directory: db must not be nil")
|
|
}
|
|
if cfg.OperationTimeout <= 0 {
|
|
return nil, errors.New("new postgres race name directory: operation timeout must be positive")
|
|
}
|
|
if cfg.Policy == nil {
|
|
return nil, errors.New("new postgres race name directory: policy must not be nil")
|
|
}
|
|
nowFn := cfg.Clock
|
|
if nowFn == nil {
|
|
nowFn = time.Now
|
|
}
|
|
return &Directory{
|
|
db: cfg.DB,
|
|
operationTimeout: cfg.OperationTimeout,
|
|
policy: cfg.Policy,
|
|
nowFn: nowFn,
|
|
}, nil
|
|
}
|
|
|
|
// Canonicalize returns the canonical uniqueness key for raceName as a
|
|
// plain string. Validation failures map to ports.ErrInvalidName.
|
|
func (directory *Directory) Canonicalize(raceName string) (string, error) {
|
|
if directory == nil {
|
|
return "", errors.New("canonicalize race name: nil directory")
|
|
}
|
|
canonical, err := directory.policy.Canonicalize(raceName)
|
|
if err != nil {
|
|
return "", fmt.Errorf("canonicalize race name: %w", ports.ErrInvalidName)
|
|
}
|
|
return canonical.String(), nil
|
|
}
|
|
|
|
// Check reports whether raceName is taken for actorUserID. A concurrent
|
|
// Reserve may race against the result; service code that needs atomicity
|
|
// must rely on Reserve returning ErrNameTaken instead of pre-checking.
|
|
func (directory *Directory) Check(
|
|
ctx context.Context,
|
|
raceName, actorUserID string,
|
|
) (ports.Availability, error) {
|
|
if directory == nil {
|
|
return ports.Availability{}, errors.New("check race name: nil directory")
|
|
}
|
|
actor, err := normalizeNonEmpty(actorUserID, "check race name", "actor user id")
|
|
if err != nil {
|
|
return ports.Availability{}, err
|
|
}
|
|
canonical, err := directory.policy.Canonicalize(raceName)
|
|
if err != nil {
|
|
return ports.Availability{}, fmt.Errorf("check race name: %w", ports.ErrInvalidName)
|
|
}
|
|
|
|
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "check race name", directory.operationTimeout)
|
|
if err != nil {
|
|
return ports.Availability{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
stmt := pg.SELECT(
|
|
pgtable.RaceNames.HolderUserID,
|
|
pgtable.RaceNames.BindingKind,
|
|
).FROM(pgtable.RaceNames).
|
|
WHERE(pgtable.RaceNames.CanonicalKey.EQ(pg.String(canonical.String())))
|
|
|
|
query, args := stmt.Sql()
|
|
rows, err := directory.db.QueryContext(operationCtx, query, args...)
|
|
if err != nil {
|
|
return ports.Availability{}, fmt.Errorf("check race name: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var (
|
|
bestHolder string
|
|
bestKind string
|
|
bestRank int
|
|
)
|
|
for rows.Next() {
|
|
var holder, kind string
|
|
if err := rows.Scan(&holder, &kind); err != nil {
|
|
return ports.Availability{}, fmt.Errorf("check race name: scan: %w", err)
|
|
}
|
|
rank := bindingPriority(kind)
|
|
if bestKind == "" || rank < bestRank {
|
|
bestHolder = holder
|
|
bestKind = kind
|
|
bestRank = rank
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return ports.Availability{}, fmt.Errorf("check race name: %w", err)
|
|
}
|
|
if bestKind == "" {
|
|
return ports.Availability{}, nil
|
|
}
|
|
return ports.Availability{
|
|
Taken: bestHolder != actor,
|
|
HolderUserID: bestHolder,
|
|
Kind: bestKind,
|
|
}, nil
|
|
}
|
|
|
|
// Reserve claims raceName for (gameID, userID). Repeating the call with
|
|
// the same tuple is idempotent; cross-user collisions on the canonical
|
|
// key surface ports.ErrNameTaken.
|
|
func (directory *Directory) Reserve(
|
|
ctx context.Context,
|
|
gameID, userID, raceName string,
|
|
) error {
|
|
if directory == nil {
|
|
return errors.New("reserve race name: nil directory")
|
|
}
|
|
game, err := normalizeGameID(gameID, "reserve race name")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "reserve race name", "user id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
displayName, err := racename.ValidateName(raceName)
|
|
if err != nil {
|
|
return fmt.Errorf("reserve race name: %w", ports.ErrInvalidName)
|
|
}
|
|
canonical, err := directory.policy.Canonical(displayName)
|
|
if err != nil {
|
|
return fmt.Errorf("reserve race name: %w", ports.ErrInvalidName)
|
|
}
|
|
|
|
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "reserve race name", directory.operationTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
reservedAtMs := directory.nowFn().UTC().UnixMilli()
|
|
return directory.withCanonicalLock(operationCtx, canonical, "reserve race name", func(tx *sql.Tx) error {
|
|
existing, err := loadByCanonicalTx(operationCtx, tx, canonical)
|
|
if err != nil {
|
|
return fmt.Errorf("reserve race name: %w", err)
|
|
}
|
|
for _, r := range existing {
|
|
if r.holderUserID != user {
|
|
return ports.ErrNameTaken
|
|
}
|
|
}
|
|
// Same-user idempotency: a row already at this PK means the
|
|
// holder already binds this canonical for this game (whether as
|
|
// reservation or pending_registration). Skip the INSERT.
|
|
for _, r := range existing {
|
|
if r.gameID == game.String() {
|
|
return nil
|
|
}
|
|
}
|
|
stmt := pgtable.RaceNames.INSERT(
|
|
pgtable.RaceNames.CanonicalKey,
|
|
pgtable.RaceNames.GameID,
|
|
pgtable.RaceNames.HolderUserID,
|
|
pgtable.RaceNames.RaceName,
|
|
pgtable.RaceNames.BindingKind,
|
|
pgtable.RaceNames.SourceGameID,
|
|
pgtable.RaceNames.ReservedAtMs,
|
|
).VALUES(
|
|
canonical.String(),
|
|
game.String(),
|
|
user,
|
|
displayName,
|
|
bindingReservation,
|
|
game.String(),
|
|
reservedAtMs,
|
|
)
|
|
query, args := stmt.Sql()
|
|
if _, err := tx.ExecContext(operationCtx, query, args...); err != nil {
|
|
return fmt.Errorf("reserve race name: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// ReleaseReservation removes the reservation held by userID for raceName
|
|
// in gameID. Missing reservation, mismatched holder, and invalid raceName
|
|
// all resolve to a silent no-op per the port contract.
|
|
func (directory *Directory) ReleaseReservation(
|
|
ctx context.Context,
|
|
gameID, userID, raceName string,
|
|
) error {
|
|
if directory == nil {
|
|
return errors.New("release race name reservation: nil directory")
|
|
}
|
|
game, err := normalizeGameID(gameID, "release race name reservation")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "release race name reservation", "user id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
canonical, err := directory.policy.Canonicalize(raceName)
|
|
if err != nil {
|
|
// Invalid name is a silent no-op per the port contract.
|
|
if ctxErr := contextAlive(ctx); ctxErr != nil {
|
|
return ctxErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "release race name reservation", directory.operationTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
return directory.withCanonicalLock(operationCtx, canonical, "release race name reservation", func(tx *sql.Tx) error {
|
|
stmt := pgtable.RaceNames.DELETE().WHERE(pg.AND(
|
|
pgtable.RaceNames.CanonicalKey.EQ(pg.String(canonical.String())),
|
|
pgtable.RaceNames.GameID.EQ(pg.String(game.String())),
|
|
pgtable.RaceNames.HolderUserID.EQ(pg.String(user)),
|
|
))
|
|
query, args := stmt.Sql()
|
|
if _, err := tx.ExecContext(operationCtx, query, args...); err != nil {
|
|
return fmt.Errorf("release race name reservation: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// MarkPendingRegistration promotes the reservation for (gameID, userID)
|
|
// on raceName's canonical key to pending_registration status.
|
|
func (directory *Directory) MarkPendingRegistration(
|
|
ctx context.Context,
|
|
gameID, userID, raceName string,
|
|
eligibleUntil time.Time,
|
|
) error {
|
|
if directory == nil {
|
|
return errors.New("mark pending race name registration: nil directory")
|
|
}
|
|
game, err := normalizeGameID(gameID, "mark pending race name registration")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "mark pending race name registration", "user id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if eligibleUntil.IsZero() {
|
|
return fmt.Errorf("mark pending race name registration: eligible until must be set")
|
|
}
|
|
displayName, err := racename.ValidateName(raceName)
|
|
if err != nil {
|
|
return fmt.Errorf("mark pending race name registration: %w", ports.ErrInvalidName)
|
|
}
|
|
canonical, err := directory.policy.Canonical(displayName)
|
|
if err != nil {
|
|
return fmt.Errorf("mark pending race name registration: %w", ports.ErrInvalidName)
|
|
}
|
|
|
|
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "mark pending race name registration", directory.operationTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
eligibleUntilMs := eligibleUntil.UTC().UnixMilli()
|
|
return directory.withCanonicalLock(operationCtx, canonical, "mark pending race name registration", func(tx *sql.Tx) error {
|
|
existing, err := loadByCanonicalTx(operationCtx, tx, canonical)
|
|
if err != nil {
|
|
return fmt.Errorf("mark pending race name registration: %w", err)
|
|
}
|
|
var target *raceNameRow
|
|
for index, candidate := range existing {
|
|
if candidate.gameID == game.String() && candidate.holderUserID == user {
|
|
target = &existing[index]
|
|
break
|
|
}
|
|
}
|
|
if target == nil {
|
|
return fmt.Errorf("mark pending race name registration: reservation missing for game %q user %q", game, user)
|
|
}
|
|
switch target.bindingKind {
|
|
case bindingPending:
|
|
if target.eligibleUntilMs == nil || *target.eligibleUntilMs != eligibleUntilMs {
|
|
return fmt.Errorf("mark pending race name registration: %w", ports.ErrInvalidName)
|
|
}
|
|
return nil
|
|
case bindingReservation:
|
|
// promote
|
|
default:
|
|
return fmt.Errorf("mark pending race name registration: reservation missing for game %q user %q", game, user)
|
|
}
|
|
|
|
stmt := pgtable.RaceNames.UPDATE(
|
|
pgtable.RaceNames.BindingKind,
|
|
pgtable.RaceNames.RaceName,
|
|
pgtable.RaceNames.EligibleUntilMs,
|
|
).SET(
|
|
bindingPending,
|
|
displayName,
|
|
eligibleUntilMs,
|
|
).WHERE(pg.AND(
|
|
pgtable.RaceNames.CanonicalKey.EQ(pg.String(canonical.String())),
|
|
pgtable.RaceNames.GameID.EQ(pg.String(game.String())),
|
|
))
|
|
query, args := stmt.Sql()
|
|
if _, err := tx.ExecContext(operationCtx, query, args...); err != nil {
|
|
return fmt.Errorf("mark pending race name registration: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// ExpirePendingRegistrations releases every pending registration whose
|
|
// eligible_until_ms is at or before now. Each released entry is returned
|
|
// so callers can emit telemetry; running the method twice over the same
|
|
// state returns an empty slice the second time.
|
|
func (directory *Directory) ExpirePendingRegistrations(
|
|
ctx context.Context,
|
|
now time.Time,
|
|
) ([]ports.ExpiredPending, error) {
|
|
if directory == nil {
|
|
return nil, errors.New("expire pending race name registrations: nil directory")
|
|
}
|
|
|
|
cutoff := now.UTC().UnixMilli()
|
|
|
|
scanCtx, cancel, err := sqlx.WithTimeout(ctx, "expire pending race name registrations", directory.operationTimeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cancel()
|
|
|
|
stmt := pg.SELECT(
|
|
pgtable.RaceNames.CanonicalKey,
|
|
pgtable.RaceNames.GameID,
|
|
).FROM(pgtable.RaceNames).WHERE(pg.AND(
|
|
pgtable.RaceNames.BindingKind.EQ(pg.String(bindingPending)),
|
|
pgtable.RaceNames.EligibleUntilMs.LT_EQ(pg.Int(cutoff)),
|
|
))
|
|
query, args := stmt.Sql()
|
|
rows, err := directory.db.QueryContext(scanCtx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("expire pending race name registrations: %w", err)
|
|
}
|
|
type candidate struct {
|
|
canonical racename.CanonicalKey
|
|
gameID string
|
|
}
|
|
var candidates []candidate
|
|
for rows.Next() {
|
|
var canonicalKey, gameID string
|
|
if err := rows.Scan(&canonicalKey, &gameID); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("expire pending race name registrations: scan: %w", err)
|
|
}
|
|
candidates = append(candidates, candidate{
|
|
canonical: racename.CanonicalKey(canonicalKey),
|
|
gameID: gameID,
|
|
})
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, fmt.Errorf("expire pending race name registrations: %w", err)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("expire pending race name registrations: %w", err)
|
|
}
|
|
if len(candidates) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
expired := make([]ports.ExpiredPending, 0, len(candidates))
|
|
for _, cand := range candidates {
|
|
entry, released, err := directory.expireOne(ctx, cand.canonical, cand.gameID, cutoff)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("expire pending race name registrations: %w", err)
|
|
}
|
|
if released {
|
|
expired = append(expired, entry)
|
|
}
|
|
}
|
|
return expired, nil
|
|
}
|
|
|
|
// expireOne re-reads the candidate row under an advisory lock, deletes it
|
|
// when still pending and at-or-before cutoff, and returns the matching
|
|
// ExpiredPending entry. Concurrent transitions (Register, ReleaseReservation,
|
|
// or a refreshed eligible_until_ms) cause expireOne to skip the row.
|
|
func (directory *Directory) expireOne(
|
|
ctx context.Context,
|
|
canonical racename.CanonicalKey,
|
|
gameID string,
|
|
cutoff int64,
|
|
) (ports.ExpiredPending, bool, error) {
|
|
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "expire pending race name registrations", directory.operationTimeout)
|
|
if err != nil {
|
|
return ports.ExpiredPending{}, false, err
|
|
}
|
|
defer cancel()
|
|
|
|
var (
|
|
entry ports.ExpiredPending
|
|
released bool
|
|
)
|
|
err = directory.withCanonicalLock(operationCtx, canonical, "expire pending race name registrations", func(tx *sql.Tx) error {
|
|
row, found, err := loadOneByPKTx(operationCtx, tx, canonical, gameID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return nil
|
|
}
|
|
if row.bindingKind != bindingPending {
|
|
return nil
|
|
}
|
|
if row.eligibleUntilMs == nil || *row.eligibleUntilMs > cutoff {
|
|
return nil
|
|
}
|
|
stmt := pgtable.RaceNames.DELETE().WHERE(pg.AND(
|
|
pgtable.RaceNames.CanonicalKey.EQ(pg.String(canonical.String())),
|
|
pgtable.RaceNames.GameID.EQ(pg.String(gameID)),
|
|
))
|
|
query, args := stmt.Sql()
|
|
if _, err := tx.ExecContext(operationCtx, query, args...); err != nil {
|
|
return err
|
|
}
|
|
entry = ports.ExpiredPending{
|
|
CanonicalKey: canonical.String(),
|
|
RaceName: row.raceName,
|
|
GameID: gameID,
|
|
UserID: row.holderUserID,
|
|
EligibleUntilMs: *row.eligibleUntilMs,
|
|
}
|
|
released = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return ports.ExpiredPending{}, false, err
|
|
}
|
|
return entry, released, nil
|
|
}
|
|
|
|
// Register converts the pending registration identified by (gameID,
|
|
// userID) on raceName's canonical key into a registered race name.
|
|
func (directory *Directory) Register(
|
|
ctx context.Context,
|
|
gameID, userID, raceName string,
|
|
) error {
|
|
if directory == nil {
|
|
return errors.New("register race name: nil directory")
|
|
}
|
|
game, err := normalizeGameID(gameID, "register race name")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "register race name", "user id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
displayName, err := racename.ValidateName(raceName)
|
|
if err != nil {
|
|
return fmt.Errorf("register race name: %w", ports.ErrInvalidName)
|
|
}
|
|
canonical, err := directory.policy.Canonical(displayName)
|
|
if err != nil {
|
|
return fmt.Errorf("register race name: %w", ports.ErrInvalidName)
|
|
}
|
|
|
|
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "register race name", directory.operationTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
nowMs := directory.nowFn().UTC().UnixMilli()
|
|
return directory.withCanonicalLock(operationCtx, canonical, "register race name", func(tx *sql.Tx) error {
|
|
existing, err := loadByCanonicalTx(operationCtx, tx, canonical)
|
|
if err != nil {
|
|
return fmt.Errorf("register race name: %w", err)
|
|
}
|
|
// Already registered: idempotent for the same holder, ErrNameTaken
|
|
// for any other user.
|
|
for _, r := range existing {
|
|
if r.bindingKind == bindingRegistered {
|
|
if r.holderUserID == user {
|
|
return nil
|
|
}
|
|
return ports.ErrNameTaken
|
|
}
|
|
}
|
|
var pending *raceNameRow
|
|
for index, r := range existing {
|
|
if r.gameID == game.String() && r.holderUserID == user && r.bindingKind == bindingPending {
|
|
pending = &existing[index]
|
|
break
|
|
}
|
|
}
|
|
if pending == nil {
|
|
return ports.ErrPendingMissing
|
|
}
|
|
if pending.eligibleUntilMs == nil || *pending.eligibleUntilMs <= nowMs {
|
|
return ports.ErrPendingExpired
|
|
}
|
|
|
|
del := pgtable.RaceNames.DELETE().WHERE(pg.AND(
|
|
pgtable.RaceNames.CanonicalKey.EQ(pg.String(canonical.String())),
|
|
pgtable.RaceNames.GameID.EQ(pg.String(game.String())),
|
|
))
|
|
delQuery, delArgs := del.Sql()
|
|
if _, err := tx.ExecContext(operationCtx, delQuery, delArgs...); err != nil {
|
|
return fmt.Errorf("register race name: %w", err)
|
|
}
|
|
ins := pgtable.RaceNames.INSERT(
|
|
pgtable.RaceNames.CanonicalKey,
|
|
pgtable.RaceNames.GameID,
|
|
pgtable.RaceNames.HolderUserID,
|
|
pgtable.RaceNames.RaceName,
|
|
pgtable.RaceNames.BindingKind,
|
|
pgtable.RaceNames.SourceGameID,
|
|
pgtable.RaceNames.ReservedAtMs,
|
|
pgtable.RaceNames.RegisteredAtMs,
|
|
).VALUES(
|
|
canonical.String(),
|
|
registeredGameID,
|
|
user,
|
|
pending.raceName,
|
|
bindingRegistered,
|
|
game.String(),
|
|
pending.reservedAtMs,
|
|
nowMs,
|
|
)
|
|
insQuery, insArgs := ins.Sql()
|
|
if _, err := tx.ExecContext(operationCtx, insQuery, insArgs...); err != nil {
|
|
return fmt.Errorf("register race name: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// ListRegistered returns every registered race name owned by userID.
|
|
func (directory *Directory) ListRegistered(
|
|
ctx context.Context,
|
|
userID string,
|
|
) ([]ports.RegisteredName, error) {
|
|
if directory == nil {
|
|
return nil, errors.New("list registered race names: nil directory")
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "list registered race names", "user id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list registered race names", directory.operationTimeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cancel()
|
|
|
|
stmt := pg.SELECT(
|
|
pgtable.RaceNames.CanonicalKey,
|
|
pgtable.RaceNames.RaceName,
|
|
pgtable.RaceNames.SourceGameID,
|
|
pgtable.RaceNames.RegisteredAtMs,
|
|
).FROM(pgtable.RaceNames).WHERE(pg.AND(
|
|
pgtable.RaceNames.HolderUserID.EQ(pg.String(user)),
|
|
pgtable.RaceNames.BindingKind.EQ(pg.String(bindingRegistered)),
|
|
))
|
|
|
|
query, args := stmt.Sql()
|
|
rows, err := directory.db.QueryContext(operationCtx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list registered race names: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []ports.RegisteredName
|
|
for rows.Next() {
|
|
var (
|
|
canonical string
|
|
raceName string
|
|
sourceGameID string
|
|
regAt sql.NullInt64
|
|
)
|
|
if err := rows.Scan(&canonical, &raceName, &sourceGameID, ®At); err != nil {
|
|
return nil, fmt.Errorf("list registered race names: scan: %w", err)
|
|
}
|
|
var regAtMs int64
|
|
if regAt.Valid {
|
|
regAtMs = regAt.Int64
|
|
}
|
|
out = append(out, ports.RegisteredName{
|
|
CanonicalKey: canonical,
|
|
RaceName: raceName,
|
|
SourceGameID: sourceGameID,
|
|
RegisteredAtMs: regAtMs,
|
|
})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list registered race names: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListReservations returns every active reservation owned by userID
|
|
// whose status has not yet been promoted to pending_registration.
|
|
func (directory *Directory) ListReservations(
|
|
ctx context.Context,
|
|
userID string,
|
|
) ([]ports.Reservation, error) {
|
|
if directory == nil {
|
|
return nil, errors.New("list race name reservations: nil directory")
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "list race name reservations", "user id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list race name reservations", directory.operationTimeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cancel()
|
|
|
|
stmt := pg.SELECT(
|
|
pgtable.RaceNames.CanonicalKey,
|
|
pgtable.RaceNames.RaceName,
|
|
pgtable.RaceNames.GameID,
|
|
pgtable.RaceNames.ReservedAtMs,
|
|
).FROM(pgtable.RaceNames).WHERE(pg.AND(
|
|
pgtable.RaceNames.HolderUserID.EQ(pg.String(user)),
|
|
pgtable.RaceNames.BindingKind.EQ(pg.String(bindingReservation)),
|
|
))
|
|
|
|
query, args := stmt.Sql()
|
|
rows, err := directory.db.QueryContext(operationCtx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list race name reservations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []ports.Reservation
|
|
for rows.Next() {
|
|
var (
|
|
canonical string
|
|
raceName string
|
|
gameID string
|
|
reservedAtMs int64
|
|
)
|
|
if err := rows.Scan(&canonical, &raceName, &gameID, &reservedAtMs); err != nil {
|
|
return nil, fmt.Errorf("list race name reservations: scan: %w", err)
|
|
}
|
|
out = append(out, ports.Reservation{
|
|
CanonicalKey: canonical,
|
|
RaceName: raceName,
|
|
GameID: gameID,
|
|
ReservedAtMs: reservedAtMs,
|
|
})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list race name reservations: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListPendingRegistrations returns every pending registration owned by
|
|
// userID.
|
|
func (directory *Directory) ListPendingRegistrations(
|
|
ctx context.Context,
|
|
userID string,
|
|
) ([]ports.PendingRegistration, error) {
|
|
if directory == nil {
|
|
return nil, errors.New("list pending race name registrations: nil directory")
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "list pending race name registrations", "user id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list pending race name registrations", directory.operationTimeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cancel()
|
|
|
|
stmt := pg.SELECT(
|
|
pgtable.RaceNames.CanonicalKey,
|
|
pgtable.RaceNames.RaceName,
|
|
pgtable.RaceNames.GameID,
|
|
pgtable.RaceNames.ReservedAtMs,
|
|
pgtable.RaceNames.EligibleUntilMs,
|
|
).FROM(pgtable.RaceNames).WHERE(pg.AND(
|
|
pgtable.RaceNames.HolderUserID.EQ(pg.String(user)),
|
|
pgtable.RaceNames.BindingKind.EQ(pg.String(bindingPending)),
|
|
))
|
|
|
|
query, args := stmt.Sql()
|
|
rows, err := directory.db.QueryContext(operationCtx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list pending race name registrations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []ports.PendingRegistration
|
|
for rows.Next() {
|
|
var (
|
|
canonical string
|
|
raceName string
|
|
gameID string
|
|
reservedAtMs int64
|
|
eligibleAt sql.NullInt64
|
|
)
|
|
if err := rows.Scan(&canonical, &raceName, &gameID, &reservedAtMs, &eligibleAt); err != nil {
|
|
return nil, fmt.Errorf("list pending race name registrations: scan: %w", err)
|
|
}
|
|
var eligibleAtMs int64
|
|
if eligibleAt.Valid {
|
|
eligibleAtMs = eligibleAt.Int64
|
|
}
|
|
out = append(out, ports.PendingRegistration{
|
|
CanonicalKey: canonical,
|
|
RaceName: raceName,
|
|
GameID: gameID,
|
|
ReservedAtMs: reservedAtMs,
|
|
EligibleUntilMs: eligibleAtMs,
|
|
})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("list pending race name registrations: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ReleaseAllByUser atomically clears every binding owned by userID. The
|
|
// user-lifecycle consumer invokes the method on permanent_blocked and
|
|
// deleted events, so concurrent writes by the same user cannot race
|
|
// (the user is permanently disabled by the time the cascade runs).
|
|
func (directory *Directory) ReleaseAllByUser(
|
|
ctx context.Context,
|
|
userID string,
|
|
) error {
|
|
if directory == nil {
|
|
return errors.New("release all race names by user: nil directory")
|
|
}
|
|
user, err := normalizeNonEmpty(userID, "release all race names by user", "user id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "release all race names by user", directory.operationTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
stmt := pgtable.RaceNames.DELETE().
|
|
WHERE(pgtable.RaceNames.HolderUserID.EQ(pg.String(user)))
|
|
query, args := stmt.Sql()
|
|
if _, err := directory.db.ExecContext(operationCtx, query, args...); err != nil {
|
|
return fmt.Errorf("release all race names by user: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// raceNameRow mirrors one race_names row in adapter-internal code so
|
|
// transactional methods can read the row state under an advisory lock and
|
|
// branch without re-deriving column ordering at every call site.
|
|
type raceNameRow struct {
|
|
canonicalKey string
|
|
gameID string
|
|
holderUserID string
|
|
raceName string
|
|
bindingKind string
|
|
sourceGameID string
|
|
reservedAtMs int64
|
|
eligibleUntilMs *int64
|
|
registeredAtMs *int64
|
|
}
|
|
|
|
// raceNameAllColumns is the column list scanRow expects in order.
|
|
var raceNameAllColumns = pg.ColumnList{
|
|
pgtable.RaceNames.CanonicalKey,
|
|
pgtable.RaceNames.GameID,
|
|
pgtable.RaceNames.HolderUserID,
|
|
pgtable.RaceNames.RaceName,
|
|
pgtable.RaceNames.BindingKind,
|
|
pgtable.RaceNames.SourceGameID,
|
|
pgtable.RaceNames.ReservedAtMs,
|
|
pgtable.RaceNames.EligibleUntilMs,
|
|
pgtable.RaceNames.RegisteredAtMs,
|
|
}
|
|
|
|
func scanRow(scanner interface{ Scan(...any) error }) (raceNameRow, error) {
|
|
var (
|
|
row raceNameRow
|
|
eligible sql.NullInt64
|
|
registered sql.NullInt64
|
|
)
|
|
if err := scanner.Scan(
|
|
&row.canonicalKey,
|
|
&row.gameID,
|
|
&row.holderUserID,
|
|
&row.raceName,
|
|
&row.bindingKind,
|
|
&row.sourceGameID,
|
|
&row.reservedAtMs,
|
|
&eligible,
|
|
®istered,
|
|
); err != nil {
|
|
return raceNameRow{}, err
|
|
}
|
|
if eligible.Valid {
|
|
v := eligible.Int64
|
|
row.eligibleUntilMs = &v
|
|
}
|
|
if registered.Valid {
|
|
v := registered.Int64
|
|
row.registeredAtMs = &v
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
// loadByCanonicalTx returns every race_names row for canonical_key.
|
|
func loadByCanonicalTx(
|
|
ctx context.Context,
|
|
tx *sql.Tx,
|
|
canonical racename.CanonicalKey,
|
|
) ([]raceNameRow, error) {
|
|
stmt := pg.SELECT(raceNameAllColumns).
|
|
FROM(pgtable.RaceNames).
|
|
WHERE(pgtable.RaceNames.CanonicalKey.EQ(pg.String(canonical.String())))
|
|
query, args := stmt.Sql()
|
|
rows, err := tx.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []raceNameRow
|
|
for rows.Next() {
|
|
row, err := scanRow(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// loadOneByPKTx loads one row by its (canonical_key, game_id) primary
|
|
// key. The returned bool is false when no row matches.
|
|
func loadOneByPKTx(
|
|
ctx context.Context,
|
|
tx *sql.Tx,
|
|
canonical racename.CanonicalKey,
|
|
gameID string,
|
|
) (raceNameRow, bool, error) {
|
|
stmt := pg.SELECT(raceNameAllColumns).
|
|
FROM(pgtable.RaceNames).
|
|
WHERE(pg.AND(
|
|
pgtable.RaceNames.CanonicalKey.EQ(pg.String(canonical.String())),
|
|
pgtable.RaceNames.GameID.EQ(pg.String(gameID)),
|
|
))
|
|
query, args := stmt.Sql()
|
|
row := tx.QueryRowContext(ctx, query, args...)
|
|
out, err := scanRow(row)
|
|
if sqlx.IsNoRows(err) {
|
|
return raceNameRow{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return raceNameRow{}, false, err
|
|
}
|
|
return out, true, nil
|
|
}
|
|
|
|
// withCanonicalLock runs op inside a transaction guarded by
|
|
// pg_advisory_xact_lock(hashtextextended(canonical_key, 0)). The lock is
|
|
// released when the transaction terminates (commit or rollback).
|
|
func (directory *Directory) withCanonicalLock(
|
|
ctx context.Context,
|
|
canonical racename.CanonicalKey,
|
|
operation string,
|
|
op func(tx *sql.Tx) error,
|
|
) error {
|
|
tx, err := directory.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: begin tx: %w", operation, err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
if _, err := tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", canonical.String()); err != nil {
|
|
return fmt.Errorf("%s: advisory lock: %w", operation, err)
|
|
}
|
|
if err := op(tx); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("%s: commit: %w", operation, err)
|
|
}
|
|
committed = true
|
|
return nil
|
|
}
|
|
|
|
// bindingPriority maps a binding_kind value to a priority rank where
|
|
// lower numbers mean a stronger binding (registered > pending > reservation).
|
|
func bindingPriority(kind string) int {
|
|
switch kind {
|
|
case bindingRegistered:
|
|
return 1
|
|
case bindingPending:
|
|
return 2
|
|
case bindingReservation:
|
|
return 3
|
|
default:
|
|
return 99
|
|
}
|
|
}
|
|
|
|
// normalizeNonEmpty trims value and rejects empty results with an error
|
|
// that mentions operation and field for traceability.
|
|
func normalizeNonEmpty(value, operation, field string) (string, error) {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return "", fmt.Errorf("%s: %s must not be empty", operation, field)
|
|
}
|
|
return trimmed, nil
|
|
}
|
|
|
|
// normalizeGameID trims value and converts it into a typed common.GameID,
|
|
// rejecting empty input through normalizeNonEmpty.
|
|
func normalizeGameID(value, operation string) (common.GameID, error) {
|
|
trimmed, err := normalizeNonEmpty(value, operation, "game id")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return common.GameID(trimmed), nil
|
|
}
|
|
|
|
// contextAlive surfaces ctx cancellation through a stable error path even
|
|
// when the calling method is a defensive no-op for invalid input. The
|
|
// shared port test suite expects every method to honour cancellation
|
|
// regardless of preceding validation.
|
|
func contextAlive(ctx context.Context) error {
|
|
if ctx == nil {
|
|
return errors.New("nil context")
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Ensure *Directory satisfies the ports.RaceNameDirectory interface at
|
|
// compile time.
|
|
var _ ports.RaceNameDirectory = (*Directory)(nil)
|