// Package jobresultspublisher provides the Redis-Streams-backed // publisher for `runtime:job_results`. The start-jobs and stop-jobs // consumers call this adapter so every consumed envelope produces // exactly one outcome entry on the result stream. // // The wire fields mirror the AsyncAPI schema frozen in // `rtmanager/api/runtime-jobs-asyncapi.yaml`. Every field is XADDed // even when empty so consumers can rely on the schema's required-field // set. package jobresultspublisher import ( "context" "errors" "fmt" "strings" "galaxy/rtmanager/internal/ports" "github.com/redis/go-redis/v9" ) // Wire field names used by the Redis Streams payload. Frozen by // `rtmanager/api/runtime-jobs-asyncapi.yaml`; renaming any of them // breaks consumers. const ( fieldGameID = "game_id" fieldOutcome = "outcome" fieldContainerID = "container_id" fieldEngineEndpoint = "engine_endpoint" fieldErrorCode = "error_code" fieldErrorMessage = "error_message" ) // Config groups the dependencies and stream name required to construct // a Publisher. type Config struct { // Client appends entries to the Redis Stream. Must be non-nil. Client *redis.Client // Stream stores the Redis Stream key job results are published to // (e.g. `runtime:job_results`). Must not be empty. Stream string } // Publisher implements `ports.JobResultPublisher` on top of a shared // Redis client. type Publisher struct { client *redis.Client stream string } // NewPublisher constructs one Publisher from cfg. Validation errors // surface the missing collaborator verbatim. func NewPublisher(cfg Config) (*Publisher, error) { if cfg.Client == nil { return nil, errors.New("new rtmanager job results publisher: nil redis client") } if strings.TrimSpace(cfg.Stream) == "" { return nil, errors.New("new rtmanager job results publisher: stream must not be empty") } return &Publisher{ client: cfg.Client, stream: cfg.Stream, }, nil } // Publish XADDs result to the configured Redis Stream. The wire payload // includes every field declared as required by the AsyncAPI schema — // empty strings are kept so consumers always see the documented keys. func (publisher *Publisher) Publish(ctx context.Context, result ports.JobResult) error { if publisher == nil || publisher.client == nil { return errors.New("publish job result: nil publisher") } if ctx == nil { return errors.New("publish job result: nil context") } if err := result.Validate(); err != nil { return fmt.Errorf("publish job result: %w", err) } values := map[string]any{ fieldGameID: result.GameID, fieldOutcome: result.Outcome, fieldContainerID: result.ContainerID, fieldEngineEndpoint: result.EngineEndpoint, fieldErrorCode: result.ErrorCode, fieldErrorMessage: result.ErrorMessage, } if err := publisher.client.XAdd(ctx, &redis.XAddArgs{ Stream: publisher.stream, Values: values, }).Err(); err != nil { return fmt.Errorf("publish job result: xadd: %w", err) } return nil } // Compile-time assertion: Publisher implements ports.JobResultPublisher. var _ ports.JobResultPublisher = (*Publisher)(nil)