feat: runtime manager
This commit is contained in:
@@ -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] = ®isteredEntry{
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user