219 lines
6.2 KiB
Go
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()
|
|
}
|