feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
@@ -0,0 +1,38 @@
// Package redisstate hosts the Game Master Redis adapters that share a
// single keyspace. The sole sibling subpackage in v1 is
// `streamoffsets` (the per-consumer offset for the
// runtime:health_events stream); membership cache lives in process and
// does not touch Redis.
//
// The package itself only declares the keyspace; concrete stores live
// in nested packages so dependencies (miniredis, testcontainers) stay
// out of consumer build graphs that do not need them.
package redisstate
import "encoding/base64"
// defaultPrefix is the mandatory `gamemaster:` namespace prefix shared
// by every Game Master Redis key.
const defaultPrefix = "gamemaster:"
// Keyspace builds the Game Master Redis keys. The namespace covers
// stream consumer offsets in v1.
//
// Dynamic key segments are encoded with base64url so raw key structure
// does not depend on caller-provided characters; this matches the
// encoding chosen by `lobby/internal/adapters/redisstate.Keyspace` and
// `rtmanager/internal/adapters/redisstate.Keyspace`.
type Keyspace struct{}
// StreamOffset returns the Redis key that stores the last successfully
// processed entry id for one Redis Stream consumer. The streamLabel is
// the short logical identifier of the consumer (e.g. `health_events`),
// not the full stream name; it stays stable when the underlying stream
// key is renamed.
func (Keyspace) StreamOffset(streamLabel string) string {
return defaultPrefix + "stream_offsets:" + encodeKeyComponent(streamLabel)
}
func encodeKeyComponent(value string) string {
return base64.RawURLEncoding.EncodeToString([]byte(value))
}
@@ -0,0 +1,94 @@
// Package streamoffsets implements the Redis-backed adapter for
// `ports.StreamOffsetStore`.
//
// In v1 the only consumer that calls Load/Save is the
// runtime:health_events worker (PLAN stage 18). Keys are produced by
// `redisstate.Keyspace.StreamOffset`, mirroring the lobby and rtmanager
// patterns.
package streamoffsets
import (
"context"
"errors"
"fmt"
"strings"
"galaxy/gamemaster/internal/adapters/redisstate"
"galaxy/gamemaster/internal/ports"
"github.com/redis/go-redis/v9"
)
// Config configures one Redis-backed stream-offset store. The store
// does not own the redis client lifecycle; the caller (typically the
// service runtime) opens and closes it.
type Config struct {
Client *redis.Client
}
// Store persists Game Master stream consumer offsets in Redis.
type Store struct {
client *redis.Client
keys redisstate.Keyspace
}
// New constructs one Redis-backed stream-offset store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.Client == nil {
return nil, errors.New("new gamemaster stream offset store: nil redis client")
}
return &Store{
client: cfg.Client,
keys: redisstate.Keyspace{},
}, nil
}
// Load returns the last processed entry id for streamLabel when one
// is stored. A missing key returns ("", false, nil).
func (store *Store) Load(ctx context.Context, streamLabel string) (string, bool, error) {
if store == nil || store.client == nil {
return "", false, errors.New("load gamemaster stream offset: nil store")
}
if ctx == nil {
return "", false, errors.New("load gamemaster stream offset: nil context")
}
if strings.TrimSpace(streamLabel) == "" {
return "", false, errors.New("load gamemaster stream offset: stream label must not be empty")
}
value, err := store.client.Get(ctx, store.keys.StreamOffset(streamLabel)).Result()
switch {
case errors.Is(err, redis.Nil):
return "", false, nil
case err != nil:
return "", false, fmt.Errorf("load gamemaster stream offset: %w", err)
}
return value, true, nil
}
// Save stores entryID as the new offset for streamLabel. The key has
// no TTL — offsets are durable and only overwritten by subsequent
// Saves.
func (store *Store) Save(ctx context.Context, streamLabel, entryID string) error {
if store == nil || store.client == nil {
return errors.New("save gamemaster stream offset: nil store")
}
if ctx == nil {
return errors.New("save gamemaster stream offset: nil context")
}
if strings.TrimSpace(streamLabel) == "" {
return errors.New("save gamemaster stream offset: stream label must not be empty")
}
if strings.TrimSpace(entryID) == "" {
return errors.New("save gamemaster stream offset: entry id must not be empty")
}
if err := store.client.Set(ctx, store.keys.StreamOffset(streamLabel), entryID, 0).Err(); err != nil {
return fmt.Errorf("save gamemaster stream offset: %w", err)
}
return nil
}
// Ensure Store satisfies the ports.StreamOffsetStore interface at
// compile time.
var _ ports.StreamOffsetStore = (*Store)(nil)
@@ -0,0 +1,93 @@
package streamoffsets_test
import (
"context"
"testing"
"galaxy/gamemaster/internal/adapters/redisstate"
"galaxy/gamemaster/internal/adapters/redisstate/streamoffsets"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newOffsetStore(t *testing.T) (*streamoffsets.Store, *miniredis.Miniredis) {
t.Helper()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { _ = client.Close() })
store, err := streamoffsets.New(streamoffsets.Config{Client: client})
require.NoError(t, err)
return store, server
}
func TestNewRejectsNilClient(t *testing.T) {
_, err := streamoffsets.New(streamoffsets.Config{})
require.Error(t, err)
}
func TestLoadMissingReturnsNotFound(t *testing.T) {
store, _ := newOffsetStore(t)
id, found, err := store.Load(context.Background(), "health_events")
require.NoError(t, err)
assert.False(t, found)
assert.Empty(t, id)
}
func TestSaveLoadRoundTrip(t *testing.T) {
store, server := newOffsetStore(t)
const entryID = "1700000000000-0"
require.NoError(t, store.Save(context.Background(), "health_events", entryID))
id, found, err := store.Load(context.Background(), "health_events")
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, entryID, id)
// Verify the namespace prefix lands as expected.
expectedKey := redisstate.Keyspace{}.StreamOffset("health_events")
assert.True(t, server.Exists(expectedKey),
"key %q must exist after Save", expectedKey)
}
func TestSaveOverwritesPreviousValue(t *testing.T) {
store, _ := newOffsetStore(t)
require.NoError(t, store.Save(context.Background(), "health_events", "1-0"))
require.NoError(t, store.Save(context.Background(), "health_events", "2-0"))
id, found, err := store.Load(context.Background(), "health_events")
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, "2-0", id)
}
func TestSaveRejectsBadInputs(t *testing.T) {
store, _ := newOffsetStore(t)
require.Error(t, store.Save(context.Background(), "", "1-0"))
require.Error(t, store.Save(context.Background(), "health_events", ""))
//nolint:staticcheck // intentional nil ctx test
require.Error(t, store.Save(nil, "health_events", "1-0"))
}
func TestLoadRejectsBadInputs(t *testing.T) {
store, _ := newOffsetStore(t)
_, _, err := store.Load(context.Background(), "")
require.Error(t, err)
//nolint:staticcheck // intentional nil ctx test
_, _, err = store.Load(nil, "health_events")
require.Error(t, err)
}
func TestNilStoreOperationsRejected(t *testing.T) {
var store *streamoffsets.Store
_, _, err := store.Load(context.Background(), "health_events")
require.Error(t, err)
require.Error(t, store.Save(context.Background(), "health_events", "1-0"))
}