feat: runtime manager

This commit is contained in:
Ilia Denisov
2026-04-28 20:39:18 +02:00
committed by GitHub
parent e0a99b346b
commit a7cee15115
289 changed files with 45660 additions and 2207 deletions
@@ -0,0 +1,601 @@
// Package racenameinmem provides the in-process implementation of the
// ports.RaceNameDirectory contract. It is used both by unit tests that
// do not need a Redis dependency and by deployments that select the
// in-memory backend via LOBBY_RACE_NAME_DIRECTORY_BACKEND=stub. It
// enforces the full two-tier Race Name Directory invariants
// (registered, reservation, pending_registration) across the lifetime
// of one process, and is interchangeable with the PostgreSQL adapter
// under the shared behavioural test suite at
// galaxy/lobby/internal/ports/racenamedirtest.
package racenameinmem
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"galaxy/lobby/internal/domain/racename"
"galaxy/lobby/internal/ports"
)
// Directory is the in-memory implementation of ports.RaceNameDirectory.
// The zero value is not usable; callers must construct instances with
// NewDirectory so the underlying data structures and policy are ready.
type Directory struct {
mu sync.Mutex
policy *racename.Policy
nowFn func() time.Time
registered map[racename.CanonicalKey]*registeredEntry
entries map[racename.CanonicalKey]*canonicalEntry
}
// Option tunes Directory construction. Options are evaluated in order.
type Option func(*Directory)
// WithClock overrides the default time.Now clock used to stamp
// reserved_at_ms and registered_at_ms. It is intended for deterministic
// tests.
func WithClock(nowFn func() time.Time) Option {
return func(directory *Directory) {
if nowFn != nil {
directory.nowFn = nowFn
}
}
}
// NewDirectory constructs an empty in-memory Race Name Directory backed
// by its own freshly allocated racename.Policy. Returned instances are
// safe for concurrent use.
func NewDirectory(opts ...Option) (*Directory, error) {
policy, err := racename.NewPolicy()
if err != nil {
return nil, fmt.Errorf("new racename stub directory: %w", err)
}
directory := &Directory{
policy: policy,
nowFn: time.Now,
registered: make(map[racename.CanonicalKey]*registeredEntry),
entries: make(map[racename.CanonicalKey]*canonicalEntry),
}
for _, opt := range opts {
opt(directory)
}
return directory, nil
}
// registeredEntry models one registered name owned by exactly one user.
type registeredEntry struct {
userID string
raceName string
sourceGameID string
registeredAtMs int64
}
// canonicalEntry groups the per-game reservations (including
// pending_registration ones) owned by the sole user bound to one
// canonical key.
type canonicalEntry struct {
holderUserID string
reservations map[string]*reservationEntry
}
// reservationEntry models one per-game reservation.
type reservationEntry struct {
raceName string
reservedAtMs int64
status string
eligibleUntilMs int64
hasEligibleUntil bool
}
const (
statusReserved = "reserved"
statusPending = "pending_registration"
)
// Canonicalize delegates to the racename policy and returns the
// canonical key as a plain string. Validation failures surface
// ports.ErrInvalidName for compatibility with the Redis adapter.
func (directory *Directory) Canonicalize(raceName string) (string, error) {
if directory == nil {
return "", errors.New("canonicalize race name: nil directory")
}
canonical, err := directory.policy.Canonicalize(raceName)
if err != nil {
return "", fmt.Errorf("canonicalize race name: %w", ports.ErrInvalidName)
}
return canonical.String(), nil
}
// Check reports whether raceName is taken for actorUserID.
func (directory *Directory) Check(
ctx context.Context,
raceName, actorUserID string,
) (ports.Availability, error) {
if directory == nil {
return ports.Availability{}, errors.New("check race name: nil directory")
}
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)
}
directory.mu.Lock()
defer directory.mu.Unlock()
if registered, ok := directory.registered[canonical]; ok {
return ports.Availability{
Taken: registered.userID != actor,
HolderUserID: registered.userID,
Kind: ports.KindRegistered,
}, nil
}
entry, ok := directory.entries[canonical]
if !ok {
return ports.Availability{}, nil
}
kind := kindFromReservations(entry.reservations)
return ports.Availability{
Taken: entry.holderUserID != actor,
HolderUserID: entry.holderUserID,
Kind: kind,
}, nil
}
// Reserve claims raceName for (gameID, userID) per the port contract.
func (directory *Directory) Reserve(
ctx context.Context,
gameID, userID, raceName string,
) error {
if directory == nil {
return errors.New("reserve race name: nil directory")
}
if err := checkContext(ctx, "reserve race name"); err != nil {
return err
}
game, err := normalizeNonEmpty(gameID, "reserve race name", "game id")
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)
}
directory.mu.Lock()
defer directory.mu.Unlock()
if registered, ok := directory.registered[canonical]; ok && registered.userID != user {
return ports.ErrNameTaken
}
entry, ok := directory.entries[canonical]
if ok && entry.holderUserID != user {
return ports.ErrNameTaken
}
if !ok {
entry = &canonicalEntry{
holderUserID: user,
reservations: make(map[string]*reservationEntry),
}
directory.entries[canonical] = entry
}
if _, exists := entry.reservations[game]; exists {
return nil
}
entry.reservations[game] = &reservationEntry{
raceName: displayName,
reservedAtMs: directory.nowFn().UTC().UnixMilli(),
status: statusReserved,
}
return nil
}
// ReleaseReservation is a defensive no-op in the three cases described
// by the port contract.
func (directory *Directory) ReleaseReservation(
ctx context.Context,
gameID, userID, raceName string,
) error {
if directory == nil {
return errors.New("release race name reservation: nil directory")
}
if err := checkContext(ctx, "release race name reservation"); err != nil {
return err
}
game, err := normalizeNonEmpty(gameID, "release race name reservation", "game id")
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
}
directory.mu.Lock()
defer directory.mu.Unlock()
entry, ok := directory.entries[canonical]
if !ok || entry.holderUserID != user {
return nil
}
if _, exists := entry.reservations[game]; !exists {
return nil
}
delete(entry.reservations, game)
if len(entry.reservations) == 0 {
delete(directory.entries, canonical)
}
return nil
}
// MarkPendingRegistration promotes the reservation held for (gameID,
// userID) on raceName's canonical key to pending_registration status.
func (directory *Directory) MarkPendingRegistration(
ctx context.Context,
gameID, userID, raceName string,
eligibleUntil time.Time,
) error {
if directory == nil {
return errors.New("mark pending race name registration: nil directory")
}
if err := checkContext(ctx, "mark pending race name registration"); err != nil {
return err
}
game, err := normalizeNonEmpty(gameID, "mark pending race name registration", "game id")
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)
}
directory.mu.Lock()
defer directory.mu.Unlock()
entry, ok := directory.entries[canonical]
if !ok || entry.holderUserID != user {
return fmt.Errorf("mark pending race name registration: reservation missing for game %q user %q", game, user)
}
reservation, ok := entry.reservations[game]
if !ok {
return fmt.Errorf("mark pending race name registration: reservation missing for game %q user %q", game, user)
}
eligibleUntilMs := eligibleUntil.UTC().UnixMilli()
if reservation.status == statusPending {
if !reservation.hasEligibleUntil || reservation.eligibleUntilMs != eligibleUntilMs {
return fmt.Errorf("mark pending race name registration: %w", ports.ErrInvalidName)
}
return nil
}
reservation.status = statusPending
reservation.eligibleUntilMs = eligibleUntilMs
reservation.hasEligibleUntil = true
reservation.raceName = displayName
return nil
}
// ExpirePendingRegistrations releases every pending entry whose
// eligibleUntil is at or before now and returns the freed entries.
func (directory *Directory) ExpirePendingRegistrations(
ctx context.Context,
now time.Time,
) ([]ports.ExpiredPending, error) {
if directory == nil {
return nil, errors.New("expire pending race name registrations: nil directory")
}
if err := checkContext(ctx, "expire pending race name registrations"); err != nil {
return nil, err
}
cutoff := now.UTC().UnixMilli()
directory.mu.Lock()
defer directory.mu.Unlock()
var expired []ports.ExpiredPending
for canonical, entry := range directory.entries {
for game, reservation := range entry.reservations {
if reservation.status != statusPending || !reservation.hasEligibleUntil {
continue
}
if reservation.eligibleUntilMs > cutoff {
continue
}
expired = append(expired, ports.ExpiredPending{
CanonicalKey: canonical.String(),
RaceName: reservation.raceName,
GameID: game,
UserID: entry.holderUserID,
EligibleUntilMs: reservation.eligibleUntilMs,
})
delete(entry.reservations, game)
}
if len(entry.reservations) == 0 {
delete(directory.entries, canonical)
}
}
return expired, nil
}
// Register converts the pending entry for (gameID, userID) on
// raceName's canonical key into a registered race name.
func (directory *Directory) Register(
ctx context.Context,
gameID, userID, raceName string,
) error {
if directory == nil {
return errors.New("register race name: nil directory")
}
if err := checkContext(ctx, "register race name"); err != nil {
return err
}
game, err := normalizeNonEmpty(gameID, "register race name", "game id")
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)
}
directory.mu.Lock()
defer directory.mu.Unlock()
if existing, ok := directory.registered[canonical]; ok {
if existing.userID == user {
return nil
}
return ports.ErrNameTaken
}
entry, ok := directory.entries[canonical]
if !ok || entry.holderUserID != user {
return ports.ErrPendingMissing
}
pending, ok := entry.reservations[game]
if !ok || pending.status != statusPending {
return ports.ErrPendingMissing
}
if !pending.hasEligibleUntil || pending.eligibleUntilMs <= directory.nowFn().UTC().UnixMilli() {
return ports.ErrPendingExpired
}
directory.registered[canonical] = &registeredEntry{
userID: user,
raceName: displayName,
sourceGameID: game,
registeredAtMs: directory.nowFn().UTC().UnixMilli(),
}
delete(entry.reservations, game)
if len(entry.reservations) == 0 {
delete(directory.entries, canonical)
}
return nil
}
// ListRegistered returns every registered race name owned by userID.
func (directory *Directory) ListRegistered(
ctx context.Context,
userID string,
) ([]ports.RegisteredName, error) {
if directory == nil {
return nil, errors.New("list registered race names: nil directory")
}
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
}
directory.mu.Lock()
defer directory.mu.Unlock()
var results []ports.RegisteredName
for canonical, registered := range directory.registered {
if registered.userID != user {
continue
}
results = append(results, ports.RegisteredName{
CanonicalKey: canonical.String(),
RaceName: registered.raceName,
SourceGameID: registered.sourceGameID,
RegisteredAtMs: registered.registeredAtMs,
})
}
return results, nil
}
// ListPendingRegistrations returns every pending registration owned by
// userID.
func (directory *Directory) ListPendingRegistrations(
ctx context.Context,
userID string,
) ([]ports.PendingRegistration, error) {
if directory == nil {
return nil, errors.New("list pending race name registrations: nil directory")
}
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
}
directory.mu.Lock()
defer directory.mu.Unlock()
var results []ports.PendingRegistration
for canonical, entry := range directory.entries {
if entry.holderUserID != user {
continue
}
for game, reservation := range entry.reservations {
if reservation.status != statusPending {
continue
}
results = append(results, ports.PendingRegistration{
CanonicalKey: canonical.String(),
RaceName: reservation.raceName,
GameID: game,
ReservedAtMs: reservation.reservedAtMs,
EligibleUntilMs: reservation.eligibleUntilMs,
})
}
}
return results, nil
}
// ListReservations returns every active reservation owned by userID
// whose status has not yet been promoted to pending_registration.
func (directory *Directory) ListReservations(
ctx context.Context,
userID string,
) ([]ports.Reservation, error) {
if directory == nil {
return nil, errors.New("list race name reservations: nil directory")
}
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
}
directory.mu.Lock()
defer directory.mu.Unlock()
var results []ports.Reservation
for canonical, entry := range directory.entries {
if entry.holderUserID != user {
continue
}
for game, reservation := range entry.reservations {
if reservation.status != statusReserved {
continue
}
results = append(results, ports.Reservation{
CanonicalKey: canonical.String(),
RaceName: reservation.raceName,
GameID: game,
ReservedAtMs: reservation.reservedAtMs,
})
}
}
return results, nil
}
// ReleaseAllByUser clears every binding owned by userID atomically
// under the directory mutex.
func (directory *Directory) ReleaseAllByUser(
ctx context.Context,
userID string,
) error {
if directory == nil {
return errors.New("release all race names by user: nil directory")
}
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
}
directory.mu.Lock()
defer directory.mu.Unlock()
for canonical, registered := range directory.registered {
if registered.userID == user {
delete(directory.registered, canonical)
}
}
for canonical, entry := range directory.entries {
if entry.holderUserID == user {
delete(directory.entries, canonical)
}
}
return nil
}
// kindFromReservations returns the strongest ports.Kind constant for a
// canonicalEntry's reservation set (pending_registration beats
// reservation).
func kindFromReservations(reservations map[string]*reservationEntry) string {
for _, reservation := range reservations {
if reservation.status == statusPending {
return ports.KindPendingRegistration
}
}
return ports.KindReservation
}
// checkContext rejects nil or already-canceled contexts so the stub
// surfaces cancellation identically to the Redis adapter.
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
}
// Ensure *Directory satisfies the port interface at compile time.
var _ ports.RaceNameDirectory = (*Directory)(nil)
@@ -0,0 +1,78 @@
package racenameinmem_test
import (
"context"
"errors"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
"galaxy/lobby/internal/adapters/racenameinmem"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/ports/racenamedirtest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDirectoryContract(t *testing.T) {
racenamedirtest.Run(t, func(now func() time.Time) ports.RaceNameDirectory {
var opts []racenameinmem.Option
if now != nil {
opts = append(opts, racenameinmem.WithClock(now))
}
directory, err := racenameinmem.NewDirectory(opts...)
require.NoError(t, err)
return directory
})
}
func TestReserveConcurrentUniquenessInvariant(t *testing.T) {
t.Parallel()
const goroutines = 64
const raceName = "SolarPilot"
const gameID = "game-concurrency"
ctx := context.Background()
directory, err := racenameinmem.NewDirectory()
require.NoError(t, err)
var (
successCount atomic.Int32
takenCount atomic.Int32
waitGroup sync.WaitGroup
start = make(chan struct{})
)
waitGroup.Add(goroutines)
for index := range goroutines {
userID := "user-" + strconv.Itoa(index)
go func(userID string) {
defer waitGroup.Done()
<-start
err := directory.Reserve(ctx, gameID, userID, raceName)
switch {
case err == nil:
successCount.Add(1)
case errors.Is(err, ports.ErrNameTaken):
takenCount.Add(1)
default:
t.Errorf("unexpected error: %v", err)
}
}(userID)
}
close(start)
waitGroup.Wait()
assert.Equal(t, int32(1), successCount.Load())
assert.Equal(t, int32(goroutines-1), takenCount.Load())
availability, err := directory.Check(ctx, raceName, "user-missing")
require.NoError(t, err)
assert.True(t, availability.Taken)
assert.Equal(t, ports.KindReservation, availability.Kind)
}