1102 lines
34 KiB
Go
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)
|