feat: runtime manager

This commit is contained in:
Ilia Denisov
2026-04-28 20:39:18 +02:00
committed by GitHub
parent e0a99b346b
commit a7cee15115
289 changed files with 45660 additions and 2207 deletions
@@ -1,4 +1,4 @@
// Package applicationstub provides an in-memory ports.ApplicationStore
// Package applicationinmem provides an in-memory ports.ApplicationStore
// implementation for service-level tests. The stub mirrors the
// behavioural contract of the Redis adapter in redisstate: it enforces
// application.Transition for status updates, the single-active
@@ -8,7 +8,7 @@
// Production code never wires this stub; it is test-only but exposed as
// a regular (non _test.go) package so other service test packages can
// import it.
package applicationstub
package applicationinmem
import (
"context"
@@ -1,7 +1,7 @@
// Package evaluationguardstub provides an in-memory
// Package evaluationguardinmem provides an in-memory
// ports.EvaluationGuardStore used by service-level capability evaluation
// tests. Production code never wires this stub.
package evaluationguardstub
package evaluationguardinmem
import (
"context"
@@ -1,13 +1,13 @@
// Package gamestub provides an in-memory ports.GameStore implementation for
// service-level tests. The stub mirrors the behavioural contract of the
// Redis-backed adapter in redisstate: it enforces game.Transition for status
// updates, the ExpectedFrom CAS check, and the StartedAt/FinishedAt side
// effects of the canonical status transitions.
// Package gameinmem provides an in-memory ports.GameStore implementation
// for service-level tests. It mirrors the behavioural contract of the
// Redis-backed adapter in redisstate: it enforces game.Transition for
// status updates, the ExpectedFrom CAS check, and the
// StartedAt/FinishedAt side effects of the canonical status transitions.
//
// Production code never wires this stub; it is test-only but exposed as a
// regular (non _test.go) package so other service test packages can import
// it.
package gamestub
// Production code never wires this adapter; it is test-only but exposed
// as a regular (non _test.go) package so other service test packages can
// import it.
package gameinmem
import (
"context"
@@ -1,4 +1,4 @@
package gamestub
package gameinmem
import (
"context"
@@ -1,4 +1,4 @@
// Package gameturnstatsstub provides an in-memory ports.GameTurnStatsStore
// Package gameturnstatsinmem provides an in-memory ports.GameTurnStatsStore
// implementation for service-level tests. The stub mirrors the behavioural
// contract of the Redis adapter in redisstate: SaveInitial freezes the
// initial fields on the first call per user, UpdateMax keeps the max fields
@@ -8,7 +8,7 @@
// Production code never wires this stub; it is test-only but exposed as a
// regular (non _test.go) package so downstream service test packages can
// import it.
package gameturnstatsstub
package gameturnstatsinmem
import (
"context"
@@ -1,9 +1,9 @@
// Package gapactivationstub provides an in-memory
// Package gapactivationinmem provides an in-memory
// ports.GapActivationStore implementation for service-level tests. The
// stub records every MarkActivated call and offers WasActivated /
// ActivatedAt accessors so test bodies can assert the gap-window trigger
// fired exactly once.
package gapactivationstub
package gapactivationinmem
import (
"context"
@@ -1,89 +0,0 @@
// Package gmclientstub provides an in-process ports.GMClient
// implementation used by service-level and worker-level tests that do
// not need to spin up an httptest server. The stub records every
// register call and every liveness probe, and supports independent
// error injection for each method so and paths can
// be exercised separately.
//
// Production code never wires this stub.
package gmclientstub
import (
"context"
"errors"
"sync"
"galaxy/lobby/internal/ports"
)
// Client is a concurrency-safe in-memory ports.GMClient.
type Client struct {
mu sync.Mutex
err error
pingErr error
requests []ports.RegisterGameRequest
pingCalls int
}
// NewClient constructs an empty Client.
func NewClient() *Client {
return &Client{}
}
// SetError makes the next RegisterGame calls return err. Passing nil
// clears the override.
func (client *Client) SetError(err error) {
client.mu.Lock()
defer client.mu.Unlock()
client.err = err
}
// SetPingError makes the next Ping calls return err. Passing nil
// clears the override. RegisterGame is unaffected.
func (client *Client) SetPingError(err error) {
client.mu.Lock()
defer client.mu.Unlock()
client.pingErr = err
}
// Requests returns the ordered slice of register requests received.
func (client *Client) Requests() []ports.RegisterGameRequest {
client.mu.Lock()
defer client.mu.Unlock()
return append([]ports.RegisterGameRequest(nil), client.requests...)
}
// PingCalls returns the number of Ping invocations observed so far.
func (client *Client) PingCalls() int {
client.mu.Lock()
defer client.mu.Unlock()
return client.pingCalls
}
// RegisterGame records the request and returns the configured error.
func (client *Client) RegisterGame(ctx context.Context, request ports.RegisterGameRequest) error {
if ctx == nil {
return errors.New("register game: nil context")
}
client.mu.Lock()
defer client.mu.Unlock()
if client.err != nil {
return client.err
}
client.requests = append(client.requests, request)
return nil
}
// Ping increments the call counter and returns the configured error.
func (client *Client) Ping(ctx context.Context) error {
if ctx == nil {
return errors.New("ping: nil context")
}
client.mu.Lock()
defer client.mu.Unlock()
client.pingCalls++
return client.pingErr
}
// Compile-time interface assertion.
var _ ports.GMClient = (*Client)(nil)
@@ -1,79 +0,0 @@
// Package intentpubstub provides an in-process
// ports.IntentPublisher implementation for service-level tests. The
// stub records every Publish call and lets tests inject failures to
// verify that publication errors do not roll back already-committed
// business state.
package intentpubstub
import (
"context"
"errors"
"strconv"
"sync"
"galaxy/lobby/internal/ports"
"galaxy/notificationintent"
)
// Publisher is a concurrency-safe in-memory implementation of
// ports.IntentPublisher. The zero value is not usable; call NewPublisher
// to construct.
type Publisher struct {
mu sync.Mutex
published []notificationintent.Intent
nextID int
err error
}
// NewPublisher constructs an empty Publisher ready for use.
func NewPublisher() *Publisher {
return &Publisher{}
}
// SetError preloads err to be returned by every Publish call. Pass nil
// to reset.
func (publisher *Publisher) SetError(err error) {
if publisher == nil {
return
}
publisher.mu.Lock()
defer publisher.mu.Unlock()
publisher.err = err
}
// Publish records intent and returns a synthetic stream entry id.
func (publisher *Publisher) Publish(ctx context.Context, intent notificationintent.Intent) (string, error) {
if publisher == nil {
return "", errors.New("publish notification intent: nil publisher")
}
if ctx == nil {
return "", errors.New("publish notification intent: nil context")
}
publisher.mu.Lock()
defer publisher.mu.Unlock()
if publisher.err != nil {
return "", publisher.err
}
publisher.nextID++
publisher.published = append(publisher.published, intent)
return strconv.Itoa(publisher.nextID), nil
}
// Published returns a snapshot of every Publish-accepted intent in the
// order it was received.
func (publisher *Publisher) Published() []notificationintent.Intent {
if publisher == nil {
return nil
}
publisher.mu.Lock()
defer publisher.mu.Unlock()
out := make([]notificationintent.Intent, len(publisher.published))
copy(out, publisher.published)
return out
}
// Compile-time interface assertion.
var _ ports.IntentPublisher = (*Publisher)(nil)
@@ -1,4 +1,4 @@
// Package invitestub provides an in-memory ports.InviteStore implementation
// Package inviteinmem provides an in-memory ports.InviteStore implementation
// for service-level tests. The stub mirrors the behavioural contract of the
// Redis adapter in redisstate: Save is create-only, UpdateStatus enforces
// invite.Transition and the ExpectedFrom CAS guard, and the index reads
@@ -6,7 +6,7 @@
//
// Production code never wires this stub; it is test-only but exposed as a
// regular (non _test.go) package so other service test packages can import it.
package invitestub
package inviteinmem
import (
"context"
@@ -1,4 +1,4 @@
// Package membershipstub provides an in-memory ports.MembershipStore
// Package membershipinmem provides an in-memory ports.MembershipStore
// implementation for service-level tests. The stub mirrors the
// behavioural contract of the Redis adapter in redisstate: Save is
// create-only, UpdateStatus enforces membership.Transition and the
@@ -8,7 +8,7 @@
// Production code never wires this stub; it is test-only but exposed as
// a regular (non _test.go) package so other service test packages can
// import it.
package membershipstub
package membershipinmem
import (
"context"
@@ -6,7 +6,7 @@ import (
"time"
"galaxy/lobby/internal/adapters/metricsracenamedir"
"galaxy/lobby/internal/adapters/racenamestub"
"galaxy/lobby/internal/adapters/racenameinmem"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/telemetry"
@@ -28,7 +28,7 @@ func newRuntime(t *testing.T) (*telemetry.Runtime, sdkmetric.Reader) {
func newInner(t *testing.T) ports.RaceNameDirectory {
t.Helper()
stub, err := racenamestub.NewDirectory()
stub, err := racenameinmem.NewDirectory()
require.NoError(t, err)
return stub
}
@@ -0,0 +1,70 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/lobby/internal/ports (interfaces: GMClient)
//
// Generated by this command:
//
// mockgen -destination=../adapters/mocks/mock_gmclient.go -package=mocks galaxy/lobby/internal/ports GMClient
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
ports "galaxy/lobby/internal/ports"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockGMClient is a mock of GMClient interface.
type MockGMClient struct {
ctrl *gomock.Controller
recorder *MockGMClientMockRecorder
isgomock struct{}
}
// MockGMClientMockRecorder is the mock recorder for MockGMClient.
type MockGMClientMockRecorder struct {
mock *MockGMClient
}
// NewMockGMClient creates a new mock instance.
func NewMockGMClient(ctrl *gomock.Controller) *MockGMClient {
mock := &MockGMClient{ctrl: ctrl}
mock.recorder = &MockGMClientMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockGMClient) EXPECT() *MockGMClientMockRecorder {
return m.recorder
}
// Ping mocks base method.
func (m *MockGMClient) Ping(ctx context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Ping", ctx)
ret0, _ := ret[0].(error)
return ret0
}
// Ping indicates an expected call of Ping.
func (mr *MockGMClientMockRecorder) Ping(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ping", reflect.TypeOf((*MockGMClient)(nil).Ping), ctx)
}
// RegisterGame mocks base method.
func (m *MockGMClient) RegisterGame(ctx context.Context, request ports.RegisterGameRequest) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RegisterGame", ctx, request)
ret0, _ := ret[0].(error)
return ret0
}
// RegisterGame indicates an expected call of RegisterGame.
func (mr *MockGMClientMockRecorder) RegisterGame(ctx, request any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterGame", reflect.TypeOf((*MockGMClient)(nil).RegisterGame), ctx, request)
}
@@ -0,0 +1,57 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/lobby/internal/ports (interfaces: IntentPublisher)
//
// Generated by this command:
//
// mockgen -destination=../adapters/mocks/mock_intentpublisher.go -package=mocks galaxy/lobby/internal/ports IntentPublisher
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
notificationintent "galaxy/notificationintent"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockIntentPublisher is a mock of IntentPublisher interface.
type MockIntentPublisher struct {
ctrl *gomock.Controller
recorder *MockIntentPublisherMockRecorder
isgomock struct{}
}
// MockIntentPublisherMockRecorder is the mock recorder for MockIntentPublisher.
type MockIntentPublisherMockRecorder struct {
mock *MockIntentPublisher
}
// NewMockIntentPublisher creates a new mock instance.
func NewMockIntentPublisher(ctrl *gomock.Controller) *MockIntentPublisher {
mock := &MockIntentPublisher{ctrl: ctrl}
mock.recorder = &MockIntentPublisherMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockIntentPublisher) EXPECT() *MockIntentPublisherMockRecorder {
return m.recorder
}
// Publish mocks base method.
func (m *MockIntentPublisher) Publish(ctx context.Context, intent notificationintent.Intent) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Publish", ctx, intent)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Publish indicates an expected call of Publish.
func (mr *MockIntentPublisherMockRecorder) Publish(ctx, intent any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Publish", reflect.TypeOf((*MockIntentPublisher)(nil).Publish), ctx, intent)
}
@@ -0,0 +1,70 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/lobby/internal/ports (interfaces: RuntimeManager)
//
// Generated by this command:
//
// mockgen -destination=../adapters/mocks/mock_runtimemanager.go -package=mocks galaxy/lobby/internal/ports RuntimeManager
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
ports "galaxy/lobby/internal/ports"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockRuntimeManager is a mock of RuntimeManager interface.
type MockRuntimeManager struct {
ctrl *gomock.Controller
recorder *MockRuntimeManagerMockRecorder
isgomock struct{}
}
// MockRuntimeManagerMockRecorder is the mock recorder for MockRuntimeManager.
type MockRuntimeManagerMockRecorder struct {
mock *MockRuntimeManager
}
// NewMockRuntimeManager creates a new mock instance.
func NewMockRuntimeManager(ctrl *gomock.Controller) *MockRuntimeManager {
mock := &MockRuntimeManager{ctrl: ctrl}
mock.recorder = &MockRuntimeManagerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockRuntimeManager) EXPECT() *MockRuntimeManagerMockRecorder {
return m.recorder
}
// PublishStartJob mocks base method.
func (m *MockRuntimeManager) PublishStartJob(ctx context.Context, gameID, imageRef string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PublishStartJob", ctx, gameID, imageRef)
ret0, _ := ret[0].(error)
return ret0
}
// PublishStartJob indicates an expected call of PublishStartJob.
func (mr *MockRuntimeManagerMockRecorder) PublishStartJob(ctx, gameID, imageRef any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishStartJob", reflect.TypeOf((*MockRuntimeManager)(nil).PublishStartJob), ctx, gameID, imageRef)
}
// PublishStopJob mocks base method.
func (m *MockRuntimeManager) PublishStopJob(ctx context.Context, gameID string, reason ports.StopReason) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PublishStopJob", ctx, gameID, reason)
ret0, _ := ret[0].(error)
return ret0
}
// PublishStopJob indicates an expected call of PublishStopJob.
func (mr *MockRuntimeManagerMockRecorder) PublishStopJob(ctx, gameID, reason any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishStopJob", reflect.TypeOf((*MockRuntimeManager)(nil).PublishStopJob), ctx, gameID, reason)
}
@@ -0,0 +1,57 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/lobby/internal/ports (interfaces: UserService)
//
// Generated by this command:
//
// mockgen -destination=../adapters/mocks/mock_userservice.go -package=mocks galaxy/lobby/internal/ports UserService
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
ports "galaxy/lobby/internal/ports"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockUserService is a mock of UserService interface.
type MockUserService struct {
ctrl *gomock.Controller
recorder *MockUserServiceMockRecorder
isgomock struct{}
}
// MockUserServiceMockRecorder is the mock recorder for MockUserService.
type MockUserServiceMockRecorder struct {
mock *MockUserService
}
// NewMockUserService creates a new mock instance.
func NewMockUserService(ctrl *gomock.Controller) *MockUserService {
mock := &MockUserService{ctrl: ctrl}
mock.recorder = &MockUserServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockUserService) EXPECT() *MockUserServiceMockRecorder {
return m.recorder
}
// GetEligibility mocks base method.
func (m *MockUserService) GetEligibility(ctx context.Context, userID string) (ports.Eligibility, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetEligibility", ctx, userID)
ret0, _ := ret[0].(ports.Eligibility)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetEligibility indicates an expected call of GetEligibility.
func (mr *MockUserServiceMockRecorder) GetEligibility(ctx, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEligibility", reflect.TypeOf((*MockUserService)(nil).GetEligibility), ctx, userID)
}
@@ -1,10 +1,13 @@
// Package racenamestub provides the in-process implementation of the
// ports.RaceNameDirectory contract used by unit tests that do not need
// a Redis dependency. The stub enforces the full two-tier Race Name
// Directory invariants (registered, reservation, pending_registration)
// across the lifetime of one process, and is interchangeable with the
// Redis adapter under the same shared behavioural test suite.
package racenamestub
// Package racenameinmem provides the in-process implementation of the
// ports.RaceNameDirectory contract. It is used both by unit tests that
// do not need a Redis dependency and by deployments that select the
// in-memory backend via LOBBY_RACE_NAME_DIRECTORY_BACKEND=stub. It
// enforces the full two-tier Race Name Directory invariants
// (registered, reservation, pending_registration) across the lifetime
// of one process, and is interchangeable with the PostgreSQL adapter
// under the shared behavioural test suite at
// galaxy/lobby/internal/ports/racenamedirtest.
package racenameinmem
import (
"context"
@@ -1,4 +1,4 @@
package racenamestub_test
package racenameinmem_test
import (
"context"
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"galaxy/lobby/internal/adapters/racenamestub"
"galaxy/lobby/internal/adapters/racenameinmem"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/ports/racenamedirtest"
@@ -19,11 +19,11 @@ import (
func TestDirectoryContract(t *testing.T) {
racenamedirtest.Run(t, func(now func() time.Time) ports.RaceNameDirectory {
var opts []racenamestub.Option
var opts []racenameinmem.Option
if now != nil {
opts = append(opts, racenamestub.WithClock(now))
opts = append(opts, racenameinmem.WithClock(now))
}
directory, err := racenamestub.NewDirectory(opts...)
directory, err := racenameinmem.NewDirectory(opts...)
require.NoError(t, err)
return directory
})
@@ -37,7 +37,7 @@ func TestReserveConcurrentUniquenessInvariant(t *testing.T) {
const gameID = "game-concurrency"
ctx := context.Background()
directory, err := racenamestub.NewDirectory()
directory, err := racenameinmem.NewDirectory()
require.NoError(t, err)
var (
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"galaxy/lobby/internal/adapters/intentpubstub"
"galaxy/lobby/internal/adapters/mocks"
"galaxy/lobby/internal/adapters/racenameintents"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/service/capabilityevaluation"
@@ -14,13 +14,26 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
func captureIntents(t *testing.T) (*mocks.MockIntentPublisher, *[]notificationintent.Intent) {
t.Helper()
publisher := mocks.NewMockIntentPublisher(gomock.NewController(t))
var captured []notificationintent.Intent
publisher.EXPECT().Publish(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, intent notificationintent.Intent) (string, error) {
captured = append(captured, intent)
return "1", nil
}).AnyTimes()
return publisher, &captured
}
func TestPublisherEligibleProducesExpectedIntent(t *testing.T) {
t.Parallel()
stub := intentpubstub.NewPublisher()
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: stub})
mock, captured := captureIntents(t)
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: mock})
require.NoError(t, err)
finishedAt := time.UnixMilli(1775121700000).UTC()
@@ -34,9 +47,8 @@ func TestPublisherEligibleProducesExpectedIntent(t *testing.T) {
FinishedAt: finishedAt,
}))
published := stub.Published()
require.Len(t, published, 1)
intent := published[0]
require.Len(t, *captured, 1)
intent := (*captured)[0]
assert.Equal(t, notificationintent.NotificationTypeLobbyRaceNameRegistrationEligible, intent.NotificationType)
assert.Equal(t, notificationintent.ProducerGameLobby, intent.Producer)
assert.Equal(t, notificationintent.AudienceKindUser, intent.AudienceKind)
@@ -53,8 +65,8 @@ func TestPublisherEligibleProducesExpectedIntent(t *testing.T) {
func TestPublisherDeniedProducesExpectedIntent(t *testing.T) {
t.Parallel()
stub := intentpubstub.NewPublisher()
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: stub})
mock, captured := captureIntents(t)
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: mock})
require.NoError(t, err)
finishedAt := time.UnixMilli(1775121700000).UTC()
@@ -67,9 +79,8 @@ func TestPublisherDeniedProducesExpectedIntent(t *testing.T) {
Reason: capabilityevaluation.ReasonCapabilityNotMet,
}))
published := stub.Published()
require.Len(t, published, 1)
intent := published[0]
require.Len(t, *captured, 1)
intent := (*captured)[0]
assert.Equal(t, notificationintent.NotificationTypeLobbyRaceNameRegistrationDenied, intent.NotificationType)
assert.Equal(t, notificationintent.ProducerGameLobby, intent.Producer)
assert.Equal(t, notificationintent.AudienceKindUser, intent.AudienceKind)
@@ -86,9 +97,10 @@ func TestPublisherDeniedProducesExpectedIntent(t *testing.T) {
func TestPublisherSurfacesPublisherError(t *testing.T) {
t.Parallel()
stub := intentpubstub.NewPublisher()
stub.SetError(errors.New("transport unavailable"))
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: stub})
mock := mocks.NewMockIntentPublisher(gomock.NewController(t))
mock.EXPECT().Publish(gomock.Any(), gomock.Any()).
Return("", errors.New("transport unavailable")).Times(1)
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: mock})
require.NoError(t, err)
finishedAt := time.UnixMilli(1775121700000).UTC()
@@ -6,6 +6,15 @@
// 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 (
@@ -75,20 +84,45 @@ func NewPublisher(cfg Config) (*Publisher, error) {
}, 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)
// 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 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)
// 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) publish(ctx context.Context, op, stream, gameID string) error {
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)
}
@@ -98,11 +132,10 @@ func (publisher *Publisher) publish(ctx context.Context, op, stream, gameID stri
if strings.TrimSpace(gameID) == "" {
return fmt.Errorf("%s: game id must not be empty", op)
}
return nil
}
values := map[string]any{
"game_id": gameID,
"requested_at_ms": publisher.clock().UTC().UnixMilli(),
}
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,
@@ -7,6 +7,7 @@ import (
"time"
"galaxy/lobby/internal/adapters/runtimemanager"
"galaxy/lobby/internal/ports"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
@@ -60,12 +61,13 @@ func TestPublishStartJobAppendsToStartStream(t *testing.T) {
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
publisher, _, client := newTestPublisher(t, func() time.Time { return now })
require.NoError(t, publisher.PublishStartJob(context.Background(), "game-1"))
require.NoError(t, publisher.PublishStartJob(context.Background(), "game-1", "galaxy/game:v1.0.0"))
entries, err := client.XRange(context.Background(), "runtime:start_jobs", "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "game-1", entries[0].Values["game_id"])
assert.Equal(t, "galaxy/game:v1.0.0", entries[0].Values["image_ref"])
assert.Equal(t, strconv.FormatInt(now.UnixMilli(), 10), entries[0].Values["requested_at_ms"])
stop, err := client.XLen(context.Background(), "runtime:stop_jobs").Result()
@@ -73,16 +75,29 @@ func TestPublishStartJobAppendsToStartStream(t *testing.T) {
assert.Equal(t, int64(0), stop, "stop stream must remain empty")
}
func TestPublisherStartJobIncludesImageRef(t *testing.T) {
publisher, _, client := newTestPublisher(t, nil)
require.NoError(t, publisher.PublishStartJob(context.Background(), "game-1", "registry.example.com/galaxy/game:v1.4.7"))
entries, err := client.XRange(context.Background(), "runtime:start_jobs", "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "registry.example.com/galaxy/game:v1.4.7", entries[0].Values["image_ref"],
"image_ref field must be present in the start envelope")
}
func TestPublishStopJobAppendsToStopStream(t *testing.T) {
now := time.Date(2026, 4, 25, 13, 0, 0, 0, time.UTC)
publisher, _, client := newTestPublisher(t, func() time.Time { return now })
require.NoError(t, publisher.PublishStopJob(context.Background(), "game-2"))
require.NoError(t, publisher.PublishStopJob(context.Background(), "game-2", ports.StopReasonOrphanCleanup))
entries, err := client.XRange(context.Background(), "runtime:stop_jobs", "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "game-2", entries[0].Values["game_id"])
assert.Equal(t, "orphan_cleanup", entries[0].Values["reason"])
assert.Equal(t, strconv.FormatInt(now.UnixMilli(), 10), entries[0].Values["requested_at_ms"])
startLen, err := client.XLen(context.Background(), "runtime:start_jobs").Result()
@@ -90,18 +105,44 @@ func TestPublishStopJobAppendsToStopStream(t *testing.T) {
assert.Equal(t, int64(0), startLen, "start stream must remain empty")
}
func TestPublisherStopJobIncludesReason(t *testing.T) {
publisher, _, client := newTestPublisher(t, nil)
require.NoError(t, publisher.PublishStopJob(context.Background(), "game-2", ports.StopReasonCancelled))
entries, err := client.XRange(context.Background(), "runtime:stop_jobs", "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "cancelled", entries[0].Values["reason"],
"reason field must be present in the stop envelope")
}
func TestPublishRejectsEmptyGameID(t *testing.T) {
publisher, _, _ := newTestPublisher(t, nil)
require.Error(t, publisher.PublishStartJob(context.Background(), ""))
require.Error(t, publisher.PublishStopJob(context.Background(), " "))
require.Error(t, publisher.PublishStartJob(context.Background(), "", "galaxy/game:v1.0.0"))
require.Error(t, publisher.PublishStopJob(context.Background(), " ", ports.StopReasonCancelled))
}
func TestPublishStartJobRejectsEmptyImageRef(t *testing.T) {
publisher, _, _ := newTestPublisher(t, nil)
require.Error(t, publisher.PublishStartJob(context.Background(), "game-1", ""))
require.Error(t, publisher.PublishStartJob(context.Background(), "game-1", " "))
}
func TestPublishStopJobRejectsUnknownReason(t *testing.T) {
publisher, _, _ := newTestPublisher(t, nil)
require.Error(t, publisher.PublishStopJob(context.Background(), "game-1", ports.StopReason("")))
require.Error(t, publisher.PublishStopJob(context.Background(), "game-1", ports.StopReason("unknown_reason")))
}
func TestPublishRejectsNilContext(t *testing.T) {
publisher, _, _ := newTestPublisher(t, nil)
require.Error(t, publisher.PublishStartJob(nilContext(), "game-1"))
require.Error(t, publisher.PublishStopJob(nilContext(), "game-1"))
require.Error(t, publisher.PublishStartJob(nilContext(), "game-1", "galaxy/game:v1.0.0"))
require.Error(t, publisher.PublishStopJob(nilContext(), "game-1", ports.StopReasonCancelled))
}
// nilContext returns an explicit untyped nil to exercise the defensive
@@ -1,92 +0,0 @@
// Package runtimemanagerstub provides an in-process ports.RuntimeManager
// implementation used by service-level and worker-level tests that do
// not need a real Redis connection. The stub records every published
// job and supports inject-on-error to simulate stream failures.
//
// Production code never wires this stub.
package runtimemanagerstub
import (
"context"
"errors"
"sync"
"galaxy/lobby/internal/ports"
)
// Publisher is a concurrency-safe in-memory ports.RuntimeManager.
type Publisher struct {
mu sync.Mutex
startErr error
stopErr error
startJobs []string
stopJobs []string
}
// NewPublisher constructs an empty Publisher.
func NewPublisher() *Publisher {
return &Publisher{}
}
// SetStartError makes the next PublishStartJob calls return err.
// Passing nil clears the override.
func (publisher *Publisher) SetStartError(err error) {
publisher.mu.Lock()
defer publisher.mu.Unlock()
publisher.startErr = err
}
// SetStopError makes the next PublishStopJob calls return err.
// Passing nil clears the override.
func (publisher *Publisher) SetStopError(err error) {
publisher.mu.Lock()
defer publisher.mu.Unlock()
publisher.stopErr = err
}
// StartJobs returns the ordered slice of game ids passed to
// PublishStartJob.
func (publisher *Publisher) StartJobs() []string {
publisher.mu.Lock()
defer publisher.mu.Unlock()
return append([]string(nil), publisher.startJobs...)
}
// StopJobs returns the ordered slice of game ids passed to
// PublishStopJob.
func (publisher *Publisher) StopJobs() []string {
publisher.mu.Lock()
defer publisher.mu.Unlock()
return append([]string(nil), publisher.stopJobs...)
}
// PublishStartJob records gameID and returns the configured error.
func (publisher *Publisher) PublishStartJob(ctx context.Context, gameID string) error {
if ctx == nil {
return errors.New("publish start job: nil context")
}
publisher.mu.Lock()
defer publisher.mu.Unlock()
if publisher.startErr != nil {
return publisher.startErr
}
publisher.startJobs = append(publisher.startJobs, gameID)
return nil
}
// PublishStopJob records gameID and returns the configured error.
func (publisher *Publisher) PublishStopJob(ctx context.Context, gameID string) error {
if ctx == nil {
return errors.New("publish stop job: nil context")
}
publisher.mu.Lock()
defer publisher.mu.Unlock()
if publisher.stopErr != nil {
return publisher.stopErr
}
publisher.stopJobs = append(publisher.stopJobs, gameID)
return nil
}
// Compile-time interface assertion.
var _ ports.RuntimeManager = (*Publisher)(nil)
@@ -1,61 +0,0 @@
// Package streamlagprobestub provides an in-memory ports.StreamLagProbe
// implementation for tests that do not need a Redis instance. Production
// code never wires this stub.
package streamlagprobestub
import (
"context"
"sync"
"time"
"galaxy/lobby/internal/ports"
)
// Probe is a concurrency-safe in-memory ports.StreamLagProbe. The zero
// value reports `(0, false, nil)` for every stream until Set is called.
type Probe struct {
mu sync.Mutex
results map[string]Result
fallback Result
}
// Result stores the value the probe reports for a stream.
type Result struct {
Age time.Duration
Found bool
Err error
}
// NewProbe constructs one Probe with no preconfigured results.
func NewProbe() *Probe {
return &Probe{results: make(map[string]Result)}
}
// Set installs the result the probe will return for stream.
func (probe *Probe) Set(stream string, result Result) {
probe.mu.Lock()
defer probe.mu.Unlock()
probe.results[stream] = result
}
// SetFallback installs the result returned when no per-stream result is
// configured.
func (probe *Probe) SetFallback(result Result) {
probe.mu.Lock()
defer probe.mu.Unlock()
probe.fallback = result
}
// OldestUnprocessedAge satisfies ports.StreamLagProbe.
func (probe *Probe) OldestUnprocessedAge(_ context.Context, stream, _ string) (time.Duration, bool, error) {
probe.mu.Lock()
defer probe.mu.Unlock()
if result, ok := probe.results[stream]; ok {
return result.Age, result.Found, result.Err
}
return probe.fallback.Age, probe.fallback.Found, probe.fallback.Err
}
// Compile-time interface assertion.
var _ ports.StreamLagProbe = (*Probe)(nil)
@@ -1,7 +1,7 @@
// Package streamoffsetstub provides an in-process ports.StreamOffsetStore
// Package streamoffsetinmem provides an in-process ports.StreamOffsetStore
// used by worker-level tests that do not need Redis. Production code
// never wires this stub.
package streamoffsetstub
package streamoffsetinmem
import (
"context"
@@ -10,7 +10,7 @@ import (
"testing"
"time"
"galaxy/lobby/internal/adapters/streamoffsetstub"
"galaxy/lobby/internal/adapters/streamoffsetinmem"
"galaxy/lobby/internal/adapters/userlifecycle"
"galaxy/lobby/internal/ports"
@@ -33,7 +33,7 @@ func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discar
type harness struct {
server *miniredis.Miniredis
client *redis.Client
offsets *streamoffsetstub.Store
offsets *streamoffsetinmem.Store
consumer *userlifecycle.Consumer
}
@@ -43,7 +43,7 @@ func newHarness(t *testing.T) *harness {
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { _ = client.Close() })
offsets := streamoffsetstub.NewStore()
offsets := streamoffsetinmem.NewStore()
consumer, err := userlifecycle.NewConsumer(userlifecycle.Config{
Client: client,
Stream: testStream,
@@ -70,21 +70,21 @@ func TestNewConsumerRejectsMissingDeps(t *testing.T) {
_, err := userlifecycle.NewConsumer(userlifecycle.Config{
Stream: testStream,
BlockTimeout: time.Second,
OffsetStore: streamoffsetstub.NewStore(),
OffsetStore: streamoffsetinmem.NewStore(),
})
require.Error(t, err)
_, err = userlifecycle.NewConsumer(userlifecycle.Config{
Client: client,
BlockTimeout: time.Second,
OffsetStore: streamoffsetstub.NewStore(),
OffsetStore: streamoffsetinmem.NewStore(),
})
require.Error(t, err)
_, err = userlifecycle.NewConsumer(userlifecycle.Config{
Client: client,
Stream: testStream,
OffsetStore: streamoffsetstub.NewStore(),
OffsetStore: streamoffsetinmem.NewStore(),
})
require.Error(t, err)
@@ -1,79 +0,0 @@
// Package userlifecyclestub provides an in-process
// ports.UserLifecycleConsumer used by worker-level tests that do not
// need a real Redis stream. Production code never wires this stub.
package userlifecyclestub
import (
"context"
"errors"
"sync"
"galaxy/lobby/internal/ports"
)
// Consumer is an in-memory ports.UserLifecycleConsumer. Tests publish
// events synchronously through Deliver and observe handler errors via
// the returned value.
type Consumer struct {
mu sync.Mutex
handler ports.UserLifecycleHandler
}
// NewConsumer constructs an empty Consumer.
func NewConsumer() *Consumer {
return &Consumer{}
}
// OnEvent installs handler as the dispatch target. A second call
// replaces the previous handler.
func (consumer *Consumer) OnEvent(handler ports.UserLifecycleHandler) {
if consumer == nil {
return
}
consumer.mu.Lock()
consumer.handler = handler
consumer.mu.Unlock()
}
// Run blocks until ctx is cancelled. The stub does not pull events from
// any backend; test code drives delivery via Deliver.
func (consumer *Consumer) Run(ctx context.Context) error {
if consumer == nil {
return errors.New("run user lifecycle stub: nil consumer")
}
if ctx == nil {
return errors.New("run user lifecycle stub: nil context")
}
<-ctx.Done()
return ctx.Err()
}
// Shutdown is a no-op.
func (consumer *Consumer) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown user lifecycle stub: nil context")
}
return nil
}
// Deliver dispatches event to the registered handler synchronously and
// returns the handler's error. It is the test-only entry point used by
// worker_test fixtures.
func (consumer *Consumer) Deliver(ctx context.Context, event ports.UserLifecycleEvent) error {
if consumer == nil {
return errors.New("deliver user lifecycle stub: nil consumer")
}
if ctx == nil {
return errors.New("deliver user lifecycle stub: nil context")
}
consumer.mu.Lock()
handler := consumer.handler
consumer.mu.Unlock()
if handler == nil {
return errors.New("deliver user lifecycle stub: no handler registered")
}
return handler(ctx, event)
}
// Compile-time assertion: Consumer satisfies the port interface.
var _ ports.UserLifecycleConsumer = (*Consumer)(nil)
@@ -1,107 +0,0 @@
// Package userservicestub provides an in-process
// ports.UserService implementation for service-level tests. The stub
// stores per-user Eligibility values and lets tests inject errors for
// specific user ids to exercise the unavailable / decode-failure paths.
package userservicestub
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"galaxy/lobby/internal/ports"
)
// Service is a concurrency-safe in-memory implementation of
// ports.UserService. The zero value is not usable; call NewService to
// construct.
type Service struct {
mu sync.Mutex
eligibilities map[string]ports.Eligibility
failures map[string]error
defaultMissing bool
}
// NewService constructs an empty Service with no preloaded
// eligibilities. By default an unknown user maps to
// Eligibility{Exists:false}, mirroring the production HTTP client's
// 404 handling. Use WithDefaultUnavailable to flip the unknown-user
// behaviour to a transport failure.
func NewService(opts ...Option) *Service {
service := &Service{
eligibilities: make(map[string]ports.Eligibility),
failures: make(map[string]error),
}
for _, opt := range opts {
opt(service)
}
return service
}
// Option tunes Service construction.
type Option func(*Service)
// WithDefaultUnavailable makes the stub return ErrUserServiceUnavailable
// for any user id without a preloaded eligibility or failure entry.
// Useful for tests that exercise the "User Service down" path without
// having to enumerate every caller.
func WithDefaultUnavailable() Option {
return func(service *Service) {
service.defaultMissing = true
}
}
// SetEligibility preloads eligibility for userID. Subsequent calls
// overwrite the prior value.
func (service *Service) SetEligibility(userID string, eligibility ports.Eligibility) {
if service == nil {
return
}
service.mu.Lock()
defer service.mu.Unlock()
service.eligibilities[strings.TrimSpace(userID)] = eligibility
}
// SetFailure preloads err to be returned for userID. err takes
// precedence over any preloaded eligibility.
func (service *Service) SetFailure(userID string, err error) {
if service == nil {
return
}
service.mu.Lock()
defer service.mu.Unlock()
service.failures[strings.TrimSpace(userID)] = err
}
// GetEligibility returns the preloaded eligibility for userID.
func (service *Service) GetEligibility(ctx context.Context, userID string) (ports.Eligibility, error) {
if service == nil {
return ports.Eligibility{}, errors.New("get eligibility: nil service")
}
if ctx == nil {
return ports.Eligibility{}, errors.New("get eligibility: nil context")
}
trimmed := strings.TrimSpace(userID)
if trimmed == "" {
return ports.Eligibility{}, errors.New("get eligibility: user id must not be empty")
}
service.mu.Lock()
defer service.mu.Unlock()
if err, ok := service.failures[trimmed]; ok {
return ports.Eligibility{}, err
}
if eligibility, ok := service.eligibilities[trimmed]; ok {
return eligibility, nil
}
if service.defaultMissing {
return ports.Eligibility{}, fmt.Errorf("get eligibility: %w", ports.ErrUserServiceUnavailable)
}
return ports.Eligibility{Exists: false}, nil
}
// Compile-time interface assertion.
var _ ports.UserService = (*Service)(nil)