// Package runtimemanager provides the Redis Streams write-only adapter // for ports.RuntimeManager. The publisher emits one event per call to // the configured start-jobs or stop-jobs stream so Runtime Manager (when // implemented) can consume them via XREAD. // // The two streams are intentionally separate: each one carries a single // command kind, which keeps the consumer-side logic in Runtime Manager // simple and avoids a `kind` discriminator inside the message body. // // Envelope shape per `rtmanager/api/runtime-jobs-asyncapi.yaml`: // // - `runtime:start_jobs` — `{game_id, image_ref, requested_at_ms}`, // - `runtime:stop_jobs` — `{game_id, reason, requested_at_ms}`. // // The producer-supplied `image_ref` is resolved by the caller from the // game's `target_engine_version` and the configured engine-image // template; Runtime Manager never resolves engine versions itself. package runtimemanager import ( "context" "errors" "fmt" "strings" "time" "galaxy/lobby/internal/ports" "github.com/redis/go-redis/v9" ) // Config groups the parameters required to construct a Publisher. type Config struct { // Client appends events to Redis Streams. Client *redis.Client // StartJobsStream stores the Redis Stream key receiving start jobs. StartJobsStream string // StopJobsStream stores the Redis Stream key receiving stop jobs. StopJobsStream string // Clock supplies the wall-clock used for the requested-at timestamp. // Defaults to time.Now when nil. Clock func() time.Time } // Validate reports whether cfg stores a usable Publisher configuration. func (cfg Config) Validate() error { switch { case cfg.Client == nil: return errors.New("runtime manager publisher: nil redis client") case strings.TrimSpace(cfg.StartJobsStream) == "": return errors.New("runtime manager publisher: start jobs stream must not be empty") case strings.TrimSpace(cfg.StopJobsStream) == "": return errors.New("runtime manager publisher: stop jobs stream must not be empty") default: return nil } } // Publisher implements ports.RuntimeManager on top of Redis Streams. type Publisher struct { client *redis.Client startJobsStream string stopJobsStream string clock func() time.Time } // NewPublisher constructs a Publisher from cfg. func NewPublisher(cfg Config) (*Publisher, error) { if err := cfg.Validate(); err != nil { return nil, err } clock := cfg.Clock if clock == nil { clock = time.Now } return &Publisher{ client: cfg.Client, startJobsStream: cfg.StartJobsStream, stopJobsStream: cfg.StopJobsStream, clock: clock, }, nil } // PublishStartJob appends one start-job event for gameID with the // resolved imageRef to the configured start-jobs stream. func (publisher *Publisher) PublishStartJob(ctx context.Context, gameID, imageRef string) error { const op = "publish start job" if err := publisher.checkCommon(op, ctx, gameID); err != nil { return err } if strings.TrimSpace(imageRef) == "" { return fmt.Errorf("%s: image ref must not be empty", op) } values := map[string]any{ "game_id": gameID, "image_ref": imageRef, "requested_at_ms": publisher.clock().UTC().UnixMilli(), } return publisher.xadd(ctx, op, publisher.startJobsStream, values) } // PublishStopJob appends one stop-job event for gameID classified by // reason to the configured stop-jobs stream. func (publisher *Publisher) PublishStopJob(ctx context.Context, gameID string, reason ports.StopReason) error { const op = "publish stop job" if err := publisher.checkCommon(op, ctx, gameID); err != nil { return err } if err := reason.Validate(); err != nil { return fmt.Errorf("%s: %w", op, err) } values := map[string]any{ "game_id": gameID, "reason": reason.String(), "requested_at_ms": publisher.clock().UTC().UnixMilli(), } return publisher.xadd(ctx, op, publisher.stopJobsStream, values) } func (publisher *Publisher) checkCommon(op string, ctx context.Context, gameID string) error { if publisher == nil || publisher.client == nil { return fmt.Errorf("%s: nil publisher", op) } if ctx == nil { return fmt.Errorf("%s: nil context", op) } if strings.TrimSpace(gameID) == "" { return fmt.Errorf("%s: game id must not be empty", op) } return nil } func (publisher *Publisher) xadd(ctx context.Context, op, stream string, values map[string]any) error { if _, err := publisher.client.XAdd(ctx, &redis.XAddArgs{ Stream: stream, Values: values, }).Result(); err != nil { return fmt.Errorf("%s: xadd: %w", op, err) } return nil } // Compile-time assertion: Publisher implements ports.RuntimeManager. var _ ports.RuntimeManager = (*Publisher)(nil)