Files
galaxy-game/lobby/internal/adapters/redisstate/racenamedir.go
T
2026-04-25 23:20:55 +02:00

1102 lines
34 KiB
Go

package redisstate
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/racename"
"galaxy/lobby/internal/ports"
"github.com/redis/go-redis/v9"
)
// RaceNameDirectory is the Redis-backed implementation of
// ports.RaceNameDirectory. It persists the two-tier Race Name Directory
// state (registered, reservation, pending_registration) under the Redis
// key layout frozen in lobby/README.md §Redis Logical Model.
type RaceNameDirectory struct {
client *redis.Client
keys Keyspace
policy *racename.Policy
nowFn func() time.Time
releaseLua *redis.Script
}
// RaceNameDirectoryOption tunes the Redis Race Name Directory adapter
// during construction. Options are evaluated in order.
type RaceNameDirectoryOption func(*RaceNameDirectory)
// WithRaceNameDirectoryClock overrides the default time.Now clock used
// to stamp reserved_at_ms and registered_at_ms. It is intended for
// deterministic tests.
func WithRaceNameDirectoryClock(nowFn func() time.Time) RaceNameDirectoryOption {
return func(directory *RaceNameDirectory) {
if nowFn != nil {
directory.nowFn = nowFn
}
}
}
// NewRaceNameDirectory constructs the Redis-backed Race Name Directory
// adapter. It returns an error when client or policy is nil.
func NewRaceNameDirectory(
client *redis.Client,
policy *racename.Policy,
opts ...RaceNameDirectoryOption,
) (*RaceNameDirectory, error) {
if client == nil {
return nil, errors.New("new race name directory: nil redis client")
}
if policy == nil {
return nil, errors.New("new race name directory: nil racename policy")
}
directory := &RaceNameDirectory{
client: client,
keys: Keyspace{},
policy: policy,
nowFn: time.Now,
releaseLua: redis.NewScript(releaseAllByUserScript),
}
for _, opt := range opts {
opt(directory)
}
return directory, nil
}
// Canonicalize returns the canonical uniqueness key for raceName as a
// plain string. Callers map validation failures to the stable
// name_taken-adjacent error code via ports.ErrInvalidName.
func (directory *RaceNameDirectory) Canonicalize(raceName string) (string, error) {
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. Taken is
// false when no binding exists on the canonical key or when the
// existing binding is owned by actorUserID; the returned
// HolderUserID and Kind always mirror the underlying Redis state.
func (directory *RaceNameDirectory) Check(
ctx context.Context,
raceName, actorUserID string,
) (ports.Availability, error) {
if err := checkContext(ctx, "check race name"); err != nil {
return ports.Availability{}, err
}
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)
}
record, err := directory.loadCanonicalLookup(ctx, canonical)
switch {
case errors.Is(err, redis.Nil):
return ports.Availability{}, nil
case err != nil:
return ports.Availability{}, fmt.Errorf("check race name: %w", err)
}
return ports.Availability{
Taken: record.HolderUserID != actor,
HolderUserID: record.HolderUserID,
Kind: record.Kind,
}, nil
}
// Reserve claims raceName for (gameID, userID). A second call by the
// same holder for the same tuple is a no-op; any cross-user collision on
// the canonical key returns ports.ErrNameTaken.
func (directory *RaceNameDirectory) Reserve(
ctx context.Context,
gameID, userID, raceName string,
) error {
if err := checkContext(ctx, "reserve race name"); err != nil {
return err
}
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)
}
reservationKey := directory.keys.RaceNameReservation(game, canonical)
lookupKey := directory.keys.RaceNameCanonicalLookup(canonical)
userReservationsKey := directory.keys.UserRaceNameReservations(user)
reservationMember := directory.keys.RaceNameReservationMember(game, canonical)
reservedAtMS := directory.nowFn().UTC().UnixMilli()
watchErr := directory.client.Watch(ctx, func(tx *redis.Tx) error {
lookup, err := loadLookupTx(ctx, tx, lookupKey)
switch {
case errors.Is(err, redis.Nil):
lookup = canonicalLookupRecord{}
case err != nil:
return fmt.Errorf("reserve race name: %w", err)
}
if lookup.HolderUserID != "" && lookup.HolderUserID != user {
return ports.ErrNameTaken
}
existing, err := loadReservationTx(ctx, tx, reservationKey)
switch {
case errors.Is(err, redis.Nil):
existing = reservationRecord{}
case err != nil:
return fmt.Errorf("reserve race name: %w", err)
}
if existing.UserID != "" {
if existing.UserID != user {
return ports.ErrNameTaken
}
// idempotent same-holder Reserve
return nil
}
payload, err := marshalReservationRecord(reservationRecord{
UserID: user,
RaceName: displayName,
ReservedAtMS: reservedAtMS,
Status: reservationStatusReserved,
})
if err != nil {
return fmt.Errorf("reserve race name: %w", err)
}
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, reservationKey, payload, 0)
pipe.SAdd(ctx, userReservationsKey, reservationMember)
if lookup.HolderUserID == "" {
lookupPayload, err := marshalCanonicalLookupRecord(canonicalLookupRecord{
Kind: ports.KindReservation,
HolderUserID: user,
GameID: game.String(),
})
if err != nil {
return err
}
pipe.Set(ctx, lookupKey, lookupPayload, 0)
}
return nil
})
return err
}, reservationKey, lookupKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("reserve race name: %w", ports.ErrNameTaken)
case watchErr != nil:
return watchErr
default:
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 *RaceNameDirectory) ReleaseReservation(
ctx context.Context,
gameID, userID, raceName string,
) error {
if err := checkContext(ctx, "release race name reservation"); err != nil {
return err
}
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 {
return nil
}
reservationKey := directory.keys.RaceNameReservation(game, canonical)
lookupKey := directory.keys.RaceNameCanonicalLookup(canonical)
userReservationsKey := directory.keys.UserRaceNameReservations(user)
userRegisteredKey := directory.keys.UserRegisteredRaceNames(user)
reservationMember := directory.keys.RaceNameReservationMember(game, canonical)
watchErr := directory.client.Watch(ctx, func(tx *redis.Tx) error {
existing, err := loadReservationTx(ctx, tx, reservationKey)
switch {
case errors.Is(err, redis.Nil):
return nil
case err != nil:
return fmt.Errorf("release race name reservation: %w", err)
}
if existing.UserID != user {
return nil
}
remainingMember, remainingGame, remainingStatus, err := directory.findOtherReservationMember(
ctx, tx, userReservationsKey, canonical, reservationMember,
)
if err != nil {
return fmt.Errorf("release race name reservation: %w", err)
}
registeredPresent, err := registeredHeldBy(ctx, tx, directory.keys.RegisteredRaceName(canonical), user)
if err != nil {
return fmt.Errorf("release race name reservation: %w", err)
}
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Del(ctx, reservationKey)
pipe.SRem(ctx, userReservationsKey, reservationMember)
if existing.Status == reservationStatusPending {
pipe.ZRem(ctx, directory.keys.PendingRaceNameIndex(), reservationMember)
}
switch {
case registeredPresent:
lookupPayload, err := marshalCanonicalLookupRecord(canonicalLookupRecord{
Kind: ports.KindRegistered,
HolderUserID: user,
})
if err != nil {
return err
}
pipe.Set(ctx, lookupKey, lookupPayload, 0)
case remainingMember != "":
kind := ports.KindReservation
if remainingStatus == reservationStatusPending {
kind = ports.KindPendingRegistration
}
lookupPayload, err := marshalCanonicalLookupRecord(canonicalLookupRecord{
Kind: kind,
HolderUserID: user,
GameID: remainingGame.String(),
})
if err != nil {
return err
}
pipe.Set(ctx, lookupKey, lookupPayload, 0)
default:
pipe.Del(ctx, lookupKey)
}
return nil
})
return err
}, reservationKey, userReservationsKey, lookupKey, userRegisteredKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
// Concurrent mutation touched the reservation — reread the state
// on a retry to preserve the defensive no-op contract.
return directory.ReleaseReservation(ctx, gameID, userID, raceName)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// MarkPendingRegistration promotes the reservation for (gameID, userID)
// on raceName's canonical key to pending_registration status with the
// supplied eligibleUntil. A second call with the same eligibleUntil is
// a no-op; a call with a different eligibleUntil returns
// ports.ErrInvalidName.
func (directory *RaceNameDirectory) MarkPendingRegistration(
ctx context.Context,
gameID, userID, raceName string,
eligibleUntil time.Time,
) error {
if err := checkContext(ctx, "mark pending race name registration"); err != nil {
return err
}
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)
}
reservationKey := directory.keys.RaceNameReservation(game, canonical)
lookupKey := directory.keys.RaceNameCanonicalLookup(canonical)
pendingIndexKey := directory.keys.PendingRaceNameIndex()
reservationMember := directory.keys.RaceNameReservationMember(game, canonical)
eligibleUntilMS := eligibleUntil.UTC().UnixMilli()
watchErr := directory.client.Watch(ctx, func(tx *redis.Tx) error {
existing, err := loadReservationTx(ctx, tx, reservationKey)
switch {
case errors.Is(err, redis.Nil):
return fmt.Errorf("mark pending race name registration: reservation missing for game %q user %q", game, user)
case err != nil:
return fmt.Errorf("mark pending race name registration: %w", err)
}
if existing.UserID != user {
return fmt.Errorf("mark pending race name registration: reservation held by different user")
}
if existing.Status == reservationStatusPending {
if existing.EligibleUntilMS == nil || *existing.EligibleUntilMS != eligibleUntilMS {
return fmt.Errorf("mark pending race name registration: %w", ports.ErrInvalidName)
}
// idempotent: same eligible_until already stored.
return nil
}
lookup, err := loadLookupTx(ctx, tx, lookupKey)
switch {
case errors.Is(err, redis.Nil):
lookup = canonicalLookupRecord{}
case err != nil:
return fmt.Errorf("mark pending race name registration: %w", err)
}
existing.Status = reservationStatusPending
existing.RaceName = displayName
eligibleUntilCopy := eligibleUntilMS
existing.EligibleUntilMS = &eligibleUntilCopy
payload, err := marshalReservationRecord(existing)
if err != nil {
return fmt.Errorf("mark pending race name registration: %w", err)
}
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, reservationKey, payload, 0)
pipe.ZAdd(ctx, pendingIndexKey, redis.Z{
Score: float64(eligibleUntilMS),
Member: reservationMember,
})
if lookup.Kind != ports.KindRegistered {
lookupPayload, err := marshalCanonicalLookupRecord(canonicalLookupRecord{
Kind: ports.KindPendingRegistration,
HolderUserID: user,
GameID: game.String(),
})
if err != nil {
return err
}
pipe.Set(ctx, lookupKey, lookupPayload, 0)
}
return nil
})
return err
}, reservationKey, lookupKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("mark pending race name registration: %w", ports.ErrInvalidName)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// ExpirePendingRegistrations releases every pending registration whose
// eligibleUntil is at or before now. Expired entries are returned so
// callers can emit telemetry. Running twice is safe.
func (directory *RaceNameDirectory) ExpirePendingRegistrations(
ctx context.Context,
now time.Time,
) ([]ports.ExpiredPending, error) {
if err := checkContext(ctx, "expire pending race name registrations"); err != nil {
return nil, err
}
cutoff := now.UTC().UnixMilli()
members, err := directory.client.ZRangeArgs(ctx, redis.ZRangeArgs{
Key: directory.keys.PendingRaceNameIndex(),
ByScore: true,
Start: "-inf",
Stop: fmt.Sprintf("%d", cutoff),
}).Result()
if err != nil {
return nil, fmt.Errorf("expire pending race name registrations: %w", err)
}
if len(members) == 0 {
return nil, nil
}
expired := make([]ports.ExpiredPending, 0, len(members))
for _, member := range members {
game, canonical, err := splitReservationMember(member)
if err != nil {
return nil, fmt.Errorf("expire pending race name registrations: %w", err)
}
entry, released, err := directory.expireOnePending(ctx, game, canonical, member, cutoff)
if err != nil {
return nil, fmt.Errorf("expire pending race name registrations: %w", err)
}
if released {
expired = append(expired, entry)
}
}
return expired, nil
}
// Register converts the pending registration identified by (gameID,
// userID) on raceName's canonical key into a permanent registered name.
// Missing pending returns ports.ErrPendingMissing; expired pending
// returns ports.ErrPendingExpired; a repeated success is a no-op.
func (directory *RaceNameDirectory) Register(
ctx context.Context,
gameID, userID, raceName string,
) error {
if err := checkContext(ctx, "register race name"); err != nil {
return err
}
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)
}
registeredKey := directory.keys.RegisteredRaceName(canonical)
reservationKey := directory.keys.RaceNameReservation(game, canonical)
lookupKey := directory.keys.RaceNameCanonicalLookup(canonical)
userRegisteredKey := directory.keys.UserRegisteredRaceNames(user)
userReservationsKey := directory.keys.UserRaceNameReservations(user)
pendingIndexKey := directory.keys.PendingRaceNameIndex()
reservationMember := directory.keys.RaceNameReservationMember(game, canonical)
nowMS := directory.nowFn().UTC().UnixMilli()
watchErr := directory.client.Watch(ctx, func(tx *redis.Tx) error {
registered, err := loadRegisteredTx(ctx, tx, registeredKey)
switch {
case errors.Is(err, redis.Nil):
registered = registeredRecord{}
case err != nil:
return fmt.Errorf("register race name: %w", err)
}
if registered.UserID != "" {
if registered.UserID == user {
// idempotent repeat
return nil
}
return ports.ErrNameTaken
}
pending, err := loadReservationTx(ctx, tx, reservationKey)
switch {
case errors.Is(err, redis.Nil):
return ports.ErrPendingMissing
case err != nil:
return fmt.Errorf("register race name: %w", err)
}
if pending.UserID != user || pending.Status != reservationStatusPending {
return ports.ErrPendingMissing
}
if pending.EligibleUntilMS == nil || *pending.EligibleUntilMS <= nowMS {
return ports.ErrPendingExpired
}
payload, err := marshalRegisteredRecord(registeredRecord{
UserID: user,
RaceName: displayName,
SourceGameID: game.String(),
RegisteredAtMS: nowMS,
})
if err != nil {
return fmt.Errorf("register race name: %w", err)
}
lookupPayload, err := marshalCanonicalLookupRecord(canonicalLookupRecord{
Kind: ports.KindRegistered,
HolderUserID: user,
})
if err != nil {
return fmt.Errorf("register race name: %w", err)
}
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, registeredKey, payload, 0)
pipe.SAdd(ctx, userRegisteredKey, encodeKeyComponent(canonical.String()))
pipe.Del(ctx, reservationKey)
pipe.SRem(ctx, userReservationsKey, reservationMember)
pipe.ZRem(ctx, pendingIndexKey, reservationMember)
pipe.Set(ctx, lookupKey, lookupPayload, 0)
return nil
})
return err
}, registeredKey, reservationKey, lookupKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("register race name: %w", ports.ErrPendingMissing)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// ListRegistered returns every registered race name owned by userID.
func (directory *RaceNameDirectory) ListRegistered(
ctx context.Context,
userID string,
) ([]ports.RegisteredName, error) {
if err := checkContext(ctx, "list registered race names"); err != nil {
return nil, err
}
user, err := normalizeNonEmpty(userID, "list registered race names", "user id")
if err != nil {
return nil, err
}
members, err := directory.client.SMembers(ctx, directory.keys.UserRegisteredRaceNames(user)).Result()
if err != nil {
return nil, fmt.Errorf("list registered race names: %w", err)
}
if len(members) == 0 {
return nil, nil
}
keys := make([]string, len(members))
canonicals := make([]racename.CanonicalKey, len(members))
for index, encoded := range members {
decodedBytes, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("list registered race names: decode canonical %q: %w", encoded, err)
}
canonical := racename.CanonicalKey(string(decodedBytes))
canonicals[index] = canonical
keys[index] = directory.keys.RegisteredRaceName(canonical)
}
payloads, err := directory.client.MGet(ctx, keys...).Result()
if err != nil {
return nil, fmt.Errorf("list registered race names: %w", err)
}
results := make([]ports.RegisteredName, 0, len(payloads))
for index, entry := range payloads {
if entry == nil {
continue
}
raw, ok := entry.(string)
if !ok {
return nil, fmt.Errorf("list registered race names: unexpected payload type %T", entry)
}
record, err := unmarshalRegisteredRecord([]byte(raw))
if err != nil {
return nil, fmt.Errorf("list registered race names: %w", err)
}
results = append(results, ports.RegisteredName{
CanonicalKey: canonicals[index].String(),
RaceName: record.RaceName,
SourceGameID: record.SourceGameID,
RegisteredAtMs: record.RegisteredAtMS,
})
}
return results, nil
}
// ListPendingRegistrations returns every pending registration owned by
// userID.
func (directory *RaceNameDirectory) ListPendingRegistrations(
ctx context.Context,
userID string,
) ([]ports.PendingRegistration, error) {
if err := checkContext(ctx, "list pending race name registrations"); err != nil {
return nil, err
}
user, err := normalizeNonEmpty(userID, "list pending race name registrations", "user id")
if err != nil {
return nil, err
}
entries, err := directory.loadUserReservations(ctx, user, "list pending race name registrations")
if err != nil {
return nil, err
}
pending := make([]ports.PendingRegistration, 0, len(entries))
for _, entry := range entries {
if entry.record.Status != reservationStatusPending {
continue
}
eligibleUntilMS := int64(0)
if entry.record.EligibleUntilMS != nil {
eligibleUntilMS = *entry.record.EligibleUntilMS
}
pending = append(pending, ports.PendingRegistration{
CanonicalKey: entry.canonical.String(),
RaceName: entry.record.RaceName,
GameID: entry.game.String(),
ReservedAtMs: entry.record.ReservedAtMS,
EligibleUntilMs: eligibleUntilMS,
})
}
return pending, nil
}
// ListReservations returns every active reservation owned by userID
// whose status has not yet been promoted to pending_registration.
func (directory *RaceNameDirectory) ListReservations(
ctx context.Context,
userID string,
) ([]ports.Reservation, error) {
if err := checkContext(ctx, "list race name reservations"); err != nil {
return nil, err
}
user, err := normalizeNonEmpty(userID, "list race name reservations", "user id")
if err != nil {
return nil, err
}
entries, err := directory.loadUserReservations(ctx, user, "list race name reservations")
if err != nil {
return nil, err
}
reservations := make([]ports.Reservation, 0, len(entries))
for _, entry := range entries {
if entry.record.Status != reservationStatusReserved {
continue
}
reservations = append(reservations, ports.Reservation{
CanonicalKey: entry.canonical.String(),
RaceName: entry.record.RaceName,
GameID: entry.game.String(),
ReservedAtMs: entry.record.ReservedAtMS,
})
}
return reservations, nil
}
// ReleaseAllByUser clears every registered, reservation, and
// pending_registration binding owned by userID via a single Lua script
// invocation, so the cascade is atomic relative to concurrent readers.
func (directory *RaceNameDirectory) ReleaseAllByUser(
ctx context.Context,
userID string,
) error {
if err := checkContext(ctx, "release all race names by user"); err != nil {
return err
}
user, err := normalizeNonEmpty(userID, "release all race names by user", "user id")
if err != nil {
return err
}
_, err = directory.releaseLua.Run(
ctx,
directory.client,
[]string{
directory.keys.UserRegisteredRaceNames(user),
directory.keys.UserRaceNameReservations(user),
directory.keys.PendingRaceNameIndex(),
},
defaultPrefix,
).Result()
if err != nil && !errors.Is(err, redis.Nil) {
return fmt.Errorf("release all race names by user: %w", err)
}
return nil
}
// expireOneMaxRetries caps retry attempts when Watch optimistic
// concurrency fails during pending expiration, so transient contention
// cannot livelock the worker.
const expireOneMaxRetries = 8
// expireOnePending atomically releases one pending entry by
// reservationMember at-or-before cutoff, returning the entry for
// telemetry when the release commits.
func (directory *RaceNameDirectory) expireOnePending(
ctx context.Context,
game common.GameID,
canonical racename.CanonicalKey,
reservationMember string,
cutoff int64,
) (ports.ExpiredPending, bool, error) {
reservationKey := directory.keys.RaceNameReservation(game, canonical)
lookupKey := directory.keys.RaceNameCanonicalLookup(canonical)
pendingIndexKey := directory.keys.PendingRaceNameIndex()
for range expireOneMaxRetries {
var (
resultEntry ports.ExpiredPending
resultReleased bool
)
watchErr := directory.client.Watch(ctx, func(tx *redis.Tx) error {
existing, err := loadReservationTx(ctx, tx, reservationKey)
switch {
case errors.Is(err, redis.Nil):
// Lost the race to another release path; drop the index
// member defensively and continue.
_, pipeErr := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.ZRem(ctx, pendingIndexKey, reservationMember)
return nil
})
return pipeErr
case err != nil:
return err
}
if existing.Status != reservationStatusPending || existing.EligibleUntilMS == nil {
_, pipeErr := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.ZRem(ctx, pendingIndexKey, reservationMember)
return nil
})
return pipeErr
}
if *existing.EligibleUntilMS > cutoff {
// Extended between ZRANGEBYSCORE and now; skip.
return nil
}
userReservationsKey := directory.keys.UserRaceNameReservations(existing.UserID)
registeredPresent, err := registeredHeldBy(ctx, tx, directory.keys.RegisteredRaceName(canonical), existing.UserID)
if err != nil {
return err
}
remainingMember, remainingGame, remainingStatus, err := directory.findOtherReservationMember(
ctx, tx, userReservationsKey, canonical, reservationMember,
)
if err != nil {
return err
}
_, pipeErr := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Del(ctx, reservationKey)
pipe.SRem(ctx, userReservationsKey, reservationMember)
pipe.ZRem(ctx, pendingIndexKey, reservationMember)
switch {
case registeredPresent:
lookupPayload, err := marshalCanonicalLookupRecord(canonicalLookupRecord{
Kind: ports.KindRegistered,
HolderUserID: existing.UserID,
})
if err != nil {
return err
}
pipe.Set(ctx, lookupKey, lookupPayload, 0)
case remainingMember != "":
kind := ports.KindReservation
if remainingStatus == reservationStatusPending {
kind = ports.KindPendingRegistration
}
lookupPayload, err := marshalCanonicalLookupRecord(canonicalLookupRecord{
Kind: kind,
HolderUserID: existing.UserID,
GameID: remainingGame.String(),
})
if err != nil {
return err
}
pipe.Set(ctx, lookupKey, lookupPayload, 0)
default:
pipe.Del(ctx, lookupKey)
}
return nil
})
if pipeErr != nil {
return pipeErr
}
resultEntry = ports.ExpiredPending{
CanonicalKey: canonical.String(),
RaceName: existing.RaceName,
GameID: game.String(),
UserID: existing.UserID,
EligibleUntilMs: *existing.EligibleUntilMS,
}
resultReleased = true
return nil
}, reservationKey, lookupKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
continue
case watchErr != nil:
return ports.ExpiredPending{}, false, watchErr
default:
return resultEntry, resultReleased, nil
}
}
return ports.ExpiredPending{}, false, fmt.Errorf("expire pending: Watch contention exceeded %d retries", expireOneMaxRetries)
}
// reservationEntry bundles a decoded reservation record with its key
// components for list-style methods.
type reservationEntry struct {
game common.GameID
canonical racename.CanonicalKey
record reservationRecord
}
// loadUserReservations resolves every reservation (including pending)
// owned by userID by expanding UserRaceNameReservations members.
func (directory *RaceNameDirectory) loadUserReservations(
ctx context.Context,
userID, operation string,
) ([]reservationEntry, error) {
members, err := directory.client.SMembers(ctx, directory.keys.UserRaceNameReservations(userID)).Result()
if err != nil {
return nil, fmt.Errorf("%s: %w", operation, err)
}
if len(members) == 0 {
return nil, nil
}
keys := make([]string, 0, len(members))
decodedMembers := make([]struct {
game common.GameID
canonical racename.CanonicalKey
}, 0, len(members))
for _, member := range members {
game, canonical, err := splitReservationMember(member)
if err != nil {
return nil, fmt.Errorf("%s: %w", operation, err)
}
keys = append(keys, directory.keys.RaceNameReservation(game, canonical))
decodedMembers = append(decodedMembers, struct {
game common.GameID
canonical racename.CanonicalKey
}{game, canonical})
}
payloads, err := directory.client.MGet(ctx, keys...).Result()
if err != nil {
return nil, fmt.Errorf("%s: %w", operation, err)
}
entries := make([]reservationEntry, 0, len(payloads))
for index, entry := range payloads {
if entry == nil {
continue
}
raw, ok := entry.(string)
if !ok {
return nil, fmt.Errorf("%s: unexpected payload type %T", operation, entry)
}
record, err := unmarshalReservationRecord([]byte(raw))
if err != nil {
return nil, fmt.Errorf("%s: %w", operation, err)
}
entries = append(entries, reservationEntry{
game: decodedMembers[index].game,
canonical: decodedMembers[index].canonical,
record: record,
})
}
return entries, nil
}
// loadCanonicalLookup loads the canonical-lookup cache entry for
// canonical.
func (directory *RaceNameDirectory) loadCanonicalLookup(
ctx context.Context,
canonical racename.CanonicalKey,
) (canonicalLookupRecord, error) {
payload, err := directory.client.Get(ctx, directory.keys.RaceNameCanonicalLookup(canonical)).Bytes()
if err != nil {
return canonicalLookupRecord{}, err
}
return unmarshalCanonicalLookupRecord(payload)
}
// findOtherReservationMember scans user_reservations for any member
// other than skip whose canonical suffix matches. It returns the raw
// member, decoded game id, and the reservation's current status when a
// match is found.
func (directory *RaceNameDirectory) findOtherReservationMember(
ctx context.Context,
tx *redis.Tx,
userReservationsKey string,
canonical racename.CanonicalKey,
skip string,
) (string, common.GameID, string, error) {
members, err := tx.SMembers(ctx, userReservationsKey).Result()
if err != nil {
return "", "", "", err
}
canonicalEncoded := encodeKeyComponent(canonical.String())
for _, member := range members {
if member == skip {
continue
}
sepIndex := strings.Index(member, ":")
if sepIndex <= 0 {
continue
}
if member[sepIndex+1:] != canonicalEncoded {
continue
}
game, parsedCanonical, err := splitReservationMember(member)
if err != nil {
return "", "", "", err
}
record, err := loadReservationTx(ctx, tx, directory.keys.RaceNameReservation(game, parsedCanonical))
switch {
case errors.Is(err, redis.Nil):
continue
case err != nil:
return "", "", "", err
}
return member, game, record.Status, nil
}
return "", "", "", nil
}
// loadReservationTx reads the reservation blob for reservationKey within
// a Redis transaction. redis.Nil is propagated so callers can branch.
func loadReservationTx(ctx context.Context, tx *redis.Tx, reservationKey string) (reservationRecord, error) {
payload, err := tx.Get(ctx, reservationKey).Bytes()
if err != nil {
return reservationRecord{}, err
}
return unmarshalReservationRecord(payload)
}
// loadLookupTx reads the canonical-lookup cache entry within a
// transaction.
func loadLookupTx(ctx context.Context, tx *redis.Tx, lookupKey string) (canonicalLookupRecord, error) {
payload, err := tx.Get(ctx, lookupKey).Bytes()
if err != nil {
return canonicalLookupRecord{}, err
}
return unmarshalCanonicalLookupRecord(payload)
}
// loadRegisteredTx reads the registered blob for registeredKey within a
// transaction.
func loadRegisteredTx(ctx context.Context, tx *redis.Tx, registeredKey string) (registeredRecord, error) {
payload, err := tx.Get(ctx, registeredKey).Bytes()
if err != nil {
return registeredRecord{}, err
}
return unmarshalRegisteredRecord(payload)
}
// registeredHeldBy reports whether registeredKey stores a registered
// race name owned by user within a transaction.
func registeredHeldBy(ctx context.Context, tx *redis.Tx, registeredKey, user string) (bool, error) {
record, err := loadRegisteredTx(ctx, tx, registeredKey)
switch {
case errors.Is(err, redis.Nil):
return false, nil
case err != nil:
return false, err
}
return record.UserID == user, nil
}
// splitReservationMember decodes a <encodedGameID>:<encodedCanonical>
// member back into its typed components.
func splitReservationMember(member string) (common.GameID, racename.CanonicalKey, error) {
sepIndex := strings.Index(member, ":")
if sepIndex <= 0 || sepIndex >= len(member)-1 {
return "", "", fmt.Errorf("invalid reservation member %q", member)
}
gameBytes, err := base64.RawURLEncoding.DecodeString(member[:sepIndex])
if err != nil {
return "", "", fmt.Errorf("decode game component of %q: %w", member, err)
}
canonicalBytes, err := base64.RawURLEncoding.DecodeString(member[sepIndex+1:])
if err != nil {
return "", "", fmt.Errorf("decode canonical component of %q: %w", member, err)
}
return common.GameID(string(gameBytes)), racename.CanonicalKey(string(canonicalBytes)), nil
}
// checkContext rejects nil or already-canceled contexts up front, so
// adapter methods always surface cancellation consistently regardless of
// whether a Redis round-trip was attempted.
func checkContext(ctx context.Context, operation string) error {
if ctx == nil {
return fmt.Errorf("%s: nil context", operation)
}
if err := ctx.Err(); err != nil {
return fmt.Errorf("%s: %w", operation, err)
}
return nil
}
// normalizeNonEmpty trims value and rejects empty results with a
// descriptive error including operation and field names.
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 and converts a user-supplied game id into a
// typed common.GameID, rejecting empty input.
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
}
// Ensure RaceNameDirectory satisfies the ports.RaceNameDirectory
// interface at compile time.
var _ ports.RaceNameDirectory = (*RaceNameDirectory)(nil)