// 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. 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 to the // configured start-jobs stream. func (publisher *Publisher) PublishStartJob(ctx context.Context, gameID string) error { return publisher.publish(ctx, "publish start job", publisher.startJobsStream, gameID) } // PublishStopJob appends one stop-job event for gameID to the configured // stop-jobs stream. In Lobby publishes stop jobs only from the // orphan-container path inside the runtimejobresult worker. func (publisher *Publisher) PublishStopJob(ctx context.Context, gameID string) error { return publisher.publish(ctx, "publish stop job", publisher.stopJobsStream, gameID) } func (publisher *Publisher) publish(ctx context.Context, op, stream, 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) } values := map[string]any{ "game_id": gameID, "requested_at_ms": publisher.clock().UTC().UnixMilli(), } 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)