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 : // 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)