Files
galaxy-game/lobby/internal/adapters/postgres/racenamedir/directory.go
T
2026-04-26 20:34:39 +02:00

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, &regAt); 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,
&registered,
); 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)