feat: runtime manager
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// 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)
|
||||
@@ -0,0 +1,142 @@
|
||||
package jobresultspublisher_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"galaxy/rtmanager/internal/adapters/jobresultspublisher"
|
||||
"galaxy/rtmanager/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newPublisher(t *testing.T) (*jobresultspublisher.Publisher, *redis.Client) {
|
||||
t.Helper()
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
publisher, err := jobresultspublisher.NewPublisher(jobresultspublisher.Config{
|
||||
Client: client,
|
||||
Stream: "runtime:job_results",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return publisher, client
|
||||
}
|
||||
|
||||
func TestNewPublisherRejectsMissingCollaborators(t *testing.T) {
|
||||
_, err := jobresultspublisher.NewPublisher(jobresultspublisher.Config{})
|
||||
require.Error(t, err)
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
_, err = jobresultspublisher.NewPublisher(jobresultspublisher.Config{Client: client})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = jobresultspublisher.NewPublisher(jobresultspublisher.Config{Client: client, Stream: " "})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPublishRejectsInvalidResult(t *testing.T) {
|
||||
publisher, _ := newPublisher(t)
|
||||
|
||||
require.Error(t, publisher.Publish(context.Background(), ports.JobResult{}))
|
||||
require.Error(t, publisher.Publish(context.Background(), ports.JobResult{
|
||||
GameID: "game-1",
|
||||
Outcome: "weird",
|
||||
}))
|
||||
}
|
||||
|
||||
func TestPublishStartSuccessXAddsAllRequiredFields(t *testing.T) {
|
||||
publisher, client := newPublisher(t)
|
||||
|
||||
result := ports.JobResult{
|
||||
GameID: "game-1",
|
||||
Outcome: ports.JobOutcomeSuccess,
|
||||
ContainerID: "c-1",
|
||||
EngineEndpoint: "http://galaxy-game-game-1:8080",
|
||||
ErrorCode: "",
|
||||
ErrorMessage: "",
|
||||
}
|
||||
require.NoError(t, publisher.Publish(context.Background(), result))
|
||||
|
||||
entries, err := client.XRange(context.Background(), "runtime:job_results", "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
values := entries[0].Values
|
||||
assert.Equal(t, "game-1", values["game_id"])
|
||||
assert.Equal(t, "success", values["outcome"])
|
||||
assert.Equal(t, "c-1", values["container_id"])
|
||||
assert.Equal(t, "http://galaxy-game-game-1:8080", values["engine_endpoint"])
|
||||
assert.Equal(t, "", values["error_code"])
|
||||
assert.Equal(t, "", values["error_message"])
|
||||
}
|
||||
|
||||
func TestPublishFailureXAddsEmptyContainerAndEndpoint(t *testing.T) {
|
||||
publisher, client := newPublisher(t)
|
||||
|
||||
result := ports.JobResult{
|
||||
GameID: "game-2",
|
||||
Outcome: ports.JobOutcomeFailure,
|
||||
ErrorCode: "image_pull_failed",
|
||||
ErrorMessage: "manifest unknown",
|
||||
}
|
||||
require.NoError(t, publisher.Publish(context.Background(), result))
|
||||
|
||||
entries, err := client.XRange(context.Background(), "runtime:job_results", "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
values := entries[0].Values
|
||||
assert.Equal(t, "game-2", values["game_id"])
|
||||
assert.Equal(t, "failure", values["outcome"])
|
||||
assert.Equal(t, "", values["container_id"], "failure must publish empty container id")
|
||||
assert.Equal(t, "", values["engine_endpoint"], "failure must publish empty engine endpoint")
|
||||
assert.Equal(t, "image_pull_failed", values["error_code"])
|
||||
assert.Equal(t, "manifest unknown", values["error_message"])
|
||||
}
|
||||
|
||||
func TestPublishReplayNoOpKeepsContainerAndEndpoint(t *testing.T) {
|
||||
publisher, client := newPublisher(t)
|
||||
|
||||
result := ports.JobResult{
|
||||
GameID: "game-3",
|
||||
Outcome: ports.JobOutcomeSuccess,
|
||||
ContainerID: "c-3",
|
||||
EngineEndpoint: "http://galaxy-game-game-3:8080",
|
||||
ErrorCode: "replay_no_op",
|
||||
}
|
||||
require.NoError(t, publisher.Publish(context.Background(), result))
|
||||
|
||||
entries, err := client.XRange(context.Background(), "runtime:job_results", "-", "+").Result()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
values := entries[0].Values
|
||||
assert.Equal(t, "game-3", values["game_id"])
|
||||
assert.Equal(t, "success", values["outcome"])
|
||||
assert.Equal(t, "c-3", values["container_id"])
|
||||
assert.Equal(t, "http://galaxy-game-game-3:8080", values["engine_endpoint"])
|
||||
assert.Equal(t, "replay_no_op", values["error_code"])
|
||||
assert.Equal(t, "", values["error_message"])
|
||||
}
|
||||
|
||||
func TestPublishFailsOnClosedClient(t *testing.T) {
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
publisher, err := jobresultspublisher.NewPublisher(jobresultspublisher.Config{
|
||||
Client: client,
|
||||
Stream: "runtime:job_results",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, client.Close())
|
||||
|
||||
err = publisher.Publish(context.Background(), ports.JobResult{
|
||||
GameID: "game-4",
|
||||
Outcome: ports.JobOutcomeSuccess,
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user