Files
galaxy-game/gamemaster/internal/worker/schedulerticker/worker.go
T
2026-05-03 07:59:03 +02:00

219 lines
6.2 KiB
Go

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