feat: game lobby service
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
// Package pendingregistration implements the periodic worker that
|
||||
// releases every Race Name Directory pending_registration whose
|
||||
// eligible_until has lapsed. The worker delegates to
|
||||
// ports.RaceNameDirectory.ExpirePendingRegistrations and emits one
|
||||
// informational log entry per released binding using the structured
|
||||
// fields frozen in lobby/README.md §Observability. Replay safety is a
|
||||
// directory invariant: a second pass over the same state returns an
|
||||
// empty slice and produces no extra side effects.
|
||||
package pendingregistration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/logging"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/telemetry"
|
||||
)
|
||||
|
||||
// Dependencies groups the collaborators consumed by Worker.
|
||||
type Dependencies struct {
|
||||
// Directory exposes the Race Name Directory expiration entry point.
|
||||
Directory ports.RaceNameDirectory
|
||||
|
||||
// Interval controls the tick cadence. It must be positive.
|
||||
Interval time.Duration
|
||||
|
||||
// Clock supplies the wall-clock used to derive the "now" reference
|
||||
// passed to ExpirePendingRegistrations. Defaults to time.Now when nil.
|
||||
Clock func() time.Time
|
||||
|
||||
// Logger receives structured worker-level events. Defaults to
|
||||
// slog.Default when nil.
|
||||
Logger *slog.Logger
|
||||
|
||||
// Telemetry records the
|
||||
// `lobby.pending_registration.expirations` counter once per
|
||||
// released entry. Optional; nil disables metric emission.
|
||||
Telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// Worker drives the periodic pending-registration expiration loop.
|
||||
type Worker struct {
|
||||
directory ports.RaceNameDirectory
|
||||
interval time.Duration
|
||||
clock func() time.Time
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
}
|
||||
|
||||
// NewWorker constructs one Worker from deps.
|
||||
func NewWorker(deps Dependencies) (*Worker, error) {
|
||||
if deps.Directory == nil {
|
||||
return nil, errors.New("new pending registration worker: nil race name directory")
|
||||
}
|
||||
if deps.Interval <= 0 {
|
||||
return nil, fmt.Errorf("new pending registration worker: interval must be positive, got %s", deps.Interval)
|
||||
}
|
||||
|
||||
clock := deps.Clock
|
||||
if clock == nil {
|
||||
clock = time.Now
|
||||
}
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &Worker{
|
||||
directory: deps.Directory,
|
||||
interval: deps.Interval,
|
||||
clock: clock,
|
||||
logger: logger.With("worker", "lobby.pendingregistration"),
|
||||
telemetry: deps.Telemetry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run drives the periodic ticker until ctx is cancelled. A failure
|
||||
// inside one tick does not terminate the loop — every tick logs its
|
||||
// outcome and the worker stays alive so subsequent ticks pick up once
|
||||
// the underlying issue clears.
|
||||
func (worker *Worker) Run(ctx context.Context) error {
|
||||
if worker == nil {
|
||||
return errors.New("run pending registration worker: nil worker")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("run pending registration worker: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
worker.logger.Info("pending registration worker started", "interval", worker.interval.String())
|
||||
defer worker.logger.Info("pending registration worker stopped")
|
||||
|
||||
ticker := time.NewTicker(worker.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
worker.Tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown is a no-op: the worker holds no resources beyond its own
|
||||
// goroutine, which Run releases on context cancellation.
|
||||
func (worker *Worker) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown pending registration worker: nil context")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tick performs one expiration pass. It is exported so tests may drive
|
||||
// the worker deterministically without a real ticker. Tick never
|
||||
// returns an error; a failed expiration call is logged and absorbed so
|
||||
// the worker survives transient backend issues.
|
||||
func (worker *Worker) Tick(ctx context.Context) {
|
||||
if worker == nil || ctx == nil {
|
||||
return
|
||||
}
|
||||
|
||||
now := worker.clock().UTC()
|
||||
|
||||
expired, err := worker.directory.ExpirePendingRegistrations(ctx, now)
|
||||
if err != nil {
|
||||
worker.logger.WarnContext(ctx, "expire pending race name registrations",
|
||||
"err", err.Error(),
|
||||
)
|
||||
return
|
||||
}
|
||||
if len(expired) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range expired {
|
||||
worker.telemetry.RecordPendingRegistrationExpiration(ctx, "tick")
|
||||
logArgs := []any{
|
||||
"canonical_key", entry.CanonicalKey,
|
||||
"race_name", entry.RaceName,
|
||||
"game_id", entry.GameID,
|
||||
"user_id", entry.UserID,
|
||||
"eligible_until_ms", entry.EligibleUntilMs,
|
||||
"reservation_kind", ports.KindPendingRegistration,
|
||||
"trigger", "tick",
|
||||
}
|
||||
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
|
||||
worker.logger.InfoContext(ctx, "released pending race name registration", logArgs...)
|
||||
}
|
||||
logArgs := []any{
|
||||
"released", len(expired),
|
||||
}
|
||||
logArgs = append(logArgs, logging.ContextAttrs(ctx)...)
|
||||
worker.logger.InfoContext(ctx, "pending registration tick released entries", logArgs...)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package pendingregistration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/racenamestub"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/worker/pendingregistration"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
gameA = "game-A"
|
||||
gameB = "game-B"
|
||||
userA = "user-A"
|
||||
userB = "user-B"
|
||||
raceNameA = "PilotNova"
|
||||
raceNameB = "Vanguard"
|
||||
)
|
||||
|
||||
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
type controlledClock struct{ instant time.Time }
|
||||
|
||||
func (clock *controlledClock) now() time.Time { return clock.instant }
|
||||
func (clock *controlledClock) advance(d time.Duration) { clock.instant = clock.instant.Add(d) }
|
||||
|
||||
func newDirectory(t *testing.T, clock *controlledClock) *racenamestub.Directory {
|
||||
t.Helper()
|
||||
directory, err := racenamestub.NewDirectory(racenamestub.WithClock(clock.now))
|
||||
require.NoError(t, err)
|
||||
return directory
|
||||
}
|
||||
|
||||
func newWorker(
|
||||
t *testing.T,
|
||||
directory ports.RaceNameDirectory,
|
||||
clock func() time.Time,
|
||||
) *pendingregistration.Worker {
|
||||
t.Helper()
|
||||
worker, err := pendingregistration.NewWorker(pendingregistration.Dependencies{
|
||||
Directory: directory,
|
||||
Interval: time.Hour,
|
||||
Clock: clock,
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return worker
|
||||
}
|
||||
|
||||
func reserveAndPend(
|
||||
t *testing.T,
|
||||
directory ports.RaceNameDirectory,
|
||||
gameID, userID, raceName string,
|
||||
eligibleUntil time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
require.NoError(t, directory.Reserve(ctx, gameID, userID, raceName))
|
||||
require.NoError(t, directory.MarkPendingRegistration(ctx, gameID, userID, raceName, eligibleUntil))
|
||||
}
|
||||
|
||||
func TestNewWorkerRejectsNilDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := pendingregistration.NewWorker(pendingregistration.Dependencies{
|
||||
Interval: time.Hour,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewWorkerRejectsNonPositiveInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory, err := racenamestub.NewDirectory()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = pendingregistration.NewWorker(pendingregistration.Dependencies{
|
||||
Directory: directory,
|
||||
Interval: 0,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestTickReleasesExpiredEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
clock := &controlledClock{instant: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)}
|
||||
directory := newDirectory(t, clock)
|
||||
|
||||
eligibleUntil := clock.instant.Add(time.Hour)
|
||||
reserveAndPend(t, directory, gameA, userA, raceNameA, eligibleUntil)
|
||||
|
||||
clock.advance(2 * time.Hour)
|
||||
worker := newWorker(t, directory, clock.now)
|
||||
worker.Tick(context.Background())
|
||||
|
||||
pending, err := directory.ListPendingRegistrations(context.Background(), userA)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, pending)
|
||||
|
||||
availability, err := directory.Check(context.Background(), raceNameA, userB)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, availability.Taken)
|
||||
}
|
||||
|
||||
func TestTickAtBoundaryReleasesEntry(t *testing.T) {
|
||||
t.Parallel()
|
||||
clock := &controlledClock{instant: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)}
|
||||
directory := newDirectory(t, clock)
|
||||
|
||||
eligibleUntil := clock.instant.Add(time.Hour)
|
||||
reserveAndPend(t, directory, gameA, userA, raceNameA, eligibleUntil)
|
||||
|
||||
clock.instant = eligibleUntil
|
||||
worker := newWorker(t, directory, clock.now)
|
||||
worker.Tick(context.Background())
|
||||
|
||||
pending, err := directory.ListPendingRegistrations(context.Background(), userA)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, pending)
|
||||
}
|
||||
|
||||
func TestTickKeepsFutureEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
clock := &controlledClock{instant: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)}
|
||||
directory := newDirectory(t, clock)
|
||||
|
||||
eligibleUntil := clock.instant.Add(time.Hour)
|
||||
reserveAndPend(t, directory, gameA, userA, raceNameA, eligibleUntil)
|
||||
|
||||
worker := newWorker(t, directory, clock.now)
|
||||
worker.Tick(context.Background())
|
||||
|
||||
pending, err := directory.ListPendingRegistrations(context.Background(), userA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, pending, 1)
|
||||
assert.Equal(t, raceNameA, pending[0].RaceName)
|
||||
}
|
||||
|
||||
func TestTickReleasesMixedAgeEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
clock := &controlledClock{instant: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)}
|
||||
directory := newDirectory(t, clock)
|
||||
|
||||
expiredUntil := clock.instant.Add(time.Hour)
|
||||
freshUntil := clock.instant.Add(48 * time.Hour)
|
||||
reserveAndPend(t, directory, gameA, userA, raceNameA, expiredUntil)
|
||||
reserveAndPend(t, directory, gameB, userB, raceNameB, freshUntil)
|
||||
|
||||
clock.advance(2 * time.Hour)
|
||||
worker := newWorker(t, directory, clock.now)
|
||||
worker.Tick(context.Background())
|
||||
|
||||
pendingA, err := directory.ListPendingRegistrations(context.Background(), userA)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, pendingA)
|
||||
|
||||
pendingB, err := directory.ListPendingRegistrations(context.Background(), userB)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, pendingB, 1)
|
||||
assert.Equal(t, raceNameB, pendingB[0].RaceName)
|
||||
}
|
||||
|
||||
func TestTickIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
clock := &controlledClock{instant: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)}
|
||||
directory := newDirectory(t, clock)
|
||||
|
||||
reserveAndPend(t, directory, gameA, userA, raceNameA, clock.instant.Add(time.Hour))
|
||||
|
||||
clock.advance(2 * time.Hour)
|
||||
worker := newWorker(t, directory, clock.now)
|
||||
|
||||
worker.Tick(context.Background())
|
||||
worker.Tick(context.Background())
|
||||
|
||||
pending, err := directory.ListPendingRegistrations(context.Background(), userA)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, pending)
|
||||
}
|
||||
|
||||
func TestTickReservedEntriesUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
clock := &controlledClock{instant: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)}
|
||||
directory := newDirectory(t, clock)
|
||||
|
||||
require.NoError(t, directory.Reserve(context.Background(), gameA, userA, raceNameA))
|
||||
|
||||
clock.advance(48 * time.Hour)
|
||||
worker := newWorker(t, directory, clock.now)
|
||||
worker.Tick(context.Background())
|
||||
|
||||
reservations, err := directory.ListReservations(context.Background(), userA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reservations, 1)
|
||||
assert.Equal(t, raceNameA, reservations[0].RaceName)
|
||||
}
|
||||
|
||||
func TestTickAbsorbsDirectoryError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := failingDirectory{err: errors.New("redis unavailable")}
|
||||
worker, err := pendingregistration.NewWorker(pendingregistration.Dependencies{
|
||||
Directory: directory,
|
||||
Interval: time.Hour,
|
||||
Clock: func() time.Time { return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC) },
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
worker.Tick(context.Background())
|
||||
}
|
||||
|
||||
func TestRunStopsOnContextCancel(t *testing.T) {
|
||||
t.Parallel()
|
||||
clock := &controlledClock{instant: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)}
|
||||
directory := newDirectory(t, clock)
|
||||
worker := newWorker(t, directory, clock.now)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- worker.Run(ctx) }()
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("worker did not stop after context cancel")
|
||||
}
|
||||
|
||||
require.NoError(t, worker.Shutdown(context.Background()))
|
||||
}
|
||||
|
||||
// failingDirectory is a stand-in that surfaces a fixed error from
|
||||
// ExpirePendingRegistrations so the worker's failure path can be
|
||||
// exercised without spinning up the full Redis adapter.
|
||||
type failingDirectory struct {
|
||||
ports.RaceNameDirectory
|
||||
err error
|
||||
}
|
||||
|
||||
func (directory failingDirectory) ExpirePendingRegistrations(
|
||||
context.Context,
|
||||
time.Time,
|
||||
) ([]ports.ExpiredPending, error) {
|
||||
return nil, directory.err
|
||||
}
|
||||
Reference in New Issue
Block a user