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...) }