// Package schedulerticker drives the periodic turn-generation // scheduler described in `gamemaster/README.md §Background workers`. // // On every tick (default 1 s) the worker scans // `runtime_records.ListDueRunning(now)` and dispatches one // `turngeneration.Service.Handle` call per due game. Each in-flight // game id is tracked in an in-process set so a long-running engine call // never causes the same game to be dispatched twice. The CAS in // `turngeneration` is the authoritative protection; the in-flight set // is a cheap optimisation that avoids issuing a doomed engine call only // to discard a `conflict` outcome. // // Per-tick errors are absorbed; the loop terminates only on context // cancellation. package schedulerticker import ( "context" "errors" "log/slog" "sync" "time" "galaxy/gamemaster/internal/domain/operation" "galaxy/gamemaster/internal/logging" "galaxy/gamemaster/internal/ports" "galaxy/gamemaster/internal/service/turngeneration" "galaxy/gamemaster/internal/telemetry" ) // Dependencies groups the collaborators required by Worker. type Dependencies struct { // RuntimeRecords lists due-now running records once per tick. RuntimeRecords ports.RuntimeRecordStore // TurnGeneration drives the per-game turn-generation flow. TurnGeneration *turngeneration.Service // Telemetry records `gamemaster.scheduler.due_games` indirectly via // the gauge probe (Stage 19 wires it). The worker itself only // records turn-generation outcomes inside `turngeneration.Service`. Telemetry *telemetry.Runtime // Interval bounds the tick period. Required positive. Interval time.Duration // Clock supplies the wall-clock used for ListDueRunning. 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 } // Worker drives the scheduler tick loop. type Worker struct { runtimeRecords ports.RuntimeRecordStore turnGeneration *turngeneration.Service telemetry *telemetry.Runtime interval time.Duration clock func() time.Time logger *slog.Logger inflight sync.Map // map[gameID]struct{} wg sync.WaitGroup } // NewWorker constructs one Worker from deps. func NewWorker(deps Dependencies) (*Worker, error) { switch { case deps.RuntimeRecords == nil: return nil, errors.New("new scheduler ticker: nil runtime records store") case deps.TurnGeneration == nil: return nil, errors.New("new scheduler ticker: nil turn generation service") case deps.Telemetry == nil: return nil, errors.New("new scheduler ticker: nil telemetry runtime") case deps.Interval <= 0: return nil, errors.New("new scheduler ticker: interval must be positive") } clock := deps.Clock if clock == nil { clock = time.Now } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Worker{ runtimeRecords: deps.RuntimeRecords, turnGeneration: deps.TurnGeneration, telemetry: deps.Telemetry, interval: deps.Interval, clock: clock, logger: logger.With("worker", "gamemaster.schedulerticker"), }, nil } // Shutdown is a no-op kept so the worker satisfies the // `app.Component` interface alongside `Run`. The loop already // terminates when the context handed to Run is cancelled and the // in-flight goroutines drain before Run returns; an explicit Shutdown // has nothing extra to release. func (worker *Worker) Shutdown(_ context.Context) error { return nil } // Run drives the scheduler loop until ctx is cancelled. Run waits for // the in-flight goroutines launched on the most recent tick to return // before exiting so cancellation is observable through ctx for both the // loop and the per-game work. func (worker *Worker) Run(ctx context.Context) error { if worker == nil { return errors.New("run scheduler ticker: nil worker") } if ctx == nil { return errors.New("run scheduler ticker: nil context") } if err := ctx.Err(); err != nil { return err } worker.logger.Info("scheduler ticker started", "interval", worker.interval.String(), ) defer worker.logger.Info("scheduler ticker stopped") ticker := time.NewTicker(worker.interval) defer ticker.Stop() for { select { case <-ctx.Done(): worker.wg.Wait() return ctx.Err() case <-ticker.C: worker.Tick(ctx) } } } // Tick performs one full pass. Exported so tests can drive the worker // deterministically without waiting on a real ticker. func (worker *Worker) Tick(ctx context.Context) { if err := ctx.Err(); err != nil { return } now := worker.clock().UTC() due, err := worker.runtimeRecords.ListDueRunning(ctx, now) if err != nil { logArgs := []any{ "err", err.Error(), } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) worker.logger.WarnContext(ctx, "list due running records", logArgs...) return } if len(due) == 0 { return } for _, record := range due { gameID := record.GameID if _, loaded := worker.inflight.LoadOrStore(gameID, struct{}{}); loaded { worker.logger.DebugContext(ctx, "skip due game: in-flight", "game_id", gameID, ) continue } worker.wg.Add(1) go worker.dispatch(ctx, gameID) } } // dispatch runs one turn-generation operation against gameID and // releases the in-flight slot when the call returns. func (worker *Worker) dispatch(ctx context.Context, gameID string) { defer worker.wg.Done() defer worker.inflight.Delete(gameID) result, err := worker.turnGeneration.Handle(ctx, turngeneration.Input{ GameID: gameID, Trigger: turngeneration.TriggerScheduler, OpSource: operation.OpSourceAdminRest, }) if err != nil { logArgs := []any{ "game_id", gameID, "err", err.Error(), } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) worker.logger.ErrorContext(ctx, "turn generation handle returned error", logArgs...) return } if !result.IsSuccess() { logArgs := []any{ "game_id", gameID, "error_code", result.ErrorCode, "error_message", result.ErrorMessage, } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) worker.logger.DebugContext(ctx, "turn generation completed with non-success outcome", logArgs...) } } // Wait blocks until every in-flight goroutine launched by Run / Tick // has returned. Useful for tests that drive Tick directly. func (worker *Worker) Wait() { if worker == nil { return } worker.wg.Wait() }