feat: runtime manager
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
package stopjobsconsumer_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/rtmanager/internal/domain/operation"
|
||||
"galaxy/rtmanager/internal/domain/runtime"
|
||||
"galaxy/rtmanager/internal/ports"
|
||||
"galaxy/rtmanager/internal/service/startruntime"
|
||||
"galaxy/rtmanager/internal/service/stopruntime"
|
||||
"galaxy/rtmanager/internal/worker/stopjobsconsumer"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func silentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
type fakeStopService struct {
|
||||
mu sync.Mutex
|
||||
inputs []stopruntime.Input
|
||||
result stopruntime.Result
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakeStopService) Handle(_ context.Context, input stopruntime.Input) (stopruntime.Result, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.inputs = append(s.inputs, input)
|
||||
return s.result, s.err
|
||||
}
|
||||
|
||||
func (s *fakeStopService) Inputs() []stopruntime.Input {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]stopruntime.Input, len(s.inputs))
|
||||
copy(out, s.inputs)
|
||||
return out
|
||||
}
|
||||
|
||||
type fakeJobResults struct {
|
||||
mu sync.Mutex
|
||||
published []ports.JobResult
|
||||
publishErr error
|
||||
}
|
||||
|
||||
func (s *fakeJobResults) Publish(_ context.Context, result ports.JobResult) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.publishErr != nil {
|
||||
return s.publishErr
|
||||
}
|
||||
s.published = append(s.published, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeJobResults) Published() []ports.JobResult {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]ports.JobResult, len(s.published))
|
||||
copy(out, s.published)
|
||||
return out
|
||||
}
|
||||
|
||||
type fakeOffsetStore struct {
|
||||
mu sync.Mutex
|
||||
offsets map[string]string
|
||||
}
|
||||
|
||||
func newFakeOffsetStore() *fakeOffsetStore {
|
||||
return &fakeOffsetStore{offsets: map[string]string{}}
|
||||
}
|
||||
|
||||
func (s *fakeOffsetStore) Load(_ context.Context, label string) (string, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
value, ok := s.offsets[label]
|
||||
return value, ok, nil
|
||||
}
|
||||
|
||||
func (s *fakeOffsetStore) Save(_ context.Context, label, entryID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.offsets[label] = entryID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeOffsetStore) Get(label string) (string, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
value, ok := s.offsets[label]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
consumer *stopjobsconsumer.Consumer
|
||||
stops *fakeStopService
|
||||
results *fakeJobResults
|
||||
offsets *fakeOffsetStore
|
||||
stream string
|
||||
server *miniredis.Miniredis
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
stops := &fakeStopService{}
|
||||
results := &fakeJobResults{}
|
||||
offsets := newFakeOffsetStore()
|
||||
stream := "runtime:stop_jobs"
|
||||
|
||||
consumer, err := stopjobsconsumer.NewConsumer(stopjobsconsumer.Config{
|
||||
Client: client,
|
||||
Stream: stream,
|
||||
BlockTimeout: 50 * time.Millisecond,
|
||||
StopService: stops,
|
||||
JobResults: results,
|
||||
OffsetStore: offsets,
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return &harness{
|
||||
consumer: consumer,
|
||||
stops: stops,
|
||||
results: results,
|
||||
offsets: offsets,
|
||||
stream: stream,
|
||||
server: server,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func stopMessage(id, gameID, reason string, requestedAtMS int64) redis.XMessage {
|
||||
return redis.XMessage{
|
||||
ID: id,
|
||||
Values: map[string]any{
|
||||
"game_id": gameID,
|
||||
"reason": reason,
|
||||
"requested_at_ms": strconv.FormatInt(requestedAtMS, 10),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConsumerRejectsMissingDeps(t *testing.T) {
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
cases := []stopjobsconsumer.Config{
|
||||
{},
|
||||
{Client: client},
|
||||
{Client: client, Stream: "runtime:stop_jobs"},
|
||||
{Client: client, Stream: "runtime:stop_jobs", BlockTimeout: time.Second},
|
||||
{Client: client, Stream: "runtime:stop_jobs", BlockTimeout: time.Second, StopService: &fakeStopService{}},
|
||||
{Client: client, Stream: "runtime:stop_jobs", BlockTimeout: time.Second, StopService: &fakeStopService{}, JobResults: &fakeJobResults{}},
|
||||
}
|
||||
for index, cfg := range cases {
|
||||
_, err := stopjobsconsumer.NewConsumer(cfg)
|
||||
require.Errorf(t, err, "case %d should fail", index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageSuccessPublishesSuccessResult(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.stops.result = stopruntime.Result{
|
||||
Record: runtime.RuntimeRecord{
|
||||
GameID: "game-1",
|
||||
Status: runtime.StatusStopped,
|
||||
CurrentContainerID: "c-1",
|
||||
CurrentImageRef: "galaxy/game:1.0.0",
|
||||
EngineEndpoint: "http://galaxy-game-game-1:8080",
|
||||
},
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
}
|
||||
|
||||
h.consumer.HandleMessage(context.Background(), stopMessage("100-0", "game-1", "cancelled", 1700))
|
||||
|
||||
inputs := h.stops.Inputs()
|
||||
require.Len(t, inputs, 1)
|
||||
assert.Equal(t, "game-1", inputs[0].GameID)
|
||||
assert.Equal(t, stopruntime.StopReasonCancelled, inputs[0].Reason)
|
||||
assert.Equal(t, operation.OpSourceLobbyStream, inputs[0].OpSource)
|
||||
assert.Equal(t, "100-0", inputs[0].SourceRef)
|
||||
|
||||
published := h.results.Published()
|
||||
require.Len(t, published, 1)
|
||||
assert.Equal(t, ports.JobResult{
|
||||
GameID: "game-1",
|
||||
Outcome: ports.JobOutcomeSuccess,
|
||||
ContainerID: "c-1",
|
||||
EngineEndpoint: "http://galaxy-game-game-1:8080",
|
||||
}, published[0])
|
||||
}
|
||||
|
||||
func TestHandleMessageFailureNotFoundPublishesFailureResult(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.stops.result = stopruntime.Result{
|
||||
Outcome: operation.OutcomeFailure,
|
||||
ErrorCode: startruntime.ErrorCodeNotFound,
|
||||
ErrorMessage: "runtime record for game \"game-2\" does not exist",
|
||||
}
|
||||
|
||||
h.consumer.HandleMessage(context.Background(), stopMessage("101-0", "game-2", "admin_request", 1700))
|
||||
|
||||
published := h.results.Published()
|
||||
require.Len(t, published, 1)
|
||||
assert.Equal(t, ports.JobResult{
|
||||
GameID: "game-2",
|
||||
Outcome: ports.JobOutcomeFailure,
|
||||
ErrorCode: "not_found",
|
||||
ErrorMessage: "runtime record for game \"game-2\" does not exist",
|
||||
}, published[0])
|
||||
}
|
||||
|
||||
func TestHandleMessageReplayNoOpForRemovedRecordHasEmptyContainerAndEndpoint(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.stops.result = stopruntime.Result{
|
||||
Record: runtime.RuntimeRecord{
|
||||
GameID: "game-3",
|
||||
Status: runtime.StatusRemoved,
|
||||
CurrentContainerID: "",
|
||||
EngineEndpoint: "http://galaxy-game-game-3:8080",
|
||||
},
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
ErrorCode: startruntime.ErrorCodeReplayNoOp,
|
||||
}
|
||||
|
||||
h.consumer.HandleMessage(context.Background(), stopMessage("102-0", "game-3", "finished", 1700))
|
||||
|
||||
published := h.results.Published()
|
||||
require.Len(t, published, 1)
|
||||
assert.Equal(t, ports.JobResult{
|
||||
GameID: "game-3",
|
||||
Outcome: ports.JobOutcomeSuccess,
|
||||
ContainerID: "",
|
||||
EngineEndpoint: "http://galaxy-game-game-3:8080",
|
||||
ErrorCode: "replay_no_op",
|
||||
}, published[0])
|
||||
}
|
||||
|
||||
func TestHandleMessageMalformedEnvelopesAreAbsorbed(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
cases := []redis.XMessage{
|
||||
{ID: "200-0", Values: map[string]any{"reason": "cancelled", "requested_at_ms": "1"}},
|
||||
{ID: "200-1", Values: map[string]any{"game_id": "game-x", "requested_at_ms": "1"}},
|
||||
{ID: "200-2", Values: map[string]any{"game_id": "game-x", "reason": " ", "requested_at_ms": "1"}},
|
||||
{ID: "200-3", Values: map[string]any{"game_id": "game-x", "reason": "not_a_known_reason", "requested_at_ms": "1"}},
|
||||
{ID: "200-4", Values: map[string]any{"game_id": "game-x", "reason": "cancelled", "requested_at_ms": "abc"}},
|
||||
}
|
||||
for _, msg := range cases {
|
||||
h.consumer.HandleMessage(context.Background(), msg)
|
||||
}
|
||||
|
||||
assert.Empty(t, h.stops.Inputs(), "malformed envelopes must not reach the stop service")
|
||||
assert.Empty(t, h.results.Published(), "malformed envelopes must not produce job results")
|
||||
}
|
||||
|
||||
func TestHandleMessagePublishFailureIsAbsorbed(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.stops.result = stopruntime.Result{Outcome: operation.OutcomeFailure, ErrorCode: "internal_error"}
|
||||
h.results.publishErr = errors.New("redis transient")
|
||||
|
||||
h.consumer.HandleMessage(context.Background(), stopMessage("300-0", "game-x", "cancelled", 1700))
|
||||
|
||||
require.Len(t, h.stops.Inputs(), 1, "service still runs even when publish fails")
|
||||
}
|
||||
|
||||
func TestHandleMessageGoLevelErrorIsAbsorbed(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.stops.err = errors.New("nil ctx")
|
||||
|
||||
h.consumer.HandleMessage(context.Background(), stopMessage("400-0", "game-y", "cancelled", 1700))
|
||||
|
||||
assert.Empty(t, h.results.Published(), "go-level service errors must not surface as job results")
|
||||
}
|
||||
|
||||
func TestRunAdvancesOffsetPerMessage(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.stops.result = stopruntime.Result{
|
||||
Record: runtime.RuntimeRecord{
|
||||
GameID: "game-5",
|
||||
Status: runtime.StatusStopped,
|
||||
CurrentContainerID: "c-5",
|
||||
EngineEndpoint: "http://galaxy-game-game-5:8080",
|
||||
},
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- h.consumer.Run(ctx) }()
|
||||
|
||||
mustXAdd(t, h.client, h.stream, "game-5", "cancelled", 1)
|
||||
mustXAdd(t, h.client, h.stream, "game-5", "finished", 2)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(h.results.Published()) == 2
|
||||
}, time.Second, 10*time.Millisecond, "consumer must produce one job result per envelope")
|
||||
|
||||
cancel()
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, time.Second, 10*time.Millisecond, "Run must exit after context cancel")
|
||||
|
||||
id, ok := h.offsets.Get("stopjobs")
|
||||
require.True(t, ok, "offset must be persisted after the run loop processed messages")
|
||||
assert.NotEmpty(t, id, "offset entry id must not be empty")
|
||||
}
|
||||
|
||||
func TestRunExitsImmediatelyOnAlreadyCancelledContext(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := h.consumer.Run(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
assert.Empty(t, h.stops.Inputs())
|
||||
assert.Empty(t, h.results.Published())
|
||||
}
|
||||
|
||||
func mustXAdd(t *testing.T, client *redis.Client, stream, gameID, reason string, requestedAtMS int64) string {
|
||||
t.Helper()
|
||||
id, err := client.XAdd(context.Background(), &redis.XAddArgs{
|
||||
Stream: stream,
|
||||
Values: map[string]any{
|
||||
"game_id": gameID,
|
||||
"reason": reason,
|
||||
"requested_at_ms": strconv.FormatInt(requestedAtMS, 10),
|
||||
},
|
||||
}).Result()
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
Reference in New Issue
Block a user