163 lines
4.8 KiB
Go
163 lines
4.8 KiB
Go
// 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...)
|
|
}
|