feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
@@ -0,0 +1,174 @@
// Package metricsracenamedir wraps a ports.RaceNameDirectory with the
// `lobby.race_name.outcomes` counter from `lobby/README.md` §Observability.
package metricsracenamedir
import (
"context"
"time"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/telemetry"
)
// Directory decorates an inner ports.RaceNameDirectory and emits a
// `lobby.race_name.outcomes` increment per successful side-effect call.
//
// Errors do not increment the counter — the README outcome vocabulary only
// enumerates positive outcomes.
type Directory struct {
inner ports.RaceNameDirectory
telemetry *telemetry.Runtime
}
// New constructs one Directory around inner. When telemetryRuntime is nil,
// the wrapper still delegates each call but does not record metrics.
func New(inner ports.RaceNameDirectory, telemetryRuntime *telemetry.Runtime) *Directory {
return &Directory{inner: inner, telemetry: telemetryRuntime}
}
// Canonicalize forwards to the inner directory; no metric is recorded.
func (directory *Directory) Canonicalize(raceName string) (string, error) {
if directory == nil || directory.inner == nil {
return "", nil
}
return directory.inner.Canonicalize(raceName)
}
// Check forwards to the inner directory; no metric is recorded.
func (directory *Directory) Check(ctx context.Context, raceName, actorUserID string) (ports.Availability, error) {
if directory == nil || directory.inner == nil {
return ports.Availability{}, nil
}
return directory.inner.Check(ctx, raceName, actorUserID)
}
// Reserve emits `outcome=reserved` after a successful inner call.
func (directory *Directory) Reserve(ctx context.Context, gameID, userID, raceName string) error {
if directory == nil || directory.inner == nil {
return nil
}
if err := directory.inner.Reserve(ctx, gameID, userID, raceName); err != nil {
return err
}
directory.telemetry.RecordRaceNameOutcome(ctx, "reserved")
return nil
}
// ReleaseReservation emits `outcome=reservation_released` after a
// successful inner call. Per the inner contract a successful return covers
// both real releases and harmless no-ops; the metric counts release
// attempts that completed without error.
func (directory *Directory) ReleaseReservation(ctx context.Context, gameID, userID, raceName string) error {
if directory == nil || directory.inner == nil {
return nil
}
if err := directory.inner.ReleaseReservation(ctx, gameID, userID, raceName); err != nil {
return err
}
directory.telemetry.RecordRaceNameOutcome(ctx, "reservation_released")
return nil
}
// MarkPendingRegistration emits `outcome=pending_created` after a
// successful inner call.
func (directory *Directory) MarkPendingRegistration(
ctx context.Context,
gameID, userID, raceName string,
eligibleUntil time.Time,
) error {
if directory == nil || directory.inner == nil {
return nil
}
if err := directory.inner.MarkPendingRegistration(ctx, gameID, userID, raceName, eligibleUntil); err != nil {
return err
}
directory.telemetry.RecordRaceNameOutcome(ctx, "pending_created")
return nil
}
// ExpirePendingRegistrations emits `outcome=pending_released` once per
// returned expired entry.
func (directory *Directory) ExpirePendingRegistrations(ctx context.Context, now time.Time) ([]ports.ExpiredPending, error) {
if directory == nil || directory.inner == nil {
return nil, nil
}
expired, err := directory.inner.ExpirePendingRegistrations(ctx, now)
if err != nil {
return expired, err
}
for range expired {
directory.telemetry.RecordRaceNameOutcome(ctx, "pending_released")
}
return expired, nil
}
// Register emits `outcome=registered` after a successful inner call.
func (directory *Directory) Register(ctx context.Context, gameID, userID, raceName string) error {
if directory == nil || directory.inner == nil {
return nil
}
if err := directory.inner.Register(ctx, gameID, userID, raceName); err != nil {
return err
}
directory.telemetry.RecordRaceNameOutcome(ctx, "registered")
return nil
}
// ListRegistered forwards to the inner directory; no metric is recorded.
func (directory *Directory) ListRegistered(ctx context.Context, userID string) ([]ports.RegisteredName, error) {
if directory == nil || directory.inner == nil {
return nil, nil
}
return directory.inner.ListRegistered(ctx, userID)
}
// ListPendingRegistrations forwards to the inner directory; no metric is
// recorded.
func (directory *Directory) ListPendingRegistrations(ctx context.Context, userID string) ([]ports.PendingRegistration, error) {
if directory == nil || directory.inner == nil {
return nil, nil
}
return directory.inner.ListPendingRegistrations(ctx, userID)
}
// ListReservations forwards to the inner directory; no metric is recorded.
func (directory *Directory) ListReservations(ctx context.Context, userID string) ([]ports.Reservation, error) {
if directory == nil || directory.inner == nil {
return nil, nil
}
return directory.inner.ListReservations(ctx, userID)
}
// ReleaseAllByUser snapshots the per-kind counts via List* before invoking
// the inner cascade, then emits one
// `reservation_released`/`pending_released`/`registered_released` per
// snapshotted entry on success. The pre-call snapshot is non-atomic
// relative to the cascade itself; telemetry counts are advisory and
// tolerate this race.
func (directory *Directory) ReleaseAllByUser(ctx context.Context, userID string) error {
if directory == nil || directory.inner == nil {
return nil
}
reservations, _ := directory.inner.ListReservations(ctx, userID)
pending, _ := directory.inner.ListPendingRegistrations(ctx, userID)
registered, _ := directory.inner.ListRegistered(ctx, userID)
if err := directory.inner.ReleaseAllByUser(ctx, userID); err != nil {
return err
}
for range reservations {
directory.telemetry.RecordRaceNameOutcome(ctx, "reservation_released")
}
for range pending {
directory.telemetry.RecordRaceNameOutcome(ctx, "pending_released")
}
for range registered {
directory.telemetry.RecordRaceNameOutcome(ctx, "registered_released")
}
return nil
}
// Compile-time interface assertion.
var _ ports.RaceNameDirectory = (*Directory)(nil)
@@ -0,0 +1,142 @@
package metricsracenamedir_test
import (
"context"
"testing"
"time"
"galaxy/lobby/internal/adapters/metricsracenamedir"
"galaxy/lobby/internal/adapters/racenamestub"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/telemetry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
)
func newRuntime(t *testing.T) (*telemetry.Runtime, sdkmetric.Reader) {
t.Helper()
reader := sdkmetric.NewManualReader()
provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
t.Cleanup(func() { _ = provider.Shutdown(context.Background()) })
runtime, err := telemetry.NewWithProviders(provider, nil)
require.NoError(t, err)
return runtime, reader
}
func newInner(t *testing.T) ports.RaceNameDirectory {
t.Helper()
stub, err := racenamestub.NewDirectory()
require.NoError(t, err)
return stub
}
func TestDirectoryRecordsReserveAndReleaseOutcomes(t *testing.T) {
t.Parallel()
runtime, reader := newRuntime(t)
dir := metricsracenamedir.New(newInner(t), runtime)
ctx := context.Background()
require.NoError(t, dir.Reserve(ctx, "game-a", "user-1", "Apollon"))
require.NoError(t, dir.ReleaseReservation(ctx, "game-a", "user-1", "Apollon"))
rm := collect(t, reader)
counts := raceNameCounts(rm)
assert.Equal(t, int64(1), counts["reserved"])
assert.Equal(t, int64(1), counts["reservation_released"])
}
func TestDirectoryRecordsPendingAndRegistered(t *testing.T) {
t.Parallel()
runtime, reader := newRuntime(t)
dir := metricsracenamedir.New(newInner(t), runtime)
ctx := context.Background()
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
eligibleUntil := now.Add(30 * 24 * time.Hour)
require.NoError(t, dir.Reserve(ctx, "game-finished", "user-7", "Helios"))
require.NoError(t, dir.MarkPendingRegistration(ctx, "game-finished", "user-7", "Helios", eligibleUntil))
require.NoError(t, dir.Register(ctx, "game-finished", "user-7", "Helios"))
rm := collect(t, reader)
counts := raceNameCounts(rm)
assert.Equal(t, int64(1), counts["pending_created"])
assert.Equal(t, int64(1), counts["registered"])
}
func TestDirectoryRecordsExpiredPending(t *testing.T) {
t.Parallel()
runtime, reader := newRuntime(t)
dir := metricsracenamedir.New(newInner(t), runtime)
ctx := context.Background()
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
require.NoError(t, dir.Reserve(ctx, "game-old", "user-9", "Aether"))
require.NoError(t, dir.MarkPendingRegistration(ctx, "game-old", "user-9", "Aether", old))
expired, err := dir.ExpirePendingRegistrations(ctx, old.Add(time.Hour))
require.NoError(t, err)
require.Len(t, expired, 1)
rm := collect(t, reader)
assert.Equal(t, int64(1), raceNameCounts(rm)["pending_released"])
}
func TestDirectoryReleaseAllByUserSnapshotsCounts(t *testing.T) {
t.Parallel()
runtime, reader := newRuntime(t)
dir := metricsracenamedir.New(newInner(t), runtime)
ctx := context.Background()
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
eligibleUntil := now.Add(30 * 24 * time.Hour)
require.NoError(t, dir.Reserve(ctx, "game-active", "user-z", "Boreas"))
require.NoError(t, dir.Reserve(ctx, "game-finished", "user-z", "Notos"))
require.NoError(t, dir.MarkPendingRegistration(ctx, "game-finished", "user-z", "Notos", eligibleUntil))
require.NoError(t, dir.Reserve(ctx, "game-other", "user-z", "Eurus"))
require.NoError(t, dir.MarkPendingRegistration(ctx, "game-other", "user-z", "Eurus", eligibleUntil))
require.NoError(t, dir.Register(ctx, "game-other", "user-z", "Eurus"))
require.NoError(t, dir.ReleaseAllByUser(ctx, "user-z"))
rm := collect(t, reader)
counts := raceNameCounts(rm)
assert.GreaterOrEqual(t, counts["reservation_released"], int64(1))
assert.GreaterOrEqual(t, counts["pending_released"], int64(1))
assert.GreaterOrEqual(t, counts["registered_released"], int64(1))
}
func collect(t *testing.T, reader sdkmetric.Reader) metricdata.ResourceMetrics {
t.Helper()
var rm metricdata.ResourceMetrics
require.NoError(t, reader.Collect(context.Background(), &rm))
return rm
}
func raceNameCounts(rm metricdata.ResourceMetrics) map[string]int64 {
counts := map[string]int64{}
for _, scope := range rm.ScopeMetrics {
for _, m := range scope.Metrics {
if m.Name != "lobby.race_name.outcomes" {
continue
}
sum, ok := m.Data.(metricdata.Sum[int64])
if !ok {
continue
}
for _, point := range sum.DataPoints {
outcome, _ := point.Attributes.Value("outcome")
counts[outcome.AsString()] += point.Value
}
}
}
return counts
}