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