feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
+142
View File
@@ -0,0 +1,142 @@
package lobby
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
// Sweeper is the periodic lobby maintenance worker. Each tick it
// releases expired `pending_registration` race-name rows and
// auto-closes enrollment windows whose `enrollment_ends_at` has passed.
//
// Implements `internal/app.Component`. The sweeper Run loop terminates
// on the parent context cancellation; Shutdown is a no-op because
// every tick already completes synchronously inside Run.
type Sweeper struct {
svc *Service
interval time.Duration
logger *zap.Logger
now func() time.Time
}
// NewSweeper constructs the sweeper. The interval falls back to the
// service config when zero.
func NewSweeper(svc *Service) *Sweeper {
cfg := svc.Config()
return &Sweeper{
svc: svc,
interval: cfg.SweeperInterval,
logger: svc.Logger().Named("sweeper"),
now: svc.deps.Now,
}
}
// Run drives the sweeper goroutine until ctx is done.
func (s *Sweeper) Run(ctx context.Context) error {
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
// Run one tick immediately so a fresh process catches up on missed
// work without waiting for the first interval. Tests rely on this
// for deterministic e2e flows.
if err := s.tick(ctx); err != nil {
s.logger.Warn("lobby sweeper tick failed", zap.Error(err))
}
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := s.tick(ctx); err != nil {
s.logger.Warn("lobby sweeper tick failed", zap.Error(err))
}
}
}
}
// Shutdown is a no-op: every tick is synchronous inside Run.
func (s *Sweeper) Shutdown(_ context.Context) error { return nil }
// Tick runs a single sweep iteration. Exposed for tests so they can
// drive the sweeper without timing dependencies.
func (s *Sweeper) Tick(ctx context.Context) error { return s.tick(ctx) }
func (s *Sweeper) tick(ctx context.Context) error {
now := s.now().UTC()
releaseErr := s.releaseExpiredPending(ctx, now)
closeErr := s.autoCloseEnrollment(ctx, now)
return errors.Join(releaseErr, closeErr)
}
func (s *Sweeper) releaseExpiredPending(ctx context.Context, now time.Time) error {
rows, err := s.svc.deps.Store.ListPendingRegistrationsExpired(ctx, now)
if err != nil {
return fmt.Errorf("lobby sweeper: list expired pending: %w", err)
}
var errs []error
for _, row := range rows {
if err := s.svc.deps.Store.DeleteRaceName(ctx, row.Canonical, row.GameID); err != nil {
errs = append(errs, fmt.Errorf("delete pending %s: %w", row.Canonical, err))
continue
}
s.svc.deps.Cache.RemoveRaceName(row.Canonical)
intent := LobbyNotification{
Kind: NotificationLobbyRaceNameExpired,
IdempotencyKey: "racename-expired:" + string(row.Canonical) + ":" + row.GameID.String(),
Recipients: []uuid.UUID{row.OwnerUserID},
Payload: map[string]any{
"race_name": row.Name,
},
}
if pubErr := s.svc.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
s.logger.Warn("expired notification failed",
zap.String("canonical", string(row.Canonical)),
zap.Error(pubErr))
}
}
return errors.Join(errs...)
}
func (s *Sweeper) autoCloseEnrollment(ctx context.Context, now time.Time) error {
games, err := s.svc.deps.Store.ListEnrollmentExpiredGames(ctx, now)
if err != nil {
return fmt.Errorf("lobby sweeper: list expired enrollments: %w", err)
}
var errs []error
for _, game := range games {
active, err := s.svc.deps.Store.CountActiveMemberships(ctx, game.GameID)
if err != nil {
errs = append(errs, fmt.Errorf("count memberships %s: %w", game.GameID, err))
continue
}
if int32(active) < game.MinPlayers {
// Below quorum — leave the game in enrollment_open. Admins
// can extend `enrollment_ends_at` or cancel manually.
s.logger.Debug("enrollment expired below quorum, leaving",
zap.String("game_id", game.GameID.String()),
zap.Int32("min_players", game.MinPlayers),
zap.Int("active", active))
continue
}
updated, err := s.svc.deps.Store.UpdateGameStatus(ctx, game.GameID, statusUpdate{
NewStatus: GameStatusReadyToStart,
UpdatedAt: now,
})
if err != nil {
errs = append(errs, fmt.Errorf("transition %s to ready_to_start: %w", game.GameID, err))
continue
}
s.svc.deps.Cache.PutGame(updated)
s.logger.Info("enrollment auto-closed",
zap.String("game_id", game.GameID.String()),
zap.Int32("min_players", game.MinPlayers),
zap.Int("active", active))
}
return errors.Join(errs...)
}