feat: game lobby service
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ApplicationStore provides Redis-backed durable storage for application
|
||||
// records.
|
||||
type ApplicationStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewApplicationStore constructs one Redis-backed application store. It
|
||||
// returns an error when client is nil.
|
||||
func NewApplicationStore(client *redis.Client) (*ApplicationStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new application store: nil redis client")
|
||||
}
|
||||
|
||||
return &ApplicationStore{
|
||||
client: client,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Save persists a new submitted application record and enforces the
|
||||
// single-active (non-rejected) constraint per (applicant, game) pair.
|
||||
func (store *ApplicationStore) Save(ctx context.Context, record application.Application) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("save application: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("save application: nil context")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("save application: %w", err)
|
||||
}
|
||||
if record.Status != application.StatusSubmitted {
|
||||
return fmt.Errorf(
|
||||
"save application: status must be %q, got %q",
|
||||
application.StatusSubmitted, record.Status,
|
||||
)
|
||||
}
|
||||
|
||||
payload, err := MarshalApplication(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save application: %w", err)
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Application(record.ApplicationID)
|
||||
activeLookupKey := store.keys.UserGameApplication(record.ApplicantUserID, record.GameID)
|
||||
gameIndexKey := store.keys.ApplicationsByGame(record.GameID)
|
||||
userIndexKey := store.keys.ApplicationsByUser(record.ApplicantUserID)
|
||||
member := record.ApplicationID.String()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
existingPrimary, getErr := tx.Exists(ctx, primaryKey).Result()
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("save application: %w", getErr)
|
||||
}
|
||||
if existingPrimary != 0 {
|
||||
return fmt.Errorf("save application: %w", application.ErrConflict)
|
||||
}
|
||||
|
||||
existingActive, getErr := tx.Exists(ctx, activeLookupKey).Result()
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("save application: %w", getErr)
|
||||
}
|
||||
if existingActive != 0 {
|
||||
return fmt.Errorf("save application: %w", application.ErrConflict)
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, payload, ApplicationRecordTTL)
|
||||
pipe.Set(ctx, activeLookupKey, member, ApplicationRecordTTL)
|
||||
pipe.SAdd(ctx, gameIndexKey, member)
|
||||
pipe.SAdd(ctx, userIndexKey, member)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey, activeLookupKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("save application: %w", application.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the record identified by applicationID.
|
||||
func (store *ApplicationStore) Get(ctx context.Context, applicationID common.ApplicationID) (application.Application, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return application.Application{}, errors.New("get application: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return application.Application{}, errors.New("get application: nil context")
|
||||
}
|
||||
if err := applicationID.Validate(); err != nil {
|
||||
return application.Application{}, fmt.Errorf("get application: %w", err)
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.Application(applicationID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return application.Application{}, application.ErrNotFound
|
||||
case err != nil:
|
||||
return application.Application{}, fmt.Errorf("get application: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalApplication(payload)
|
||||
if err != nil {
|
||||
return application.Application{}, fmt.Errorf("get application: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByGame returns every application attached to gameID.
|
||||
func (store *ApplicationStore) GetByGame(ctx context.Context, gameID common.GameID) ([]application.Application, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get applications by game: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get applications by game: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("get applications by game: %w", err)
|
||||
}
|
||||
|
||||
return store.loadApplicationsBySet(ctx,
|
||||
"get applications by game",
|
||||
store.keys.ApplicationsByGame(gameID),
|
||||
)
|
||||
}
|
||||
|
||||
// GetByUser returns every application submitted by applicantUserID.
|
||||
func (store *ApplicationStore) GetByUser(ctx context.Context, applicantUserID string) ([]application.Application, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get applications by user: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get applications by user: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(applicantUserID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get applications by user: applicant user id must not be empty")
|
||||
}
|
||||
|
||||
return store.loadApplicationsBySet(ctx,
|
||||
"get applications by user",
|
||||
store.keys.ApplicationsByUser(trimmed),
|
||||
)
|
||||
}
|
||||
|
||||
// loadApplicationsBySet materializes applications whose ids are stored in
|
||||
// setKey. Stale set members (primary key removed out-of-band) are dropped
|
||||
// silently, mirroring gamestore.GetByStatus.
|
||||
func (store *ApplicationStore) loadApplicationsBySet(ctx context.Context, operation, setKey string) ([]application.Application, error) {
|
||||
members, err := store.client.SMembers(ctx, setKey).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
primaryKeys := make([]string, len(members))
|
||||
for index, member := range members {
|
||||
primaryKeys[index] = store.keys.Application(common.ApplicationID(member))
|
||||
}
|
||||
|
||||
payloads, err := store.client.MGet(ctx, primaryKeys...).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
records := make([]application.Application, 0, len(payloads))
|
||||
for _, entry := range payloads {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
raw, ok := entry.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: unexpected payload type %T", operation, entry)
|
||||
}
|
||||
record, err := UnmarshalApplication([]byte(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// UpdateStatus applies one status transition in a compare-and-swap fashion.
|
||||
func (store *ApplicationStore) UpdateStatus(ctx context.Context, input ports.UpdateApplicationStatusInput) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("update application status: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("update application status: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("update application status: %w", err)
|
||||
}
|
||||
|
||||
if err := application.Transition(input.ExpectedFrom, input.To); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Application(input.ApplicationID)
|
||||
at := input.At.UTC()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
payload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
return application.ErrNotFound
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("update application status: %w", getErr)
|
||||
}
|
||||
|
||||
existing, err := UnmarshalApplication(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update application status: %w", err)
|
||||
}
|
||||
if existing.Status != input.ExpectedFrom {
|
||||
return fmt.Errorf("update application status: %w", application.ErrConflict)
|
||||
}
|
||||
|
||||
existing.Status = input.To
|
||||
decidedAt := at
|
||||
existing.DecidedAt = &decidedAt
|
||||
|
||||
encoded, err := MarshalApplication(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update application status: %w", err)
|
||||
}
|
||||
|
||||
activeLookupKey := store.keys.UserGameApplication(existing.ApplicantUserID, existing.GameID)
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, encoded, ApplicationRecordTTL)
|
||||
if input.To == application.StatusRejected {
|
||||
pipe.Del(ctx, activeLookupKey)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("update application status: %w", application.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure ApplicationStore satisfies the ports.ApplicationStore interface
|
||||
// at compile time.
|
||||
var _ ports.ApplicationStore = (*ApplicationStore)(nil)
|
||||
@@ -0,0 +1,360 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newApplicationTestStore(t *testing.T) (*redisstate.ApplicationStore, *miniredis.Miniredis, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
})
|
||||
|
||||
store, err := redisstate.NewApplicationStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store, server, client
|
||||
}
|
||||
|
||||
func fixtureApplication(t *testing.T, id common.ApplicationID, userID string, gameID common.GameID) application.Application {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
record, err := application.New(application.NewApplicationInput{
|
||||
ApplicationID: id,
|
||||
GameID: gameID,
|
||||
ApplicantUserID: userID,
|
||||
RaceName: "Spring Racer",
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return record
|
||||
}
|
||||
|
||||
func TestNewApplicationStoreRejectsNilClient(t *testing.T) {
|
||||
_, err := redisstate.NewApplicationStore(nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestApplicationStoreSaveAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newApplicationTestStore(t)
|
||||
|
||||
record := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
got, err := store.Get(ctx, record.ApplicationID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.ApplicationID, got.ApplicationID)
|
||||
assert.Equal(t, record.GameID, got.GameID)
|
||||
assert.Equal(t, record.ApplicantUserID, got.ApplicantUserID)
|
||||
assert.Equal(t, record.RaceName, got.RaceName)
|
||||
assert.Equal(t, application.StatusSubmitted, got.Status)
|
||||
assert.Nil(t, got.DecidedAt)
|
||||
|
||||
byGame, err := client.SMembers(ctx, "lobby:game_applications:"+base64URL(record.GameID.String())).Result()
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{record.ApplicationID.String()}, byGame)
|
||||
|
||||
byUser, err := client.SMembers(ctx, "lobby:user_applications:"+base64URL(record.ApplicantUserID)).Result()
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{record.ApplicationID.String()}, byUser)
|
||||
|
||||
active, err := client.Get(ctx,
|
||||
"lobby:user_game_application:"+base64URL(record.ApplicantUserID)+":"+base64URL(record.GameID.String()),
|
||||
).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.ApplicationID.String(), active)
|
||||
}
|
||||
|
||||
func TestApplicationStoreGetReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
_, err := store.Get(ctx, common.ApplicationID("application-missing"))
|
||||
require.ErrorIs(t, err, application.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestApplicationStoreSaveRejectsNonSubmitted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
record := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
record.Status = application.StatusApproved
|
||||
decidedAt := record.CreatedAt.Add(time.Minute)
|
||||
record.DecidedAt = &decidedAt
|
||||
|
||||
err := store.Save(ctx, record)
|
||||
require.Error(t, err)
|
||||
assert.False(t, errors.Is(err, application.ErrConflict))
|
||||
}
|
||||
|
||||
func TestApplicationStoreSaveRejectsSecondActiveForSameUserGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
first := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, first))
|
||||
|
||||
second := fixtureApplication(t, "application-b", "user-1", "game-1")
|
||||
err := store.Save(ctx, second)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, application.ErrConflict))
|
||||
|
||||
_, err = store.Get(ctx, second.ApplicationID)
|
||||
require.ErrorIs(t, err, application.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestApplicationStoreSaveRejectsDuplicateApplicationID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
first := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, first))
|
||||
|
||||
err := store.Save(ctx, first)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, application.ErrConflict))
|
||||
}
|
||||
|
||||
func TestApplicationStoreSaveAllowsSameUserDifferentGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
first := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
second := fixtureApplication(t, "application-b", "user-1", "game-2")
|
||||
|
||||
require.NoError(t, store.Save(ctx, first))
|
||||
require.NoError(t, store.Save(ctx, second))
|
||||
|
||||
byUser, err := store.GetByUser(ctx, "user-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byUser, 2)
|
||||
}
|
||||
|
||||
func TestApplicationStoreUpdateStatusApproveKeepsActiveKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newApplicationTestStore(t)
|
||||
|
||||
record := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := record.CreatedAt.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: record.ApplicationID,
|
||||
ExpectedFrom: application.StatusSubmitted,
|
||||
To: application.StatusApproved,
|
||||
At: at,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.ApplicationID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, application.StatusApproved, got.Status)
|
||||
require.NotNil(t, got.DecidedAt)
|
||||
assert.True(t, got.DecidedAt.Equal(at.UTC()))
|
||||
|
||||
activeKey := "lobby:user_game_application:" + base64URL(record.ApplicantUserID) + ":" + base64URL(record.GameID.String())
|
||||
stored, err := client.Get(ctx, activeKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.ApplicationID.String(), stored)
|
||||
}
|
||||
|
||||
func TestApplicationStoreUpdateStatusRejectClearsActiveKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newApplicationTestStore(t)
|
||||
|
||||
record := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := record.CreatedAt.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: record.ApplicationID,
|
||||
ExpectedFrom: application.StatusSubmitted,
|
||||
To: application.StatusRejected,
|
||||
At: at,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.ApplicationID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, application.StatusRejected, got.Status)
|
||||
require.NotNil(t, got.DecidedAt)
|
||||
|
||||
activeKey := "lobby:user_game_application:" + base64URL(record.ApplicantUserID) + ":" + base64URL(record.GameID.String())
|
||||
_, err = client.Get(ctx, activeKey).Result()
|
||||
require.ErrorIs(t, err, redis.Nil)
|
||||
|
||||
// After rejection, the same user may re-apply to the same game.
|
||||
reapplied := fixtureApplication(t, "application-b", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, reapplied))
|
||||
}
|
||||
|
||||
func TestApplicationStoreUpdateStatusRejectsInvalidTransitionWithoutMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
record := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: record.ApplicationID,
|
||||
ExpectedFrom: application.StatusApproved,
|
||||
To: application.StatusSubmitted,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, application.ErrInvalidTransition))
|
||||
|
||||
got, err := store.Get(ctx, record.ApplicationID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, application.StatusSubmitted, got.Status)
|
||||
assert.Nil(t, got.DecidedAt)
|
||||
}
|
||||
|
||||
func TestApplicationStoreUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
record := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: record.ApplicationID,
|
||||
ExpectedFrom: application.StatusSubmitted,
|
||||
To: application.StatusApproved,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
}))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: record.ApplicationID,
|
||||
ExpectedFrom: application.StatusSubmitted,
|
||||
To: application.StatusRejected,
|
||||
At: record.CreatedAt.Add(2 * time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, application.ErrConflict))
|
||||
}
|
||||
|
||||
func TestApplicationStoreUpdateStatusReturnsNotFoundForMissingRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateApplicationStatusInput{
|
||||
ApplicationID: common.ApplicationID("application-missing"),
|
||||
ExpectedFrom: application.StatusSubmitted,
|
||||
To: application.StatusApproved,
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, application.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestApplicationStoreGetByGameAndByUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newApplicationTestStore(t)
|
||||
|
||||
a1 := fixtureApplication(t, "application-a1", "user-1", "game-1")
|
||||
a2 := fixtureApplication(t, "application-a2", "user-2", "game-1")
|
||||
a3 := fixtureApplication(t, "application-a3", "user-1", "game-2")
|
||||
|
||||
for _, record := range []application.Application{a1, a2, a3} {
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
}
|
||||
|
||||
byGame1, err := store.GetByGame(ctx, "game-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byGame1, 2)
|
||||
|
||||
byUser1, err := store.GetByUser(ctx, "user-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byUser1, 2)
|
||||
|
||||
ids := collectApplicationIDs(byUser1)
|
||||
sort.Strings(ids)
|
||||
assert.Equal(t, []string{"application-a1", "application-a3"}, ids)
|
||||
|
||||
byUser3, err := store.GetByUser(ctx, "user-missing")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, byUser3)
|
||||
}
|
||||
|
||||
func TestApplicationStoreGetByGameDropsStaleIndexEntries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server, _ := newApplicationTestStore(t)
|
||||
|
||||
record := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
server.Del("lobby:applications:" + base64URL(record.ApplicationID.String()))
|
||||
|
||||
records, err := store.GetByGame(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records)
|
||||
}
|
||||
|
||||
func TestApplicationStoreConcurrentSaveHasExactlyOneWinner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, _, client := newApplicationTestStore(t)
|
||||
|
||||
storeA, err := redisstate.NewApplicationStore(client)
|
||||
require.NoError(t, err)
|
||||
storeB, err := redisstate.NewApplicationStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
recordA := fixtureApplication(t, "application-a", "user-1", "game-1")
|
||||
recordB := fixtureApplication(t, "application-b", "user-1", "game-1")
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
successes atomic.Int32
|
||||
conflicts atomic.Int32
|
||||
others atomic.Int32
|
||||
)
|
||||
|
||||
apply := func(target *redisstate.ApplicationStore, record application.Application) {
|
||||
defer wg.Done()
|
||||
err := target.Save(ctx, record)
|
||||
switch {
|
||||
case err == nil:
|
||||
successes.Add(1)
|
||||
case errors.Is(err, application.ErrConflict):
|
||||
conflicts.Add(1)
|
||||
default:
|
||||
others.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(2)
|
||||
go apply(storeA, recordA)
|
||||
go apply(storeB, recordB)
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, int32(0), others.Load(), "unexpected non-conflict error")
|
||||
assert.Equal(t, int32(1), successes.Load(), "expected exactly one success")
|
||||
assert.Equal(t, int32(1), conflicts.Load(), "expected exactly one conflict")
|
||||
}
|
||||
|
||||
func collectApplicationIDs(records []application.Application) []string {
|
||||
ids := make([]string, len(records))
|
||||
for index, record := range records {
|
||||
ids[index] = record.ApplicationID.String()
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
)
|
||||
|
||||
// gameRecord stores the strict Redis JSON shape used for one game record.
|
||||
type gameRecord struct {
|
||||
GameID string `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
GameType game.GameType `json:"game_type"`
|
||||
OwnerUserID string `json:"owner_user_id,omitempty"`
|
||||
Status game.Status `json:"status"`
|
||||
MinPlayers int `json:"min_players"`
|
||||
MaxPlayers int `json:"max_players"`
|
||||
StartGapHours int `json:"start_gap_hours"`
|
||||
StartGapPlayers int `json:"start_gap_players"`
|
||||
EnrollmentEndsAtSec int64 `json:"enrollment_ends_at_sec"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
StartedAtMS *int64 `json:"started_at_ms,omitempty"`
|
||||
FinishedAtMS *int64 `json:"finished_at_ms,omitempty"`
|
||||
CurrentTurn int `json:"current_turn"`
|
||||
RuntimeStatus string `json:"runtime_status,omitempty"`
|
||||
EngineHealthSummary string `json:"engine_health_summary,omitempty"`
|
||||
RuntimeBinding *runtimeBindingRecord `json:"runtime_binding,omitempty"`
|
||||
}
|
||||
|
||||
// runtimeBindingRecord stores the strict Redis JSON shape used for the
|
||||
// optional runtime binding object on one game record.
|
||||
type runtimeBindingRecord struct {
|
||||
ContainerID string `json:"container_id"`
|
||||
EngineEndpoint string `json:"engine_endpoint"`
|
||||
RuntimeJobID string `json:"runtime_job_id"`
|
||||
BoundAtMS int64 `json:"bound_at_ms"`
|
||||
}
|
||||
|
||||
// MarshalGame encodes record into the strict Redis JSON shape used for
|
||||
// game records. The record is re-validated before marshalling.
|
||||
func MarshalGame(record game.Game) ([]byte, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis game record: %w", err)
|
||||
}
|
||||
|
||||
stored := gameRecord{
|
||||
GameID: record.GameID.String(),
|
||||
GameName: record.GameName,
|
||||
Description: record.Description,
|
||||
GameType: record.GameType,
|
||||
OwnerUserID: record.OwnerUserID,
|
||||
Status: record.Status,
|
||||
MinPlayers: record.MinPlayers,
|
||||
MaxPlayers: record.MaxPlayers,
|
||||
StartGapHours: record.StartGapHours,
|
||||
StartGapPlayers: record.StartGapPlayers,
|
||||
EnrollmentEndsAtSec: record.EnrollmentEndsAt.UTC().Unix(),
|
||||
TurnSchedule: record.TurnSchedule,
|
||||
TargetEngineVersion: record.TargetEngineVersion,
|
||||
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAtMS: record.UpdatedAt.UTC().UnixMilli(),
|
||||
StartedAtMS: optionalUnixMilli(record.StartedAt),
|
||||
FinishedAtMS: optionalUnixMilli(record.FinishedAt),
|
||||
CurrentTurn: record.RuntimeSnapshot.CurrentTurn,
|
||||
RuntimeStatus: record.RuntimeSnapshot.RuntimeStatus,
|
||||
EngineHealthSummary: record.RuntimeSnapshot.EngineHealthSummary,
|
||||
}
|
||||
if record.RuntimeBinding != nil {
|
||||
stored.RuntimeBinding = &runtimeBindingRecord{
|
||||
ContainerID: record.RuntimeBinding.ContainerID,
|
||||
EngineEndpoint: record.RuntimeBinding.EngineEndpoint,
|
||||
RuntimeJobID: record.RuntimeBinding.RuntimeJobID,
|
||||
BoundAtMS: record.RuntimeBinding.BoundAt.UTC().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis game record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// UnmarshalGame decodes payload from the strict Redis JSON shape used for
|
||||
// game records. The decoded record is validated before returning.
|
||||
func UnmarshalGame(payload []byte) (game.Game, error) {
|
||||
var stored gameRecord
|
||||
if err := decodeStrictJSON("decode redis game record", payload, &stored); err != nil {
|
||||
return game.Game{}, err
|
||||
}
|
||||
|
||||
record := game.Game{
|
||||
GameID: common.GameID(stored.GameID),
|
||||
GameName: stored.GameName,
|
||||
Description: stored.Description,
|
||||
GameType: stored.GameType,
|
||||
OwnerUserID: stored.OwnerUserID,
|
||||
Status: stored.Status,
|
||||
MinPlayers: stored.MinPlayers,
|
||||
MaxPlayers: stored.MaxPlayers,
|
||||
StartGapHours: stored.StartGapHours,
|
||||
StartGapPlayers: stored.StartGapPlayers,
|
||||
EnrollmentEndsAt: time.Unix(stored.EnrollmentEndsAtSec, 0).UTC(),
|
||||
TurnSchedule: stored.TurnSchedule,
|
||||
TargetEngineVersion: stored.TargetEngineVersion,
|
||||
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
|
||||
UpdatedAt: time.UnixMilli(stored.UpdatedAtMS).UTC(),
|
||||
StartedAt: inflateOptionalTime(stored.StartedAtMS),
|
||||
FinishedAt: inflateOptionalTime(stored.FinishedAtMS),
|
||||
RuntimeSnapshot: game.RuntimeSnapshot{
|
||||
CurrentTurn: stored.CurrentTurn,
|
||||
RuntimeStatus: stored.RuntimeStatus,
|
||||
EngineHealthSummary: stored.EngineHealthSummary,
|
||||
},
|
||||
}
|
||||
if stored.RuntimeBinding != nil {
|
||||
record.RuntimeBinding = &game.RuntimeBinding{
|
||||
ContainerID: stored.RuntimeBinding.ContainerID,
|
||||
EngineEndpoint: stored.RuntimeBinding.EngineEndpoint,
|
||||
RuntimeJobID: stored.RuntimeBinding.RuntimeJobID,
|
||||
BoundAt: time.UnixMilli(stored.RuntimeBinding.BoundAtMS).UTC(),
|
||||
}
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return game.Game{}, fmt.Errorf("decode redis game record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func decodeStrictJSON(operation string, payload []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("%s: unexpected trailing JSON input", operation)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func optionalUnixMilli(value *time.Time) *int64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
milliseconds := value.UTC().UnixMilli()
|
||||
return &milliseconds
|
||||
}
|
||||
|
||||
func inflateOptionalTime(value *int64) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
converted := time.UnixMilli(*value).UTC()
|
||||
return &converted
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/application"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
)
|
||||
|
||||
// applicationRecord stores the strict Redis JSON shape used for one
|
||||
// application record.
|
||||
type applicationRecord struct {
|
||||
ApplicationID string `json:"application_id"`
|
||||
GameID string `json:"game_id"`
|
||||
ApplicantUserID string `json:"applicant_user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
Status application.Status `json:"status"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
DecidedAtMS *int64 `json:"decided_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalApplication encodes record into the strict Redis JSON shape
|
||||
// used for application records. The record is re-validated before
|
||||
// marshalling.
|
||||
func MarshalApplication(record application.Application) ([]byte, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis application record: %w", err)
|
||||
}
|
||||
|
||||
stored := applicationRecord{
|
||||
ApplicationID: record.ApplicationID.String(),
|
||||
GameID: record.GameID.String(),
|
||||
ApplicantUserID: record.ApplicantUserID,
|
||||
RaceName: record.RaceName,
|
||||
Status: record.Status,
|
||||
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
||||
DecidedAtMS: optionalUnixMilli(record.DecidedAt),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis application record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// UnmarshalApplication decodes payload from the strict Redis JSON shape
|
||||
// used for application records. The decoded record is validated before
|
||||
// returning.
|
||||
func UnmarshalApplication(payload []byte) (application.Application, error) {
|
||||
var stored applicationRecord
|
||||
if err := decodeStrictJSON("decode redis application record", payload, &stored); err != nil {
|
||||
return application.Application{}, err
|
||||
}
|
||||
|
||||
record := application.Application{
|
||||
ApplicationID: common.ApplicationID(stored.ApplicationID),
|
||||
GameID: common.GameID(stored.GameID),
|
||||
ApplicantUserID: stored.ApplicantUserID,
|
||||
RaceName: stored.RaceName,
|
||||
Status: stored.Status,
|
||||
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
|
||||
DecidedAt: inflateOptionalTime(stored.DecidedAtMS),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return application.Application{}, fmt.Errorf("decode redis application record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
)
|
||||
|
||||
// playerStatsRecord stores the strict Redis JSON shape used for one
|
||||
// per-game per-user stats aggregate. The shape mirrors the field set
|
||||
// documented in lobby/README.md §Runtime Snapshot.
|
||||
type playerStatsRecord struct {
|
||||
UserID string `json:"user_id"`
|
||||
InitialPlanets int64 `json:"initial_planets"`
|
||||
InitialPopulation int64 `json:"initial_population"`
|
||||
InitialShipsBuilt int64 `json:"initial_ships_built"`
|
||||
MaxPlanets int64 `json:"max_planets"`
|
||||
MaxPopulation int64 `json:"max_population"`
|
||||
MaxShipsBuilt int64 `json:"max_ships_built"`
|
||||
}
|
||||
|
||||
// MarshalPlayerStats encodes aggregate into the strict Redis JSON shape.
|
||||
// Negative counters are rejected to match the validation surface of
|
||||
// ports.PlayerObservedStats.Validate.
|
||||
func MarshalPlayerStats(aggregate ports.PlayerStatsAggregate) ([]byte, error) {
|
||||
if err := validatePlayerStatsAggregate(aggregate); err != nil {
|
||||
return nil, fmt.Errorf("marshal player stats aggregate: %w", err)
|
||||
}
|
||||
return json.Marshal(playerStatsRecord{
|
||||
UserID: aggregate.UserID,
|
||||
InitialPlanets: aggregate.InitialPlanets,
|
||||
InitialPopulation: aggregate.InitialPopulation,
|
||||
InitialShipsBuilt: aggregate.InitialShipsBuilt,
|
||||
MaxPlanets: aggregate.MaxPlanets,
|
||||
MaxPopulation: aggregate.MaxPopulation,
|
||||
MaxShipsBuilt: aggregate.MaxShipsBuilt,
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalPlayerStats decodes payload into a PlayerStatsAggregate. The
|
||||
// returned aggregate is re-validated to guarantee the Redis store never
|
||||
// surfaces malformed records.
|
||||
func UnmarshalPlayerStats(payload []byte) (ports.PlayerStatsAggregate, error) {
|
||||
var stored playerStatsRecord
|
||||
if err := json.Unmarshal(payload, &stored); err != nil {
|
||||
return ports.PlayerStatsAggregate{}, fmt.Errorf("unmarshal player stats aggregate: %w", err)
|
||||
}
|
||||
aggregate := ports.PlayerStatsAggregate{
|
||||
UserID: stored.UserID,
|
||||
InitialPlanets: stored.InitialPlanets,
|
||||
InitialPopulation: stored.InitialPopulation,
|
||||
InitialShipsBuilt: stored.InitialShipsBuilt,
|
||||
MaxPlanets: stored.MaxPlanets,
|
||||
MaxPopulation: stored.MaxPopulation,
|
||||
MaxShipsBuilt: stored.MaxShipsBuilt,
|
||||
}
|
||||
if err := validatePlayerStatsAggregate(aggregate); err != nil {
|
||||
return ports.PlayerStatsAggregate{}, fmt.Errorf("unmarshal player stats aggregate: %w", err)
|
||||
}
|
||||
return aggregate, nil
|
||||
}
|
||||
|
||||
func validatePlayerStatsAggregate(aggregate ports.PlayerStatsAggregate) error {
|
||||
if aggregate.UserID == "" {
|
||||
return fmt.Errorf("user id must not be empty")
|
||||
}
|
||||
if aggregate.InitialPlanets < 0 {
|
||||
return fmt.Errorf("initial planets must not be negative")
|
||||
}
|
||||
if aggregate.InitialPopulation < 0 {
|
||||
return fmt.Errorf("initial population must not be negative")
|
||||
}
|
||||
if aggregate.InitialShipsBuilt < 0 {
|
||||
return fmt.Errorf("initial ships built must not be negative")
|
||||
}
|
||||
if aggregate.MaxPlanets < aggregate.InitialPlanets {
|
||||
return fmt.Errorf("max planets must not be below initial planets")
|
||||
}
|
||||
if aggregate.MaxPopulation < aggregate.InitialPopulation {
|
||||
return fmt.Errorf("max population must not be below initial population")
|
||||
}
|
||||
if aggregate.MaxShipsBuilt < aggregate.InitialShipsBuilt {
|
||||
return fmt.Errorf("max ships built must not be below initial ships built")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
)
|
||||
|
||||
// inviteRecord stores the strict Redis JSON shape used for one invite
|
||||
// record.
|
||||
type inviteRecord struct {
|
||||
InviteID string `json:"invite_id"`
|
||||
GameID string `json:"game_id"`
|
||||
InviterUserID string `json:"inviter_user_id"`
|
||||
InviteeUserID string `json:"invitee_user_id"`
|
||||
RaceName string `json:"race_name,omitempty"`
|
||||
Status invite.Status `json:"status"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
ExpiresAtMS int64 `json:"expires_at_ms"`
|
||||
DecidedAtMS *int64 `json:"decided_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalInvite encodes record into the strict Redis JSON shape used for
|
||||
// invite records. The record is re-validated before marshalling.
|
||||
func MarshalInvite(record invite.Invite) ([]byte, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis invite record: %w", err)
|
||||
}
|
||||
|
||||
stored := inviteRecord{
|
||||
InviteID: record.InviteID.String(),
|
||||
GameID: record.GameID.String(),
|
||||
InviterUserID: record.InviterUserID,
|
||||
InviteeUserID: record.InviteeUserID,
|
||||
RaceName: record.RaceName,
|
||||
Status: record.Status,
|
||||
CreatedAtMS: record.CreatedAt.UTC().UnixMilli(),
|
||||
ExpiresAtMS: record.ExpiresAt.UTC().UnixMilli(),
|
||||
DecidedAtMS: optionalUnixMilli(record.DecidedAt),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis invite record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// UnmarshalInvite decodes payload from the strict Redis JSON shape used
|
||||
// for invite records. The decoded record is validated before returning.
|
||||
func UnmarshalInvite(payload []byte) (invite.Invite, error) {
|
||||
var stored inviteRecord
|
||||
if err := decodeStrictJSON("decode redis invite record", payload, &stored); err != nil {
|
||||
return invite.Invite{}, err
|
||||
}
|
||||
|
||||
record := invite.Invite{
|
||||
InviteID: common.InviteID(stored.InviteID),
|
||||
GameID: common.GameID(stored.GameID),
|
||||
InviterUserID: stored.InviterUserID,
|
||||
InviteeUserID: stored.InviteeUserID,
|
||||
RaceName: stored.RaceName,
|
||||
Status: stored.Status,
|
||||
CreatedAt: time.UnixMilli(stored.CreatedAtMS).UTC(),
|
||||
ExpiresAt: time.UnixMilli(stored.ExpiresAtMS).UTC(),
|
||||
DecidedAt: inflateOptionalTime(stored.DecidedAtMS),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return invite.Invite{}, fmt.Errorf("decode redis invite record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
)
|
||||
|
||||
// membershipRecord stores the strict Redis JSON shape used for one
|
||||
// membership record.
|
||||
type membershipRecord struct {
|
||||
MembershipID string `json:"membership_id"`
|
||||
GameID string `json:"game_id"`
|
||||
UserID string `json:"user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
CanonicalKey string `json:"canonical_key"`
|
||||
Status membership.Status `json:"status"`
|
||||
JoinedAtMS int64 `json:"joined_at_ms"`
|
||||
RemovedAtMS *int64 `json:"removed_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalMembership encodes record into the strict Redis JSON shape used
|
||||
// for membership records. The record is re-validated before marshalling.
|
||||
func MarshalMembership(record membership.Membership) ([]byte, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal redis membership record: %w", err)
|
||||
}
|
||||
|
||||
stored := membershipRecord{
|
||||
MembershipID: record.MembershipID.String(),
|
||||
GameID: record.GameID.String(),
|
||||
UserID: record.UserID,
|
||||
RaceName: record.RaceName,
|
||||
CanonicalKey: record.CanonicalKey,
|
||||
Status: record.Status,
|
||||
JoinedAtMS: record.JoinedAt.UTC().UnixMilli(),
|
||||
RemovedAtMS: optionalUnixMilli(record.RemovedAt),
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis membership record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// UnmarshalMembership decodes payload from the strict Redis JSON shape
|
||||
// used for membership records. The decoded record is validated before
|
||||
// returning.
|
||||
func UnmarshalMembership(payload []byte) (membership.Membership, error) {
|
||||
var stored membershipRecord
|
||||
if err := decodeStrictJSON("decode redis membership record", payload, &stored); err != nil {
|
||||
return membership.Membership{}, err
|
||||
}
|
||||
|
||||
record := membership.Membership{
|
||||
MembershipID: common.MembershipID(stored.MembershipID),
|
||||
GameID: common.GameID(stored.GameID),
|
||||
UserID: stored.UserID,
|
||||
RaceName: stored.RaceName,
|
||||
CanonicalKey: stored.CanonicalKey,
|
||||
Status: stored.Status,
|
||||
JoinedAt: time.UnixMilli(stored.JoinedAtMS).UTC(),
|
||||
RemovedAt: inflateOptionalTime(stored.RemovedAtMS),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return membership.Membership{}, fmt.Errorf("decode redis membership record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// registeredRecord stores the strict Redis JSON shape of one registered
|
||||
// race name. The canonical key is stored only as the Redis key suffix and
|
||||
// is not duplicated inside the blob.
|
||||
type registeredRecord struct {
|
||||
UserID string `json:"user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
SourceGameID string `json:"source_game_id"`
|
||||
RegisteredAtMS int64 `json:"registered_at_ms"`
|
||||
}
|
||||
|
||||
// reservationStatusReserved marks a per-game race name reservation that
|
||||
// has not yet been promoted by capability evaluation.
|
||||
const reservationStatusReserved = "reserved"
|
||||
|
||||
// reservationStatusPending marks a reservation that has been promoted to
|
||||
// pending_registration by the capability evaluator at game_finished.
|
||||
const reservationStatusPending = "pending_registration"
|
||||
|
||||
// reservationRecord stores the strict Redis JSON shape of one per-game
|
||||
// race name reservation. The game_id and canonical key are carried by the
|
||||
// Redis key suffix; the blob never duplicates them.
|
||||
type reservationRecord struct {
|
||||
UserID string `json:"user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
ReservedAtMS int64 `json:"reserved_at_ms"`
|
||||
Status string `json:"status"`
|
||||
EligibleUntilMS *int64 `json:"eligible_until_ms,omitempty"`
|
||||
}
|
||||
|
||||
// canonicalLookupRecord stores the eager canonical-lookup cache entry
|
||||
// used by Check to return availability without scanning the authoritative
|
||||
// keys. GameID is populated only for reservation and pending_registration
|
||||
// kinds; it is omitted for registered bindings.
|
||||
type canonicalLookupRecord struct {
|
||||
Kind string `json:"kind"`
|
||||
HolderUserID string `json:"holder_user_id"`
|
||||
GameID string `json:"game_id,omitempty"`
|
||||
}
|
||||
|
||||
// marshalRegisteredRecord encodes record into the strict Redis JSON shape
|
||||
// used for registered race names.
|
||||
func marshalRegisteredRecord(record registeredRecord) ([]byte, error) {
|
||||
payload, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis registered race name record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// unmarshalRegisteredRecord decodes payload from the strict Redis JSON
|
||||
// shape used for registered race names.
|
||||
func unmarshalRegisteredRecord(payload []byte) (registeredRecord, error) {
|
||||
var record registeredRecord
|
||||
if err := decodeStrictJSON("decode redis registered race name record", payload, &record); err != nil {
|
||||
return registeredRecord{}, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// marshalReservationRecord encodes record into the strict Redis JSON
|
||||
// shape used for per-game race name reservations.
|
||||
func marshalReservationRecord(record reservationRecord) ([]byte, error) {
|
||||
payload, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis race name reservation record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// unmarshalReservationRecord decodes payload from the strict Redis JSON
|
||||
// shape used for per-game race name reservations.
|
||||
func unmarshalReservationRecord(payload []byte) (reservationRecord, error) {
|
||||
var record reservationRecord
|
||||
if err := decodeStrictJSON("decode redis race name reservation record", payload, &record); err != nil {
|
||||
return reservationRecord{}, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// marshalCanonicalLookupRecord encodes record into the strict Redis JSON
|
||||
// shape used for canonical-lookup cache entries.
|
||||
func marshalCanonicalLookupRecord(record canonicalLookupRecord) ([]byte, error) {
|
||||
payload, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal redis race name canonical lookup record: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// unmarshalCanonicalLookupRecord decodes payload from the strict Redis
|
||||
// JSON shape used for canonical-lookup cache entries.
|
||||
func unmarshalCanonicalLookupRecord(payload []byte) (canonicalLookupRecord, error) {
|
||||
var record canonicalLookupRecord
|
||||
if err := decodeStrictJSON("decode redis race name canonical lookup record", payload, &record); err != nil {
|
||||
return canonicalLookupRecord{}, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package redisstate defines the frozen Game Lobby Service Redis keyspace,
|
||||
// strict JSON record shapes, and low-level mutation helpers used by the
|
||||
// Game Lobby store adapters.
|
||||
//
|
||||
// Adapters in this package implement ports.GameStore,
|
||||
// ports.ApplicationStore, ports.InviteStore, and ports.MembershipStore on
|
||||
// top of a `*redis.Client`. Every marshal and unmarshal round-trip calls
|
||||
// the domain-level Validate method to guarantee that the store never
|
||||
// exposes malformed records.
|
||||
package redisstate
|
||||
@@ -0,0 +1,95 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// CapabilityEvaluationGuardTTL bounds how long the guard marker survives
|
||||
// in Redis. The evaluator only reads the guard during `game_finished`
|
||||
// processing, and capability windows expire after 30 days, so a 60-day
|
||||
// retention is comfortably long enough to absorb any practical replay
|
||||
// while still letting the keyspace reclaim space eventually.
|
||||
const CapabilityEvaluationGuardTTL time.Duration = 60 * 24 * time.Hour
|
||||
|
||||
// EvaluationGuardStore stores per-game «already evaluated» markers in Redis
|
||||
// using SETNX semantics. The first MarkEvaluated call for a gameID records
|
||||
// the marker; later calls observe the existing key and return already=true.
|
||||
type EvaluationGuardStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewEvaluationGuardStore constructs one Redis-backed EvaluationGuardStore
|
||||
// using the default guard TTL.
|
||||
func NewEvaluationGuardStore(client *redis.Client) (*EvaluationGuardStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new lobby evaluation guard store: nil redis client")
|
||||
}
|
||||
return &EvaluationGuardStore{
|
||||
client: client,
|
||||
keys: Keyspace{},
|
||||
ttl: CapabilityEvaluationGuardTTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsEvaluated reports whether gameID is already marked. It performs a
|
||||
// single GET against the guard key and treats the missing-key case as
|
||||
// not-yet-evaluated.
|
||||
func (store *EvaluationGuardStore) IsEvaluated(ctx context.Context, gameID common.GameID) (bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return false, errors.New("is evaluated: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return false, errors.New("is evaluated: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return false, fmt.Errorf("is evaluated: %w", err)
|
||||
}
|
||||
|
||||
_, err := store.client.Get(ctx, store.keys.CapabilityEvaluationGuard(gameID)).Result()
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
case errors.Is(err, redis.Nil):
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("is evaluated: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// MarkEvaluated records gameID as evaluated. Calling MarkEvaluated twice
|
||||
// for the same gameID is safe; the second call leaves the marker
|
||||
// untouched and refreshes the TTL.
|
||||
func (store *EvaluationGuardStore) MarkEvaluated(ctx context.Context, gameID common.GameID) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("mark evaluated: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("mark evaluated: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return fmt.Errorf("mark evaluated: %w", err)
|
||||
}
|
||||
|
||||
if err := store.client.Set(
|
||||
ctx,
|
||||
store.keys.CapabilityEvaluationGuard(gameID),
|
||||
"1",
|
||||
store.ttl,
|
||||
).Err(); err != nil {
|
||||
return fmt.Errorf("mark evaluated: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.EvaluationGuardStore = (*EvaluationGuardStore)(nil)
|
||||
@@ -0,0 +1,77 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newGuardStore(t *testing.T) (*redisstate.EvaluationGuardStore, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
store, err := redisstate.NewEvaluationGuardStore(client)
|
||||
require.NoError(t, err)
|
||||
return store, server
|
||||
}
|
||||
|
||||
func TestEvaluationGuardStoreIsEvaluatedReturnsFalseWhenMissing(t *testing.T) {
|
||||
store, _ := newGuardStore(t)
|
||||
|
||||
evaluated, err := store.IsEvaluated(context.Background(), common.GameID("game-guard-1"))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, evaluated)
|
||||
}
|
||||
|
||||
func TestEvaluationGuardStoreMarkThenIsEvaluated(t *testing.T) {
|
||||
store, _ := newGuardStore(t)
|
||||
gameID := common.GameID("game-guard-2")
|
||||
|
||||
require.NoError(t, store.MarkEvaluated(context.Background(), gameID))
|
||||
|
||||
evaluated, err := store.IsEvaluated(context.Background(), gameID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, evaluated)
|
||||
}
|
||||
|
||||
func TestEvaluationGuardStoreMarkIsIdempotent(t *testing.T) {
|
||||
store, _ := newGuardStore(t)
|
||||
gameID := common.GameID("game-guard-3")
|
||||
|
||||
require.NoError(t, store.MarkEvaluated(context.Background(), gameID))
|
||||
require.NoError(t, store.MarkEvaluated(context.Background(), gameID))
|
||||
|
||||
evaluated, err := store.IsEvaluated(context.Background(), gameID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, evaluated)
|
||||
}
|
||||
|
||||
func TestEvaluationGuardStoreInvalidGameID(t *testing.T) {
|
||||
store, _ := newGuardStore(t)
|
||||
|
||||
_, err := store.IsEvaluated(context.Background(), common.GameID(""))
|
||||
require.Error(t, err)
|
||||
|
||||
err = store.MarkEvaluated(context.Background(), common.GameID(""))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestEvaluationGuardStoreSetsTTL(t *testing.T) {
|
||||
store, server := newGuardStore(t)
|
||||
gameID := common.GameID("game-guard-ttl")
|
||||
|
||||
require.NoError(t, store.MarkEvaluated(context.Background(), gameID))
|
||||
|
||||
keyspace := redisstate.Keyspace{}
|
||||
ttl := server.TTL(keyspace.CapabilityEvaluationGuard(gameID))
|
||||
assert.Equal(t, redisstate.CapabilityEvaluationGuardTTL, ttl)
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// GameStore provides Redis-backed durable storage for game records.
|
||||
type GameStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewGameStore constructs one Redis-backed game store. It returns an
|
||||
// error when client is nil.
|
||||
func NewGameStore(client *redis.Client) (*GameStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new game store: nil redis client")
|
||||
}
|
||||
|
||||
return &GameStore{
|
||||
client: client,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Save upserts record and rewrites the status secondary index when the
|
||||
// status changes.
|
||||
func (store *GameStore) Save(ctx context.Context, record game.Game) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("save game: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("save game: nil context")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("save game: %w", err)
|
||||
}
|
||||
|
||||
payload, err := MarshalGame(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save game: %w", err)
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Game(record.GameID)
|
||||
newIndexKey := store.keys.GamesByStatus(record.Status)
|
||||
member := record.GameID.String()
|
||||
createdAtScore := CreatedAtScore(record.CreatedAt)
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
var previousStatus game.Status
|
||||
existingPayload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
previousStatus = ""
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("save game: %w", getErr)
|
||||
default:
|
||||
existing, err := UnmarshalGame(existingPayload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save game: %w", err)
|
||||
}
|
||||
previousStatus = existing.Status
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, payload, GameRecordTTL)
|
||||
if previousStatus != "" && previousStatus != record.Status {
|
||||
pipe.ZRem(ctx, store.keys.GamesByStatus(previousStatus), member)
|
||||
}
|
||||
pipe.ZAdd(ctx, newIndexKey, redis.Z{
|
||||
Score: createdAtScore,
|
||||
Member: member,
|
||||
})
|
||||
if owner := strings.TrimSpace(record.OwnerUserID); owner != "" {
|
||||
pipe.SAdd(ctx, store.keys.GamesByOwner(owner), member)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("save game: %w", game.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the record identified by gameID.
|
||||
func (store *GameStore) Get(ctx context.Context, gameID common.GameID) (game.Game, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return game.Game{}, errors.New("get game: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return game.Game{}, errors.New("get game: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return game.Game{}, fmt.Errorf("get game: %w", err)
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.Game(gameID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return game.Game{}, game.ErrNotFound
|
||||
case err != nil:
|
||||
return game.Game{}, fmt.Errorf("get game: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalGame(payload)
|
||||
if err != nil {
|
||||
return game.Game{}, fmt.Errorf("get game: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByStatus returns every record indexed under status. Stale index
|
||||
// entries (primary key removed out-of-band) are dropped silently.
|
||||
func (store *GameStore) GetByStatus(ctx context.Context, status game.Status) ([]game.Game, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get games by status: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get games by status: nil context")
|
||||
}
|
||||
if !status.IsKnown() {
|
||||
return nil, fmt.Errorf("get games by status: status %q is unsupported", status)
|
||||
}
|
||||
|
||||
members, err := store.client.ZRange(ctx, store.keys.GamesByStatus(status), 0, -1).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get games by status: %w", err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
primaryKeys := make([]string, len(members))
|
||||
for index, member := range members {
|
||||
primaryKeys[index] = store.keys.Game(common.GameID(member))
|
||||
}
|
||||
|
||||
payloads, err := store.client.MGet(ctx, primaryKeys...).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get games by status: %w", err)
|
||||
}
|
||||
|
||||
records := make([]game.Game, 0, len(payloads))
|
||||
for _, entry := range payloads {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
raw, ok := entry.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("get games by status: unexpected payload type %T", entry)
|
||||
}
|
||||
record, err := UnmarshalGame([]byte(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get games by status: %w", err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// CountByStatus returns the number of game identifiers indexed under each
|
||||
// known status. The map carries one entry per game.AllStatuses, with zero
|
||||
// counts for empty buckets. The implementation issues one ZCARD per status
|
||||
// in a single Redis pipeline so the cost stays O(number of statuses).
|
||||
func (store *GameStore) CountByStatus(ctx context.Context) (map[game.Status]int, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("count games by status: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("count games by status: nil context")
|
||||
}
|
||||
|
||||
statuses := game.AllStatuses()
|
||||
pipeline := store.client.Pipeline()
|
||||
results := make([]*redis.IntCmd, len(statuses))
|
||||
for index, status := range statuses {
|
||||
results[index] = pipeline.ZCard(ctx, store.keys.GamesByStatus(status))
|
||||
}
|
||||
if _, err := pipeline.Exec(ctx); err != nil {
|
||||
return nil, fmt.Errorf("count games by status: %w", err)
|
||||
}
|
||||
|
||||
counts := make(map[game.Status]int, len(statuses))
|
||||
for index, status := range statuses {
|
||||
count, err := results[index].Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count games by status: %s: %w", status, err)
|
||||
}
|
||||
counts[status] = int(count)
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// GetByOwner returns every record whose OwnerUserID equals userID.
|
||||
// Stale index entries (primary key removed out-of-band) are dropped
|
||||
// silently. The slice order is adapter-defined.
|
||||
func (store *GameStore) GetByOwner(ctx context.Context, userID string) ([]game.Game, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get games by owner: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get games by owner: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(userID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get games by owner: user id must not be empty")
|
||||
}
|
||||
|
||||
members, err := store.client.SMembers(ctx, store.keys.GamesByOwner(trimmed)).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get games by owner: %w", err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
primaryKeys := make([]string, len(members))
|
||||
for index, member := range members {
|
||||
primaryKeys[index] = store.keys.Game(common.GameID(member))
|
||||
}
|
||||
|
||||
payloads, err := store.client.MGet(ctx, primaryKeys...).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get games by owner: %w", err)
|
||||
}
|
||||
|
||||
records := make([]game.Game, 0, len(payloads))
|
||||
for _, entry := range payloads {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
raw, ok := entry.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("get games by owner: unexpected payload type %T", entry)
|
||||
}
|
||||
record, err := UnmarshalGame([]byte(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get games by owner: %w", err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// UpdateStatus applies one status transition in a compare-and-swap
|
||||
// fashion.
|
||||
func (store *GameStore) UpdateStatus(ctx context.Context, input ports.UpdateStatusInput) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("update game status: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("update game status: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("update game status: %w", err)
|
||||
}
|
||||
|
||||
if err := game.Transition(input.ExpectedFrom, input.To, input.Trigger); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Game(input.GameID)
|
||||
member := input.GameID.String()
|
||||
at := input.At.UTC()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
payload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
return game.ErrNotFound
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("update game status: %w", getErr)
|
||||
}
|
||||
|
||||
existing, err := UnmarshalGame(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update game status: %w", err)
|
||||
}
|
||||
if existing.Status != input.ExpectedFrom {
|
||||
return fmt.Errorf("update game status: %w", game.ErrConflict)
|
||||
}
|
||||
|
||||
existing.Status = input.To
|
||||
existing.UpdatedAt = at
|
||||
if input.To == game.StatusRunning && existing.StartedAt == nil {
|
||||
startedAt := at
|
||||
existing.StartedAt = &startedAt
|
||||
}
|
||||
if input.To == game.StatusFinished && existing.FinishedAt == nil {
|
||||
finishedAt := at
|
||||
existing.FinishedAt = &finishedAt
|
||||
}
|
||||
|
||||
encoded, err := MarshalGame(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update game status: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, encoded, GameRecordTTL)
|
||||
pipe.ZRem(ctx, store.keys.GamesByStatus(input.ExpectedFrom), member)
|
||||
pipe.ZAdd(ctx, store.keys.GamesByStatus(input.To), redis.Z{
|
||||
Score: CreatedAtScore(existing.CreatedAt),
|
||||
Member: member,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("update game status: %w", game.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateRuntimeSnapshot overwrites the denormalized runtime snapshot
|
||||
// fields on the record identified by input.GameID.
|
||||
func (store *GameStore) UpdateRuntimeSnapshot(ctx context.Context, input ports.UpdateRuntimeSnapshotInput) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("update runtime snapshot: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("update runtime snapshot: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("update runtime snapshot: %w", err)
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Game(input.GameID)
|
||||
at := input.At.UTC()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
payload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
return game.ErrNotFound
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("update runtime snapshot: %w", getErr)
|
||||
}
|
||||
|
||||
existing, err := UnmarshalGame(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime snapshot: %w", err)
|
||||
}
|
||||
|
||||
existing.RuntimeSnapshot = input.Snapshot
|
||||
existing.UpdatedAt = at
|
||||
|
||||
encoded, err := MarshalGame(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime snapshot: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, encoded, GameRecordTTL)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("update runtime snapshot: %w", game.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateRuntimeBinding overwrites the runtime binding metadata on the
|
||||
// record identified by input.GameID. calls this method from
|
||||
// the runtimejobresult worker after a successful container start.
|
||||
func (store *GameStore) UpdateRuntimeBinding(ctx context.Context, input ports.UpdateRuntimeBindingInput) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("update runtime binding: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("update runtime binding: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("update runtime binding: %w", err)
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Game(input.GameID)
|
||||
at := input.At.UTC()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
payload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
return game.ErrNotFound
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("update runtime binding: %w", getErr)
|
||||
}
|
||||
|
||||
existing, err := UnmarshalGame(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime binding: %w", err)
|
||||
}
|
||||
|
||||
binding := input.Binding
|
||||
existing.RuntimeBinding = &binding
|
||||
existing.UpdatedAt = at
|
||||
|
||||
encoded, err := MarshalGame(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime binding: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, encoded, GameRecordTTL)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("update runtime binding: %w", game.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure GameStore satisfies the ports.GameStore interface at compile
|
||||
// time.
|
||||
var _ ports.GameStore = (*GameStore)(nil)
|
||||
@@ -0,0 +1,557 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) (*redisstate.GameStore, *miniredis.Miniredis, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
})
|
||||
|
||||
store, err := redisstate.NewGameStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store, server, client
|
||||
}
|
||||
|
||||
func fixtureGame(t *testing.T) game.Game {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
record, err := game.New(game.NewGameInput{
|
||||
GameID: common.GameID("game-1"),
|
||||
GameName: "Spring Classic",
|
||||
Description: "first public game",
|
||||
GameType: game.GameTypePublic,
|
||||
MinPlayers: 4,
|
||||
MaxPlayers: 8,
|
||||
StartGapHours: 24,
|
||||
StartGapPlayers: 2,
|
||||
EnrollmentEndsAt: now.Add(7 * 24 * time.Hour),
|
||||
TurnSchedule: "0 18 * * *",
|
||||
TargetEngineVersion: "v1.2.3",
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func statusIndexMembers(t *testing.T, client *redis.Client, status game.Status) []string {
|
||||
t.Helper()
|
||||
|
||||
members, err := client.ZRange(context.Background(), "lobby:games_by_status:"+base64URL(string(status)), 0, -1).Result()
|
||||
require.NoError(t, err)
|
||||
return members
|
||||
}
|
||||
|
||||
func TestNewGameStoreRejectsNilClient(t *testing.T) {
|
||||
_, err := redisstate.NewGameStore(nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGameStoreSaveAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
got, err := store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.GameID, got.GameID)
|
||||
assert.Equal(t, record.Status, got.Status)
|
||||
assert.Equal(t, record.GameName, got.GameName)
|
||||
assert.Equal(t, record.MinPlayers, got.MinPlayers)
|
||||
assert.Equal(t, record.MaxPlayers, got.MaxPlayers)
|
||||
assert.Equal(t, record.EnrollmentEndsAt.Unix(), got.EnrollmentEndsAt.Unix())
|
||||
|
||||
members := statusIndexMembers(t, client, game.StatusDraft)
|
||||
assert.Contains(t, members, record.GameID.String())
|
||||
}
|
||||
|
||||
func TestGameStoreGetReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
_, err := store.Get(ctx, common.GameID("game-missing"))
|
||||
require.ErrorIs(t, err, game.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestGameStoreSaveRewritesStatusIndexOnStatusChange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
record.Status = game.StatusEnrollmentOpen
|
||||
record.UpdatedAt = record.UpdatedAt.Add(time.Minute)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
assert.Empty(t, statusIndexMembers(t, client, game.StatusDraft))
|
||||
assert.Contains(t, statusIndexMembers(t, client, game.StatusEnrollmentOpen), record.GameID.String())
|
||||
}
|
||||
|
||||
func TestGameStoreCountByStatusReturnsAllBuckets(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
record1 := fixtureGame(t)
|
||||
record1.GameID = common.GameID("game-count-a")
|
||||
|
||||
record2 := fixtureGame(t)
|
||||
record2.GameID = common.GameID("game-count-b")
|
||||
record2.CreatedAt = record2.CreatedAt.Add(time.Second)
|
||||
record2.UpdatedAt = record2.CreatedAt
|
||||
|
||||
record3 := fixtureGame(t)
|
||||
record3.GameID = common.GameID("game-count-c")
|
||||
record3.Status = game.StatusEnrollmentOpen
|
||||
|
||||
for _, record := range []game.Game{record1, record2, record3} {
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
}
|
||||
|
||||
counts, err := store.CountByStatus(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, status := range game.AllStatuses() {
|
||||
_, present := counts[status]
|
||||
require.True(t, present, "expected %s bucket", status)
|
||||
}
|
||||
require.Equal(t, 2, counts[game.StatusDraft])
|
||||
require.Equal(t, 1, counts[game.StatusEnrollmentOpen])
|
||||
require.Equal(t, 0, counts[game.StatusRunning])
|
||||
}
|
||||
|
||||
func TestGameStoreGetByStatusReturnsMatchingRecords(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
record1 := fixtureGame(t)
|
||||
record1.GameID = common.GameID("game-a")
|
||||
|
||||
record2 := fixtureGame(t)
|
||||
record2.GameID = common.GameID("game-b")
|
||||
record2.CreatedAt = record2.CreatedAt.Add(time.Second)
|
||||
record2.UpdatedAt = record2.CreatedAt
|
||||
|
||||
record3 := fixtureGame(t)
|
||||
record3.GameID = common.GameID("game-c")
|
||||
record3.Status = game.StatusEnrollmentOpen
|
||||
|
||||
for _, record := range []game.Game{record1, record2, record3} {
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
}
|
||||
|
||||
drafts, err := store.GetByStatus(ctx, game.StatusDraft)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, drafts, 2)
|
||||
gotIDs := []string{drafts[0].GameID.String(), drafts[1].GameID.String()}
|
||||
assert.Contains(t, gotIDs, record1.GameID.String())
|
||||
assert.Contains(t, gotIDs, record2.GameID.String())
|
||||
|
||||
enrollment, err := store.GetByStatus(ctx, game.StatusEnrollmentOpen)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, enrollment, 1)
|
||||
assert.Equal(t, record3.GameID, enrollment[0].GameID)
|
||||
|
||||
running, err := store.GetByStatus(ctx, game.StatusRunning)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, running)
|
||||
}
|
||||
|
||||
func TestGameStoreGetByOwnerReturnsOwnedGames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
record1, err := game.New(game.NewGameInput{
|
||||
GameID: common.GameID("game-priv-a"),
|
||||
GameName: "Owner A first",
|
||||
GameType: game.GameTypePrivate,
|
||||
OwnerUserID: "user-owner-a",
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 1,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(48 * time.Hour),
|
||||
TurnSchedule: "0 18 * * *",
|
||||
TargetEngineVersion: "v1.0.0",
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
record2, err := game.New(game.NewGameInput{
|
||||
GameID: common.GameID("game-priv-b"),
|
||||
GameName: "Owner A second",
|
||||
GameType: game.GameTypePrivate,
|
||||
OwnerUserID: "user-owner-a",
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 1,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(48 * time.Hour),
|
||||
TurnSchedule: "0 18 * * *",
|
||||
TargetEngineVersion: "v1.0.0",
|
||||
Now: now.Add(time.Second),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
record3, err := game.New(game.NewGameInput{
|
||||
GameID: common.GameID("game-priv-c"),
|
||||
GameName: "Owner B",
|
||||
GameType: game.GameTypePrivate,
|
||||
OwnerUserID: "user-owner-b",
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 1,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(48 * time.Hour),
|
||||
TurnSchedule: "0 18 * * *",
|
||||
TargetEngineVersion: "v1.0.0",
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
publicRecord := fixtureGame(t)
|
||||
|
||||
for _, record := range []game.Game{record1, record2, record3, publicRecord} {
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
}
|
||||
|
||||
ownerA, err := store.GetByOwner(ctx, "user-owner-a")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ownerA, 2)
|
||||
|
||||
ownerB, err := store.GetByOwner(ctx, "user-owner-b")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ownerB, 1)
|
||||
assert.Equal(t, record3.GameID, ownerB[0].GameID)
|
||||
|
||||
ownerNone, err := store.GetByOwner(ctx, "user-owner-none")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, ownerNone)
|
||||
}
|
||||
|
||||
func TestGameStoreGetByStatusDropsStaleIndexEntries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server, _ := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
// Delete the primary key out-of-band, leaving the index entry stale.
|
||||
server.Del("lobby:games:" + base64URL(record.GameID.String()))
|
||||
|
||||
records, err := store.GetByStatus(ctx, game.StatusDraft)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records)
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateStatusValidTransition(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := record.CreatedAt.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
ExpectedFrom: game.StatusDraft,
|
||||
To: game.StatusEnrollmentOpen,
|
||||
Trigger: game.TriggerCommand,
|
||||
At: at,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusEnrollmentOpen, got.Status)
|
||||
assert.True(t, got.UpdatedAt.Equal(at.UTC()))
|
||||
assert.Nil(t, got.StartedAt)
|
||||
assert.Nil(t, got.FinishedAt)
|
||||
|
||||
assert.Empty(t, statusIndexMembers(t, client, game.StatusDraft))
|
||||
assert.Contains(t, statusIndexMembers(t, client, game.StatusEnrollmentOpen), record.GameID.String())
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateStatusSetsStartedAtAndFinishedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
record.Status = game.StatusStarting
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
startedAt := record.CreatedAt.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
ExpectedFrom: game.StatusStarting,
|
||||
To: game.StatusRunning,
|
||||
Trigger: game.TriggerRuntimeEvent,
|
||||
At: startedAt,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusRunning, got.Status)
|
||||
require.NotNil(t, got.StartedAt)
|
||||
assert.True(t, got.StartedAt.Equal(startedAt.UTC()))
|
||||
assert.Nil(t, got.FinishedAt)
|
||||
|
||||
finishedAt := startedAt.Add(2 * time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
ExpectedFrom: game.StatusRunning,
|
||||
To: game.StatusFinished,
|
||||
Trigger: game.TriggerRuntimeEvent,
|
||||
At: finishedAt,
|
||||
}))
|
||||
|
||||
got, err = store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusFinished, got.Status)
|
||||
require.NotNil(t, got.StartedAt)
|
||||
assert.True(t, got.StartedAt.Equal(startedAt.UTC()))
|
||||
require.NotNil(t, got.FinishedAt)
|
||||
assert.True(t, got.FinishedAt.Equal(finishedAt.UTC()))
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateStatusRejectsInvalidTransitionWithoutMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
ExpectedFrom: game.StatusDraft,
|
||||
To: game.StatusRunning,
|
||||
Trigger: game.TriggerCommand,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, game.ErrInvalidTransition))
|
||||
|
||||
got, err := store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, game.StatusDraft, got.Status)
|
||||
assert.True(t, got.UpdatedAt.Equal(record.UpdatedAt))
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateStatusRejectsWrongTrigger(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
ExpectedFrom: game.StatusDraft,
|
||||
To: game.StatusEnrollmentOpen,
|
||||
Trigger: game.TriggerDeadline,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, game.ErrInvalidTransition))
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
ExpectedFrom: game.StatusEnrollmentOpen,
|
||||
To: game.StatusReadyToStart,
|
||||
Trigger: game.TriggerManual,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, game.ErrConflict))
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateStatusReturnsNotFoundForMissingRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: common.GameID("game-missing"),
|
||||
ExpectedFrom: game.StatusDraft,
|
||||
To: game.StatusEnrollmentOpen,
|
||||
Trigger: game.TriggerCommand,
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateRuntimeSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
record.Status = game.StatusRunning
|
||||
startedAt := record.CreatedAt.Add(time.Hour)
|
||||
record.StartedAt = &startedAt
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := startedAt.Add(10 * time.Minute)
|
||||
require.NoError(t, store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{
|
||||
GameID: record.GameID,
|
||||
Snapshot: game.RuntimeSnapshot{
|
||||
CurrentTurn: 5,
|
||||
RuntimeStatus: "running_accepting_commands",
|
||||
EngineHealthSummary: "ok",
|
||||
},
|
||||
At: at,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5, got.RuntimeSnapshot.CurrentTurn)
|
||||
assert.Equal(t, "running_accepting_commands", got.RuntimeSnapshot.RuntimeStatus)
|
||||
assert.Equal(t, "ok", got.RuntimeSnapshot.EngineHealthSummary)
|
||||
assert.True(t, got.UpdatedAt.Equal(at.UTC()))
|
||||
assert.Equal(t, game.StatusRunning, got.Status)
|
||||
|
||||
assert.Contains(t, statusIndexMembers(t, client, game.StatusRunning), record.GameID.String())
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateRuntimeSnapshotReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
err := store.UpdateRuntimeSnapshot(ctx, ports.UpdateRuntimeSnapshotInput{
|
||||
GameID: common.GameID("game-missing"),
|
||||
Snapshot: game.RuntimeSnapshot{},
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateRuntimeBinding(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
record.Status = game.StatusStarting
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
bound := record.CreatedAt.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateRuntimeBinding(ctx, ports.UpdateRuntimeBindingInput{
|
||||
GameID: record.GameID,
|
||||
Binding: game.RuntimeBinding{
|
||||
ContainerID: "container-1",
|
||||
EngineEndpoint: "engine.local:9000",
|
||||
RuntimeJobID: "1700000000000-0",
|
||||
BoundAt: bound,
|
||||
},
|
||||
At: bound,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.RuntimeBinding)
|
||||
assert.Equal(t, "container-1", got.RuntimeBinding.ContainerID)
|
||||
assert.Equal(t, "engine.local:9000", got.RuntimeBinding.EngineEndpoint)
|
||||
assert.Equal(t, "1700000000000-0", got.RuntimeBinding.RuntimeJobID)
|
||||
assert.True(t, got.RuntimeBinding.BoundAt.Equal(bound.UTC()))
|
||||
assert.Equal(t, game.StatusStarting, got.Status, "binding update must not change status")
|
||||
assert.True(t, got.UpdatedAt.Equal(bound.UTC()))
|
||||
}
|
||||
|
||||
func TestGameStoreUpdateRuntimeBindingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newTestStore(t)
|
||||
|
||||
err := store.UpdateRuntimeBinding(ctx, ports.UpdateRuntimeBindingInput{
|
||||
GameID: common.GameID("game-missing"),
|
||||
Binding: game.RuntimeBinding{
|
||||
ContainerID: "container-1",
|
||||
EngineEndpoint: "engine.local:9000",
|
||||
RuntimeJobID: "1700000000000-0",
|
||||
BoundAt: time.Now().UTC(),
|
||||
},
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, game.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestGameStoreConcurrentUpdateStatusHasExactlyOneWinner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newTestStore(t)
|
||||
|
||||
record := fixtureGame(t)
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
storeA, err := redisstate.NewGameStore(client)
|
||||
require.NoError(t, err)
|
||||
storeB, err := redisstate.NewGameStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
successes atomic.Int32
|
||||
conflicts atomic.Int32
|
||||
others atomic.Int32
|
||||
)
|
||||
|
||||
apply := func(target *redisstate.GameStore) {
|
||||
defer wg.Done()
|
||||
err := target.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: record.GameID,
|
||||
ExpectedFrom: game.StatusDraft,
|
||||
To: game.StatusEnrollmentOpen,
|
||||
Trigger: game.TriggerCommand,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
})
|
||||
switch {
|
||||
case err == nil:
|
||||
successes.Add(1)
|
||||
case errors.Is(err, game.ErrConflict):
|
||||
conflicts.Add(1)
|
||||
default:
|
||||
others.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(2)
|
||||
go apply(storeA)
|
||||
go apply(storeB)
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, int32(0), others.Load(), "unexpected non-conflict error")
|
||||
assert.Equal(t, int32(1), successes.Load(), "expected exactly one success")
|
||||
assert.Equal(t, int32(1), conflicts.Load(), "expected exactly one conflict")
|
||||
}
|
||||
|
||||
// base64URL mirrors the private key-segment encoding used by Keyspace.
|
||||
// The tests use it to assert on exact Redis key shapes.
|
||||
func base64URL(value string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// saveInitialPlayerStatsScript stores the JSON aggregate under the primary
|
||||
// key only when no aggregate exists yet for the user. The script also
|
||||
// records the user id in the per-game lookup set so Load and Delete avoid
|
||||
// scanning the keyspace. Inputs:
|
||||
//
|
||||
// KEYS[1] — primary aggregate key
|
||||
// KEYS[2] — per-game lookup set key
|
||||
// ARGV[1] — user id stored in the lookup set
|
||||
// ARGV[2] — JSON payload to store on first observation
|
||||
//
|
||||
// Returns 1 when the script wrote the payload and 0 when the user already
|
||||
// had an aggregate.
|
||||
const saveInitialPlayerStatsScript = `
|
||||
local primaryKey = KEYS[1]
|
||||
local byGameKey = KEYS[2]
|
||||
local userID = ARGV[1]
|
||||
local payload = ARGV[2]
|
||||
|
||||
local existing = redis.call('GET', primaryKey)
|
||||
if existing then
|
||||
return 0
|
||||
end
|
||||
redis.call('SET', primaryKey, payload)
|
||||
redis.call('SADD', byGameKey, userID)
|
||||
return 1
|
||||
`
|
||||
|
||||
// updateMaxPlayerStatsScript updates the running maxima for the user in
|
||||
// place. When no aggregate exists yet the script seeds one whose initial
|
||||
// fields and max fields both equal the observation. The script always
|
||||
// keeps the max fields monotonically non-decreasing. Inputs:
|
||||
//
|
||||
// KEYS[1] — primary aggregate key
|
||||
// KEYS[2] — per-game lookup set key
|
||||
// ARGV[1] — user id stored in the lookup set
|
||||
// ARGV[2] — observed planets
|
||||
// ARGV[3] — observed population
|
||||
// ARGV[4] — observed ships built
|
||||
// ARGV[5] — JSON payload to seed when no aggregate exists yet
|
||||
//
|
||||
// Returns 1 when a new aggregate was created and 0 otherwise.
|
||||
const updateMaxPlayerStatsScript = `
|
||||
local primaryKey = KEYS[1]
|
||||
local byGameKey = KEYS[2]
|
||||
local userID = ARGV[1]
|
||||
local newPlanets = tonumber(ARGV[2])
|
||||
local newPopulation = tonumber(ARGV[3])
|
||||
local newShipsBuilt = tonumber(ARGV[4])
|
||||
local freshPayload = ARGV[5]
|
||||
|
||||
local existing = redis.call('GET', primaryKey)
|
||||
if not existing then
|
||||
redis.call('SET', primaryKey, freshPayload)
|
||||
redis.call('SADD', byGameKey, userID)
|
||||
return 1
|
||||
end
|
||||
|
||||
local data = cjson.decode(existing)
|
||||
local changed = false
|
||||
if newPlanets > data.max_planets then
|
||||
data.max_planets = newPlanets
|
||||
changed = true
|
||||
end
|
||||
if newPopulation > data.max_population then
|
||||
data.max_population = newPopulation
|
||||
changed = true
|
||||
end
|
||||
if newShipsBuilt > data.max_ships_built then
|
||||
data.max_ships_built = newShipsBuilt
|
||||
changed = true
|
||||
end
|
||||
if changed then
|
||||
redis.call('SET', primaryKey, cjson.encode(data))
|
||||
end
|
||||
return 0
|
||||
`
|
||||
|
||||
// GameTurnStatsStore is the Redis-backed implementation of
|
||||
// ports.GameTurnStatsStore. It keeps one JSON aggregate per (game, user)
|
||||
// at the GameTurnStat key and indexes the user ids in a per-game set so
|
||||
// Load and Delete reach every entry without scanning the full keyspace.
|
||||
type GameTurnStatsStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
saveInitialLua *redis.Script
|
||||
updateMaxLua *redis.Script
|
||||
}
|
||||
|
||||
// NewGameTurnStatsStore constructs one Redis-backed GameTurnStatsStore.
|
||||
func NewGameTurnStatsStore(client *redis.Client) (*GameTurnStatsStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new game turn stats store: nil redis client")
|
||||
}
|
||||
return &GameTurnStatsStore{
|
||||
client: client,
|
||||
keys: Keyspace{},
|
||||
saveInitialLua: redis.NewScript(saveInitialPlayerStatsScript),
|
||||
updateMaxLua: redis.NewScript(updateMaxPlayerStatsScript),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveInitial freezes the initial fields for every user in stats. The
|
||||
// script in Redis enforces the «first observation wins» invariant per
|
||||
// user; later calls observe an existing aggregate and return without
|
||||
// writes.
|
||||
func (store *GameTurnStatsStore) SaveInitial(ctx context.Context, gameID common.GameID, stats []ports.PlayerInitialStats) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("save initial player stats: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("save initial player stats: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return fmt.Errorf("save initial player stats: %w", err)
|
||||
}
|
||||
for _, line := range stats {
|
||||
if err := line.Validate(); err != nil {
|
||||
return fmt.Errorf("save initial player stats: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
byGameKey := store.keys.GameTurnStatsByGame(gameID)
|
||||
for _, line := range stats {
|
||||
primaryKey := store.keys.GameTurnStat(gameID, line.UserID)
|
||||
payload, err := MarshalPlayerStats(ports.PlayerStatsAggregate{
|
||||
UserID: line.UserID,
|
||||
InitialPlanets: line.Planets,
|
||||
InitialPopulation: line.Population,
|
||||
InitialShipsBuilt: line.ShipsBuilt,
|
||||
MaxPlanets: line.Planets,
|
||||
MaxPopulation: line.Population,
|
||||
MaxShipsBuilt: line.ShipsBuilt,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("save initial player stats: %w", err)
|
||||
}
|
||||
if _, err := store.saveInitialLua.Run(
|
||||
ctx, store.client,
|
||||
[]string{primaryKey, byGameKey},
|
||||
line.UserID, string(payload),
|
||||
).Result(); err != nil {
|
||||
return fmt.Errorf("save initial player stats: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateMax updates the per-user max fields by per-component maximum. New
|
||||
// users observed for the first time receive an aggregate whose initial
|
||||
// fields and max fields both equal the observation, so callers never need
|
||||
// to invoke SaveInitial first to keep state consistent.
|
||||
func (store *GameTurnStatsStore) UpdateMax(ctx context.Context, gameID common.GameID, stats []ports.PlayerObservedStats) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("update max player stats: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("update max player stats: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return fmt.Errorf("update max player stats: %w", err)
|
||||
}
|
||||
for _, line := range stats {
|
||||
if err := line.Validate(); err != nil {
|
||||
return fmt.Errorf("update max player stats: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
byGameKey := store.keys.GameTurnStatsByGame(gameID)
|
||||
for _, line := range stats {
|
||||
primaryKey := store.keys.GameTurnStat(gameID, line.UserID)
|
||||
freshPayload, err := MarshalPlayerStats(ports.PlayerStatsAggregate{
|
||||
UserID: line.UserID,
|
||||
InitialPlanets: line.Planets,
|
||||
InitialPopulation: line.Population,
|
||||
InitialShipsBuilt: line.ShipsBuilt,
|
||||
MaxPlanets: line.Planets,
|
||||
MaxPopulation: line.Population,
|
||||
MaxShipsBuilt: line.ShipsBuilt,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("update max player stats: %w", err)
|
||||
}
|
||||
if _, err := store.updateMaxLua.Run(
|
||||
ctx, store.client,
|
||||
[]string{primaryKey, byGameKey},
|
||||
line.UserID,
|
||||
line.Planets,
|
||||
line.Population,
|
||||
line.ShipsBuilt,
|
||||
string(freshPayload),
|
||||
).Result(); err != nil {
|
||||
return fmt.Errorf("update max player stats: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load returns the GameTurnStatsAggregate for gameID. The Players slice is
|
||||
// sorted by UserID ascending so capability evaluation produces
|
||||
// deterministic side-effect order on replay.
|
||||
func (store *GameTurnStatsStore) Load(ctx context.Context, gameID common.GameID) (ports.GameTurnStatsAggregate, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return ports.GameTurnStatsAggregate{}, errors.New("load player stats: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return ports.GameTurnStatsAggregate{}, errors.New("load player stats: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return ports.GameTurnStatsAggregate{}, fmt.Errorf("load player stats: %w", err)
|
||||
}
|
||||
|
||||
byGameKey := store.keys.GameTurnStatsByGame(gameID)
|
||||
userIDs, err := store.client.SMembers(ctx, byGameKey).Result()
|
||||
if err != nil {
|
||||
return ports.GameTurnStatsAggregate{}, fmt.Errorf("load player stats: %w", err)
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return ports.GameTurnStatsAggregate{GameID: gameID}, nil
|
||||
}
|
||||
sort.Strings(userIDs)
|
||||
|
||||
keys := make([]string, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
keys = append(keys, store.keys.GameTurnStat(gameID, userID))
|
||||
}
|
||||
payloads, err := store.client.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return ports.GameTurnStatsAggregate{}, fmt.Errorf("load player stats: %w", err)
|
||||
}
|
||||
|
||||
players := make([]ports.PlayerStatsAggregate, 0, len(payloads))
|
||||
for index, raw := range payloads {
|
||||
if raw == nil {
|
||||
continue
|
||||
}
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
return ports.GameTurnStatsAggregate{}, fmt.Errorf("load player stats: unexpected payload type for %s", userIDs[index])
|
||||
}
|
||||
aggregate, err := UnmarshalPlayerStats([]byte(text))
|
||||
if err != nil {
|
||||
return ports.GameTurnStatsAggregate{}, fmt.Errorf("load player stats: %w", err)
|
||||
}
|
||||
players = append(players, aggregate)
|
||||
}
|
||||
return ports.GameTurnStatsAggregate{GameID: gameID, Players: players}, nil
|
||||
}
|
||||
|
||||
// Delete removes every aggregate entry for gameID and the per-game lookup
|
||||
// set itself. It is a no-op when no entries exist.
|
||||
func (store *GameTurnStatsStore) Delete(ctx context.Context, gameID common.GameID) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("delete player stats: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("delete player stats: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return fmt.Errorf("delete player stats: %w", err)
|
||||
}
|
||||
|
||||
byGameKey := store.keys.GameTurnStatsByGame(gameID)
|
||||
userIDs, err := store.client.SMembers(ctx, byGameKey).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete player stats: %w", err)
|
||||
}
|
||||
|
||||
pipeline := store.client.Pipeline()
|
||||
for _, userID := range userIDs {
|
||||
pipeline.Del(ctx, store.keys.GameTurnStat(gameID, userID))
|
||||
}
|
||||
pipeline.Del(ctx, byGameKey)
|
||||
if _, err := pipeline.Exec(ctx); err != nil {
|
||||
return fmt.Errorf("delete player stats: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.GameTurnStatsStore = (*GameTurnStatsStore)(nil)
|
||||
@@ -0,0 +1,184 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newGameTurnStatsStore(t *testing.T) (*redisstate.GameTurnStatsStore, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
store, err := redisstate.NewGameTurnStatsStore(client)
|
||||
require.NoError(t, err)
|
||||
return store, server
|
||||
}
|
||||
|
||||
func TestGameTurnStatsStoreSaveInitialFreezesValues(t *testing.T) {
|
||||
store, _ := newGameTurnStatsStore(t)
|
||||
ctx := context.Background()
|
||||
gameID := common.GameID("game-stats-1")
|
||||
|
||||
require.NoError(t, store.SaveInitial(ctx, gameID, []ports.PlayerInitialStats{
|
||||
{UserID: "user-a", Planets: 3, Population: 100, ShipsBuilt: 7},
|
||||
}))
|
||||
|
||||
require.NoError(t, store.SaveInitial(ctx, gameID, []ports.PlayerInitialStats{
|
||||
{UserID: "user-a", Planets: 99, Population: 9999, ShipsBuilt: 999},
|
||||
}))
|
||||
|
||||
aggregate, err := store.Load(ctx, gameID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, aggregate.Players, 1)
|
||||
assert.Equal(t, int64(3), aggregate.Players[0].InitialPlanets)
|
||||
assert.Equal(t, int64(100), aggregate.Players[0].InitialPopulation)
|
||||
assert.Equal(t, int64(7), aggregate.Players[0].InitialShipsBuilt)
|
||||
assert.Equal(t, int64(3), aggregate.Players[0].MaxPlanets)
|
||||
assert.Equal(t, int64(100), aggregate.Players[0].MaxPopulation)
|
||||
assert.Equal(t, int64(7), aggregate.Players[0].MaxShipsBuilt)
|
||||
}
|
||||
|
||||
func TestGameTurnStatsStoreUpdateMaxRaisesOnly(t *testing.T) {
|
||||
store, _ := newGameTurnStatsStore(t)
|
||||
ctx := context.Background()
|
||||
gameID := common.GameID("game-stats-2")
|
||||
|
||||
require.NoError(t, store.SaveInitial(ctx, gameID, []ports.PlayerInitialStats{
|
||||
{UserID: "user-a", Planets: 3, Population: 100, ShipsBuilt: 7},
|
||||
}))
|
||||
|
||||
require.NoError(t, store.UpdateMax(ctx, gameID, []ports.PlayerObservedStats{
|
||||
{UserID: "user-a", Planets: 5, Population: 80, ShipsBuilt: 9},
|
||||
}))
|
||||
|
||||
require.NoError(t, store.UpdateMax(ctx, gameID, []ports.PlayerObservedStats{
|
||||
{UserID: "user-a", Planets: 4, Population: 60, ShipsBuilt: 8},
|
||||
}))
|
||||
|
||||
aggregate, err := store.Load(ctx, gameID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, aggregate.Players, 1)
|
||||
assert.Equal(t, int64(3), aggregate.Players[0].InitialPlanets)
|
||||
assert.Equal(t, int64(100), aggregate.Players[0].InitialPopulation)
|
||||
assert.Equal(t, int64(7), aggregate.Players[0].InitialShipsBuilt)
|
||||
assert.Equal(t, int64(5), aggregate.Players[0].MaxPlanets)
|
||||
assert.Equal(t, int64(100), aggregate.Players[0].MaxPopulation)
|
||||
assert.Equal(t, int64(9), aggregate.Players[0].MaxShipsBuilt)
|
||||
}
|
||||
|
||||
func TestGameTurnStatsStoreUpdateMaxBeforeSaveInitial(t *testing.T) {
|
||||
store, _ := newGameTurnStatsStore(t)
|
||||
ctx := context.Background()
|
||||
gameID := common.GameID("game-stats-3")
|
||||
|
||||
require.NoError(t, store.UpdateMax(ctx, gameID, []ports.PlayerObservedStats{
|
||||
{UserID: "user-a", Planets: 4, Population: 50, ShipsBuilt: 1},
|
||||
}))
|
||||
|
||||
require.NoError(t, store.SaveInitial(ctx, gameID, []ports.PlayerInitialStats{
|
||||
{UserID: "user-a", Planets: 99, Population: 99, ShipsBuilt: 99},
|
||||
}))
|
||||
|
||||
aggregate, err := store.Load(ctx, gameID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, aggregate.Players, 1)
|
||||
assert.Equal(t, int64(4), aggregate.Players[0].InitialPlanets)
|
||||
assert.Equal(t, int64(50), aggregate.Players[0].InitialPopulation)
|
||||
assert.Equal(t, int64(1), aggregate.Players[0].InitialShipsBuilt)
|
||||
assert.Equal(t, int64(4), aggregate.Players[0].MaxPlanets)
|
||||
assert.Equal(t, int64(50), aggregate.Players[0].MaxPopulation)
|
||||
assert.Equal(t, int64(1), aggregate.Players[0].MaxShipsBuilt)
|
||||
}
|
||||
|
||||
func TestGameTurnStatsStoreLoadEmpty(t *testing.T) {
|
||||
store, _ := newGameTurnStatsStore(t)
|
||||
gameID := common.GameID("game-stats-empty")
|
||||
|
||||
aggregate, err := store.Load(context.Background(), gameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gameID, aggregate.GameID)
|
||||
assert.Empty(t, aggregate.Players)
|
||||
}
|
||||
|
||||
func TestGameTurnStatsStoreLoadSortsByUserID(t *testing.T) {
|
||||
store, _ := newGameTurnStatsStore(t)
|
||||
ctx := context.Background()
|
||||
gameID := common.GameID("game-stats-sorted")
|
||||
|
||||
require.NoError(t, store.SaveInitial(ctx, gameID, []ports.PlayerInitialStats{
|
||||
{UserID: "user-c", Planets: 1, Population: 1, ShipsBuilt: 1},
|
||||
{UserID: "user-a", Planets: 2, Population: 2, ShipsBuilt: 2},
|
||||
{UserID: "user-b", Planets: 3, Population: 3, ShipsBuilt: 3},
|
||||
}))
|
||||
|
||||
aggregate, err := store.Load(ctx, gameID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, aggregate.Players, 3)
|
||||
|
||||
got := []string{aggregate.Players[0].UserID, aggregate.Players[1].UserID, aggregate.Players[2].UserID}
|
||||
expected := []string{"user-a", "user-b", "user-c"}
|
||||
require.True(t, sort.StringsAreSorted(got))
|
||||
assert.Equal(t, expected, got)
|
||||
}
|
||||
|
||||
func TestGameTurnStatsStoreDeleteRemovesEverything(t *testing.T) {
|
||||
store, server := newGameTurnStatsStore(t)
|
||||
ctx := context.Background()
|
||||
gameID := common.GameID("game-stats-del")
|
||||
|
||||
require.NoError(t, store.SaveInitial(ctx, gameID, []ports.PlayerInitialStats{
|
||||
{UserID: "user-a", Planets: 1, Population: 1, ShipsBuilt: 1},
|
||||
{UserID: "user-b", Planets: 2, Population: 2, ShipsBuilt: 2},
|
||||
}))
|
||||
|
||||
require.NoError(t, store.Delete(ctx, gameID))
|
||||
|
||||
aggregate, err := store.Load(ctx, gameID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, aggregate.Players)
|
||||
|
||||
keyspace := redisstate.Keyspace{}
|
||||
assert.False(t, server.Exists(keyspace.GameTurnStatsByGame(gameID)))
|
||||
assert.False(t, server.Exists(keyspace.GameTurnStat(gameID, "user-a")))
|
||||
assert.False(t, server.Exists(keyspace.GameTurnStat(gameID, "user-b")))
|
||||
}
|
||||
|
||||
func TestGameTurnStatsStoreDeleteIsIdempotent(t *testing.T) {
|
||||
store, _ := newGameTurnStatsStore(t)
|
||||
ctx := context.Background()
|
||||
gameID := common.GameID("game-stats-del-noop")
|
||||
|
||||
require.NoError(t, store.Delete(ctx, gameID))
|
||||
require.NoError(t, store.Delete(ctx, gameID))
|
||||
}
|
||||
|
||||
func TestGameTurnStatsStoreRejectsInvalidInputs(t *testing.T) {
|
||||
store, _ := newGameTurnStatsStore(t)
|
||||
ctx := context.Background()
|
||||
gameID := common.GameID("game-stats-bad")
|
||||
|
||||
err := store.SaveInitial(ctx, gameID, []ports.PlayerInitialStats{
|
||||
{UserID: "", Planets: 1, Population: 1, ShipsBuilt: 1},
|
||||
})
|
||||
assert.Error(t, err)
|
||||
|
||||
err = store.UpdateMax(ctx, gameID, []ports.PlayerObservedStats{
|
||||
{UserID: "user-a", Planets: -1, Population: 1, ShipsBuilt: 1},
|
||||
})
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = store.Load(ctx, common.GameID(""))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// GapActivationRecordTTL is the Redis retention applied to gap activation
|
||||
// timestamps. uses zero (no expiry); the worker that consumes
|
||||
// these records will revisit retention when the surface
|
||||
// stabilizes.
|
||||
const GapActivationRecordTTL time.Duration = 0
|
||||
|
||||
// gapActivationRecord stores the strict Redis JSON shape used for one
|
||||
// gap-window activation timestamp.
|
||||
type gapActivationRecord struct {
|
||||
ActivatedAtMS int64 `json:"activated_at_ms"`
|
||||
}
|
||||
|
||||
// GapActivationStore provides Redis-backed durable storage for gap-window
|
||||
// activation timestamps used by enrollment automation.
|
||||
type GapActivationStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewGapActivationStore constructs one Redis-backed gap activation store.
|
||||
// It returns an error when client is nil.
|
||||
func NewGapActivationStore(client *redis.Client) (*GapActivationStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new gap activation store: nil redis client")
|
||||
}
|
||||
return &GapActivationStore{client: client, keys: Keyspace{}}, nil
|
||||
}
|
||||
|
||||
// MarkActivated writes at as the gap activation timestamp for gameID iff
|
||||
// no prior activation exists. A second call is a silent no-op.
|
||||
func (store *GapActivationStore) MarkActivated(ctx context.Context, gameID common.GameID, at time.Time) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("mark gap activation: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("mark gap activation: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return fmt.Errorf("mark gap activation: %w", err)
|
||||
}
|
||||
if at.IsZero() {
|
||||
return errors.New("mark gap activation: at must not be zero")
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(gapActivationRecord{ActivatedAtMS: at.UTC().UnixMilli()})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark gap activation: %w", err)
|
||||
}
|
||||
|
||||
args := redis.SetArgs{Mode: "NX"}
|
||||
if GapActivationRecordTTL > 0 {
|
||||
args.TTL = GapActivationRecordTTL
|
||||
}
|
||||
if _, err := store.client.SetArgs(ctx, store.keys.GapActivatedAt(gameID), payload, args).Result(); err != nil && !errors.Is(err, redis.Nil) {
|
||||
return fmt.Errorf("mark gap activation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the gap-window activation time previously recorded for
|
||||
// gameID. The second return value is false when no activation has been
|
||||
// recorded.
|
||||
func (store *GapActivationStore) Get(ctx context.Context, gameID common.GameID) (time.Time, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return time.Time{}, false, errors.New("get gap activation: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return time.Time{}, false, errors.New("get gap activation: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return time.Time{}, false, fmt.Errorf("get gap activation: %w", err)
|
||||
}
|
||||
|
||||
raw, err := store.client.Get(ctx, store.keys.GapActivatedAt(gameID)).Bytes()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
return time.Time{}, false, fmt.Errorf("get gap activation: %w", err)
|
||||
}
|
||||
|
||||
var record gapActivationRecord
|
||||
if err := json.Unmarshal(raw, &record); err != nil {
|
||||
return time.Time{}, false, fmt.Errorf("get gap activation: %w", err)
|
||||
}
|
||||
if record.ActivatedAtMS <= 0 {
|
||||
return time.Time{}, false, fmt.Errorf("get gap activation: activated_at_ms %d must be positive", record.ActivatedAtMS)
|
||||
}
|
||||
return time.UnixMilli(record.ActivatedAtMS).UTC(), true, nil
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.GapActivationStore = (*GapActivationStore)(nil)
|
||||
@@ -0,0 +1,116 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newGapActivationTestStore(t *testing.T) (*redisstate.GapActivationStore, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
store, err := redisstate.NewGapActivationStore(client)
|
||||
require.NoError(t, err)
|
||||
return store, server
|
||||
}
|
||||
|
||||
func TestNewGapActivationStoreRejectsNilClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := redisstate.NewGapActivationStore(nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMarkActivatedWritesRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store, server := newGapActivationTestStore(t)
|
||||
|
||||
at := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.MarkActivated(ctx, common.GameID("game-a"), at))
|
||||
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte("game-a"))
|
||||
stored, err := server.Get("lobby:gap_activated_at:" + encoded)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stored, "1777111200000")
|
||||
}
|
||||
|
||||
func TestMarkActivatedIsNoOpOnSecondCall(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store, server := newGapActivationTestStore(t)
|
||||
|
||||
first := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
second := first.Add(time.Hour)
|
||||
|
||||
require.NoError(t, store.MarkActivated(ctx, common.GameID("game-a"), first))
|
||||
require.NoError(t, store.MarkActivated(ctx, common.GameID("game-a"), second))
|
||||
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte("game-a"))
|
||||
stored, err := server.Get("lobby:gap_activated_at:" + encoded)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stored, "1777111200000")
|
||||
}
|
||||
|
||||
func TestMarkActivatedRejectsInvalidGameID(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store, _ := newGapActivationTestStore(t)
|
||||
|
||||
err := store.MarkActivated(ctx, common.GameID(""), time.Now().UTC())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMarkActivatedRejectsZeroTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store, _ := newGapActivationTestStore(t)
|
||||
|
||||
err := store.MarkActivated(ctx, common.GameID("game-a"), time.Time{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGapActivationStoreGetReturnsRecordedTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store, _ := newGapActivationTestStore(t)
|
||||
|
||||
at := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.MarkActivated(ctx, common.GameID("game-a"), at))
|
||||
|
||||
got, ok, err := store.Get(ctx, common.GameID("game-a"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
assert.True(t, got.Equal(at))
|
||||
}
|
||||
|
||||
func TestGapActivationStoreGetReturnsFalseWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store, _ := newGapActivationTestStore(t)
|
||||
|
||||
got, ok, err := store.Get(ctx, common.GameID("game-missing"))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
assert.True(t, got.IsZero())
|
||||
}
|
||||
|
||||
func TestGapActivationStoreGetRejectsInvalidGameID(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store, _ := newGapActivationTestStore(t)
|
||||
|
||||
_, _, err := store.Get(ctx, common.GameID(""))
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// InviteStore provides Redis-backed durable storage for invite records.
|
||||
type InviteStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewInviteStore constructs one Redis-backed invite store. It returns an
|
||||
// error when client is nil.
|
||||
func NewInviteStore(client *redis.Client) (*InviteStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new invite store: nil redis client")
|
||||
}
|
||||
|
||||
return &InviteStore{
|
||||
client: client,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Save persists a new created invite record. Save is create-only; a
|
||||
// second save against the same invite id returns invite.ErrConflict.
|
||||
func (store *InviteStore) Save(ctx context.Context, record invite.Invite) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("save invite: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("save invite: nil context")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("save invite: %w", err)
|
||||
}
|
||||
if record.Status != invite.StatusCreated {
|
||||
return fmt.Errorf(
|
||||
"save invite: status must be %q, got %q",
|
||||
invite.StatusCreated, record.Status,
|
||||
)
|
||||
}
|
||||
|
||||
payload, err := MarshalInvite(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save invite: %w", err)
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Invite(record.InviteID)
|
||||
gameIndexKey := store.keys.InvitesByGame(record.GameID)
|
||||
userIndexKey := store.keys.InvitesByUser(record.InviteeUserID)
|
||||
inviterIndexKey := store.keys.InvitesByInviter(record.InviterUserID)
|
||||
member := record.InviteID.String()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
existing, getErr := tx.Exists(ctx, primaryKey).Result()
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("save invite: %w", getErr)
|
||||
}
|
||||
if existing != 0 {
|
||||
return fmt.Errorf("save invite: %w", invite.ErrConflict)
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, payload, InviteRecordTTL)
|
||||
pipe.SAdd(ctx, gameIndexKey, member)
|
||||
pipe.SAdd(ctx, userIndexKey, member)
|
||||
pipe.SAdd(ctx, inviterIndexKey, member)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("save invite: %w", invite.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the record identified by inviteID.
|
||||
func (store *InviteStore) Get(ctx context.Context, inviteID common.InviteID) (invite.Invite, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return invite.Invite{}, errors.New("get invite: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return invite.Invite{}, errors.New("get invite: nil context")
|
||||
}
|
||||
if err := inviteID.Validate(); err != nil {
|
||||
return invite.Invite{}, fmt.Errorf("get invite: %w", err)
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.Invite(inviteID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return invite.Invite{}, invite.ErrNotFound
|
||||
case err != nil:
|
||||
return invite.Invite{}, fmt.Errorf("get invite: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalInvite(payload)
|
||||
if err != nil {
|
||||
return invite.Invite{}, fmt.Errorf("get invite: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByGame returns every invite attached to gameID.
|
||||
func (store *InviteStore) GetByGame(ctx context.Context, gameID common.GameID) ([]invite.Invite, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get invites by game: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get invites by game: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("get invites by game: %w", err)
|
||||
}
|
||||
|
||||
return store.loadInvitesBySet(ctx,
|
||||
"get invites by game",
|
||||
store.keys.InvitesByGame(gameID),
|
||||
)
|
||||
}
|
||||
|
||||
// GetByUser returns every invite addressed to inviteeUserID.
|
||||
func (store *InviteStore) GetByUser(ctx context.Context, inviteeUserID string) ([]invite.Invite, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get invites by user: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get invites by user: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(inviteeUserID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get invites by user: invitee user id must not be empty")
|
||||
}
|
||||
|
||||
return store.loadInvitesBySet(ctx,
|
||||
"get invites by user",
|
||||
store.keys.InvitesByUser(trimmed),
|
||||
)
|
||||
}
|
||||
|
||||
// GetByInviter returns every invite created by inviterUserID.
|
||||
func (store *InviteStore) GetByInviter(ctx context.Context, inviterUserID string) ([]invite.Invite, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get invites by inviter: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get invites by inviter: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(inviterUserID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get invites by inviter: inviter user id must not be empty")
|
||||
}
|
||||
|
||||
return store.loadInvitesBySet(ctx,
|
||||
"get invites by inviter",
|
||||
store.keys.InvitesByInviter(trimmed),
|
||||
)
|
||||
}
|
||||
|
||||
// loadInvitesBySet materializes invites whose ids are stored in setKey.
|
||||
// Stale set members (primary key removed out-of-band) are dropped silently.
|
||||
func (store *InviteStore) loadInvitesBySet(ctx context.Context, operation, setKey string) ([]invite.Invite, error) {
|
||||
members, err := store.client.SMembers(ctx, setKey).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
primaryKeys := make([]string, len(members))
|
||||
for index, member := range members {
|
||||
primaryKeys[index] = store.keys.Invite(common.InviteID(member))
|
||||
}
|
||||
|
||||
payloads, err := store.client.MGet(ctx, primaryKeys...).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
records := make([]invite.Invite, 0, len(payloads))
|
||||
for _, entry := range payloads {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
raw, ok := entry.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: unexpected payload type %T", operation, entry)
|
||||
}
|
||||
record, err := UnmarshalInvite([]byte(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// UpdateStatus applies one status transition in a compare-and-swap fashion.
|
||||
func (store *InviteStore) UpdateStatus(ctx context.Context, input ports.UpdateInviteStatusInput) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("update invite status: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("update invite status: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("update invite status: %w", err)
|
||||
}
|
||||
|
||||
if err := invite.Transition(input.ExpectedFrom, input.To); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Invite(input.InviteID)
|
||||
at := input.At.UTC()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
payload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
return invite.ErrNotFound
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("update invite status: %w", getErr)
|
||||
}
|
||||
|
||||
existing, err := UnmarshalInvite(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update invite status: %w", err)
|
||||
}
|
||||
if existing.Status != input.ExpectedFrom {
|
||||
return fmt.Errorf("update invite status: %w", invite.ErrConflict)
|
||||
}
|
||||
|
||||
existing.Status = input.To
|
||||
decidedAt := at
|
||||
existing.DecidedAt = &decidedAt
|
||||
if input.To == invite.StatusRedeemed {
|
||||
existing.RaceName = strings.TrimSpace(input.RaceName)
|
||||
}
|
||||
|
||||
encoded, err := MarshalInvite(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update invite status: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, encoded, InviteRecordTTL)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("update invite status: %w", invite.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure InviteStore satisfies the ports.InviteStore interface at
|
||||
// compile time.
|
||||
var _ ports.InviteStore = (*InviteStore)(nil)
|
||||
@@ -0,0 +1,363 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/invite"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newInviteTestStore(t *testing.T) (*redisstate.InviteStore, *miniredis.Miniredis, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
})
|
||||
|
||||
store, err := redisstate.NewInviteStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store, server, client
|
||||
}
|
||||
|
||||
func fixtureInvite(t *testing.T, id common.InviteID, inviter, invitee string, gameID common.GameID) invite.Invite {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
record, err := invite.New(invite.NewInviteInput{
|
||||
InviteID: id,
|
||||
GameID: gameID,
|
||||
InviterUserID: inviter,
|
||||
InviteeUserID: invitee,
|
||||
Now: now,
|
||||
ExpiresAt: now.Add(7 * 24 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return record
|
||||
}
|
||||
|
||||
func TestNewInviteStoreRejectsNilClient(t *testing.T) {
|
||||
_, err := redisstate.NewInviteStore(nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestInviteStoreSaveAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
got, err := store.Get(ctx, record.InviteID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.InviteID, got.InviteID)
|
||||
assert.Equal(t, record.InviteeUserID, got.InviteeUserID)
|
||||
assert.Equal(t, invite.StatusCreated, got.Status)
|
||||
assert.Equal(t, "", got.RaceName)
|
||||
assert.Nil(t, got.DecidedAt)
|
||||
assert.True(t, got.ExpiresAt.Equal(record.ExpiresAt))
|
||||
|
||||
byGame, err := client.SMembers(ctx, "lobby:game_invites:"+base64URL(record.GameID.String())).Result()
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{record.InviteID.String()}, byGame)
|
||||
|
||||
byUser, err := client.SMembers(ctx, "lobby:user_invites:"+base64URL(record.InviteeUserID)).Result()
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{record.InviteID.String()}, byUser)
|
||||
}
|
||||
|
||||
func TestInviteStoreGetReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
_, err := store.Get(ctx, common.InviteID("invite-missing"))
|
||||
require.ErrorIs(t, err, invite.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestInviteStoreSaveRejectsDuplicate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.Save(ctx, record)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, invite.ErrConflict))
|
||||
}
|
||||
|
||||
func TestInviteStoreSaveRejectsNonCreated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
record.Status = invite.StatusRevoked
|
||||
decidedAt := record.CreatedAt.Add(time.Minute)
|
||||
record.DecidedAt = &decidedAt
|
||||
|
||||
err := store.Save(ctx, record)
|
||||
require.Error(t, err)
|
||||
assert.False(t, errors.Is(err, invite.ErrConflict))
|
||||
}
|
||||
|
||||
func TestInviteStoreUpdateStatusRedeemSetsRaceName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := record.CreatedAt.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusRedeemed,
|
||||
At: at,
|
||||
RaceName: "Lunar Raider",
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.InviteID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, invite.StatusRedeemed, got.Status)
|
||||
assert.Equal(t, "Lunar Raider", got.RaceName)
|
||||
require.NotNil(t, got.DecidedAt)
|
||||
assert.True(t, got.DecidedAt.Equal(at.UTC()))
|
||||
}
|
||||
|
||||
func TestInviteStoreUpdateStatusTerminalTransitions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
target invite.Status
|
||||
}{
|
||||
{"declined", invite.StatusDeclined},
|
||||
{"revoked", invite.StatusRevoked},
|
||||
{"expired", invite.StatusExpired},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, common.InviteID("invite-"+tc.name), "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := record.CreatedAt.Add(30 * time.Minute)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: tc.target,
|
||||
At: at,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.InviteID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.target, got.Status)
|
||||
assert.Equal(t, "", got.RaceName)
|
||||
require.NotNil(t, got.DecidedAt)
|
||||
assert.True(t, got.DecidedAt.Equal(at.UTC()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteStoreUpdateStatusRejectsRedeemWithoutRaceName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusRedeemed,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.False(t, errors.Is(err, invite.ErrInvalidTransition))
|
||||
}
|
||||
|
||||
func TestInviteStoreUpdateStatusRejectsRaceNameOnNonRedeem(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusDeclined,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
RaceName: "Nope",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.False(t, errors.Is(err, invite.ErrInvalidTransition))
|
||||
}
|
||||
|
||||
func TestInviteStoreUpdateStatusRejectsInvalidTransitionWithoutMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusRedeemed,
|
||||
To: invite.StatusExpired,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, invite.ErrInvalidTransition))
|
||||
}
|
||||
|
||||
func TestInviteStoreUpdateStatusReturnsConflictOnExpectedFromMismatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusRevoked,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
}))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusDeclined,
|
||||
At: record.CreatedAt.Add(2 * time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, invite.ErrConflict))
|
||||
}
|
||||
|
||||
func TestInviteStoreUpdateStatusReturnsNotFoundForMissingRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: common.InviteID("invite-missing"),
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusDeclined,
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, invite.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestInviteStoreGetByGameAndByUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
i1 := fixtureInvite(t, "invite-a1", "user-owner", "user-1", "game-1")
|
||||
i2 := fixtureInvite(t, "invite-a2", "user-owner", "user-2", "game-1")
|
||||
i3 := fixtureInvite(t, "invite-a3", "user-owner", "user-1", "game-2")
|
||||
|
||||
for _, record := range []invite.Invite{i1, i2, i3} {
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
}
|
||||
|
||||
byGame1, err := store.GetByGame(ctx, "game-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byGame1, 2)
|
||||
|
||||
byUser1, err := store.GetByUser(ctx, "user-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byUser1, 2)
|
||||
|
||||
ids := collectInviteIDs(byUser1)
|
||||
sort.Strings(ids)
|
||||
assert.Equal(t, []string{"invite-a1", "invite-a3"}, ids)
|
||||
|
||||
byGameMissing, err := store.GetByGame(ctx, "game-missing")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, byGameMissing)
|
||||
}
|
||||
|
||||
func TestInviteStoreGetByInviter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
i1 := fixtureInvite(t, "invite-i1", "user-owner-a", "user-guest-1", "game-1")
|
||||
i2 := fixtureInvite(t, "invite-i2", "user-owner-a", "user-guest-2", "game-2")
|
||||
i3 := fixtureInvite(t, "invite-i3", "user-owner-b", "user-guest-1", "game-3")
|
||||
|
||||
for _, record := range []invite.Invite{i1, i2, i3} {
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
}
|
||||
|
||||
byInviterA, err := store.GetByInviter(ctx, "user-owner-a")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byInviterA, 2)
|
||||
idsA := collectInviteIDs(byInviterA)
|
||||
sort.Strings(idsA)
|
||||
assert.Equal(t, []string{"invite-i1", "invite-i2"}, idsA)
|
||||
|
||||
byInviterB, err := store.GetByInviter(ctx, "user-owner-b")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byInviterB, 1)
|
||||
assert.Equal(t, "invite-i3", byInviterB[0].InviteID.String())
|
||||
|
||||
byInviterMissing, err := store.GetByInviter(ctx, "user-owner-none")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, byInviterMissing)
|
||||
}
|
||||
|
||||
func TestInviteStoreGetByInviterRetainsAfterStatusChange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-i", "user-owner-a", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
|
||||
InviteID: record.InviteID,
|
||||
ExpectedFrom: invite.StatusCreated,
|
||||
To: invite.StatusRevoked,
|
||||
At: record.CreatedAt.Add(time.Minute),
|
||||
}))
|
||||
|
||||
matches, err := store.GetByInviter(ctx, "user-owner-a")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, matches, 1)
|
||||
assert.Equal(t, invite.StatusRevoked, matches[0].Status)
|
||||
}
|
||||
|
||||
func TestInviteStoreGetByGameDropsStaleIndexEntries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server, _ := newInviteTestStore(t)
|
||||
|
||||
record := fixtureInvite(t, "invite-a", "user-owner", "user-guest", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
server.Del("lobby:invites:" + base64URL(record.InviteID.String()))
|
||||
|
||||
records, err := store.GetByGame(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records)
|
||||
}
|
||||
|
||||
func collectInviteIDs(records []invite.Invite) []string {
|
||||
ids := make([]string, len(records))
|
||||
for index, record := range records {
|
||||
ids[index] = record.InviteID.String()
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/domain/racename"
|
||||
)
|
||||
|
||||
// defaultPrefix is the mandatory `lobby:` namespace prefix shared by every
|
||||
// Game Lobby Redis key.
|
||||
const defaultPrefix = "lobby:"
|
||||
|
||||
// GameRecordTTL is the Redis retention applied to game records. The
|
||||
// value is zero (no expiry); a future stage will revisit this
|
||||
// choice when the platform locks in archival/GDPR policy.
|
||||
const GameRecordTTL time.Duration = 0
|
||||
|
||||
// ApplicationRecordTTL is the Redis retention applied to application
|
||||
// records. uses zero (no expiry) to match game records; the
|
||||
// archival policy will be revisited when the platform locks it in.
|
||||
const ApplicationRecordTTL time.Duration = 0
|
||||
|
||||
// InviteRecordTTL is the Redis retention applied to invite records.
|
||||
// uses zero (no expiry); the `expires_at` field is a business
|
||||
// deadline enforced by the service layer, not a Redis TTL.
|
||||
const InviteRecordTTL time.Duration = 0
|
||||
|
||||
// MembershipRecordTTL is the Redis retention applied to membership
|
||||
// records. uses zero (no expiry) to match the other participant
|
||||
// entities.
|
||||
const MembershipRecordTTL time.Duration = 0
|
||||
|
||||
// Keyspace builds the frozen Game Lobby Redis keys. All dynamic key
|
||||
// segments are encoded with base64url so raw key structure does not
|
||||
// depend on user-provided or caller-provided characters.
|
||||
type Keyspace struct{}
|
||||
|
||||
// Game returns the primary Redis key for one game record.
|
||||
func (Keyspace) Game(gameID common.GameID) string {
|
||||
return defaultPrefix + "games:" + encodeKeyComponent(gameID.String())
|
||||
}
|
||||
|
||||
// GamesByStatus returns the sorted-set key that stores game identifiers
|
||||
// indexed by their current status.
|
||||
func (Keyspace) GamesByStatus(status game.Status) string {
|
||||
return defaultPrefix + "games_by_status:" + encodeKeyComponent(string(status))
|
||||
}
|
||||
|
||||
// GamesByOwner returns the set key that stores game identifiers owned
|
||||
// by one user. The set is maintained for private games whose
|
||||
// OwnerUserID is non-empty (public games are admin-owned and carry an
|
||||
// empty OwnerUserID, so they never enter the index).
|
||||
func (Keyspace) GamesByOwner(userID string) string {
|
||||
return defaultPrefix + "games_by_owner:" + encodeKeyComponent(userID)
|
||||
}
|
||||
|
||||
// Application returns the primary Redis key for one application record.
|
||||
func (Keyspace) Application(applicationID common.ApplicationID) string {
|
||||
return defaultPrefix + "applications:" + encodeKeyComponent(applicationID.String())
|
||||
}
|
||||
|
||||
// ApplicationsByGame returns the set key that stores application
|
||||
// identifiers attached to one game.
|
||||
func (Keyspace) ApplicationsByGame(gameID common.GameID) string {
|
||||
return defaultPrefix + "game_applications:" + encodeKeyComponent(gameID.String())
|
||||
}
|
||||
|
||||
// ApplicationsByUser returns the set key that stores application
|
||||
// identifiers submitted by one applicant.
|
||||
func (Keyspace) ApplicationsByUser(applicantUserID string) string {
|
||||
return defaultPrefix + "user_applications:" + encodeKeyComponent(applicantUserID)
|
||||
}
|
||||
|
||||
// UserGameApplication returns the lookup key that stores the single
|
||||
// non-rejected application identifier for one (user, game) pair. Presence
|
||||
// of this key blocks a second submitted/approved application for the
|
||||
// same user and game.
|
||||
func (Keyspace) UserGameApplication(applicantUserID string, gameID common.GameID) string {
|
||||
return defaultPrefix + "user_game_application:" +
|
||||
encodeKeyComponent(applicantUserID) + ":" +
|
||||
encodeKeyComponent(gameID.String())
|
||||
}
|
||||
|
||||
// Invite returns the primary Redis key for one invite record.
|
||||
func (Keyspace) Invite(inviteID common.InviteID) string {
|
||||
return defaultPrefix + "invites:" + encodeKeyComponent(inviteID.String())
|
||||
}
|
||||
|
||||
// InvitesByGame returns the set key that stores invite identifiers
|
||||
// attached to one game.
|
||||
func (Keyspace) InvitesByGame(gameID common.GameID) string {
|
||||
return defaultPrefix + "game_invites:" + encodeKeyComponent(gameID.String())
|
||||
}
|
||||
|
||||
// InvitesByUser returns the set key that stores invite identifiers
|
||||
// addressed to one invitee.
|
||||
func (Keyspace) InvitesByUser(inviteeUserID string) string {
|
||||
return defaultPrefix + "user_invites:" + encodeKeyComponent(inviteeUserID)
|
||||
}
|
||||
|
||||
// InvitesByInviter returns the set key that stores invite identifiers
|
||||
// created by one inviter (private-game owner). The set retains
|
||||
// invite_ids regardless of subsequent status transitions; callers
|
||||
// filter by status when needed.
|
||||
func (Keyspace) InvitesByInviter(inviterUserID string) string {
|
||||
return defaultPrefix + "user_inviter_invites:" + encodeKeyComponent(inviterUserID)
|
||||
}
|
||||
|
||||
// Membership returns the primary Redis key for one membership record.
|
||||
func (Keyspace) Membership(membershipID common.MembershipID) string {
|
||||
return defaultPrefix + "memberships:" + encodeKeyComponent(membershipID.String())
|
||||
}
|
||||
|
||||
// MembershipsByGame returns the set key that stores membership
|
||||
// identifiers attached to one game.
|
||||
func (Keyspace) MembershipsByGame(gameID common.GameID) string {
|
||||
return defaultPrefix + "game_memberships:" + encodeKeyComponent(gameID.String())
|
||||
}
|
||||
|
||||
// MembershipsByUser returns the set key that stores membership
|
||||
// identifiers held by one user.
|
||||
func (Keyspace) MembershipsByUser(userID string) string {
|
||||
return defaultPrefix + "user_memberships:" + encodeKeyComponent(userID)
|
||||
}
|
||||
|
||||
// RegisteredRaceName returns the Redis key that stores the registered
|
||||
// race name bound to canonical.
|
||||
func (Keyspace) RegisteredRaceName(canonical racename.CanonicalKey) string {
|
||||
return defaultPrefix + "race_names:registered:" + encodeKeyComponent(canonical.String())
|
||||
}
|
||||
|
||||
// UserRegisteredRaceNames returns the set key that stores canonical keys
|
||||
// of every registered race name owned by userID.
|
||||
func (Keyspace) UserRegisteredRaceNames(userID string) string {
|
||||
return defaultPrefix + "race_names:user_registered:" + encodeKeyComponent(userID)
|
||||
}
|
||||
|
||||
// RaceNameReservation returns the Redis key that stores the per-game race
|
||||
// name reservation bound to (gameID, canonical).
|
||||
func (Keyspace) RaceNameReservation(gameID common.GameID, canonical racename.CanonicalKey) string {
|
||||
return defaultPrefix + "race_names:reservations:" +
|
||||
encodeKeyComponent(gameID.String()) + ":" +
|
||||
encodeKeyComponent(canonical.String())
|
||||
}
|
||||
|
||||
// UserRaceNameReservations returns the set key that stores
|
||||
// `<encodedGameID>:<encodedCanonical>` tuples of every active reservation
|
||||
// (including pending_registration) owned by userID.
|
||||
func (Keyspace) UserRaceNameReservations(userID string) string {
|
||||
return defaultPrefix + "race_names:user_reservations:" + encodeKeyComponent(userID)
|
||||
}
|
||||
|
||||
// RaceNameCanonicalLookup returns the Redis key that stores the eager
|
||||
// canonical-lookup cache entry for canonical. The cache surfaces the
|
||||
// strongest existing binding (registered > pending_registration >
|
||||
// reservation) so Check remains an O(1) read.
|
||||
func (Keyspace) RaceNameCanonicalLookup(canonical racename.CanonicalKey) string {
|
||||
return defaultPrefix + "race_names:canonical_lookup:" + encodeKeyComponent(canonical.String())
|
||||
}
|
||||
|
||||
// PendingRaceNameIndex returns the singleton sorted-set key that indexes
|
||||
// pending registrations by eligible_until_ms for the expiration worker.
|
||||
func (Keyspace) PendingRaceNameIndex() string {
|
||||
return defaultPrefix + "race_names:pending_index"
|
||||
}
|
||||
|
||||
// RaceNameReservationMember returns the canonical member representation
|
||||
// stored inside UserRaceNameReservations and PendingRaceNameIndex for
|
||||
// (gameID, canonical).
|
||||
func (Keyspace) RaceNameReservationMember(gameID common.GameID, canonical racename.CanonicalKey) string {
|
||||
return encodeKeyComponent(gameID.String()) + ":" + encodeKeyComponent(canonical.String())
|
||||
}
|
||||
|
||||
// GapActivatedAt returns the Redis key that stores the gap-window
|
||||
// activation timestamp for one game.
|
||||
func (Keyspace) GapActivatedAt(gameID common.GameID) string {
|
||||
return defaultPrefix + "gap_activated_at:" + encodeKeyComponent(gameID.String())
|
||||
}
|
||||
|
||||
// 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. `runtime_results`,
|
||||
// `gm_events`, `user_lifecycle`), 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)
|
||||
}
|
||||
|
||||
// GameTurnStat returns the per-user Redis key that stores the
|
||||
// initial/max stats aggregate for one game. keeps one key per
|
||||
// user so the Lua-backed SaveInitial and UpdateMax scripts can operate
|
||||
// on a single primary key without a secondary index.
|
||||
func (Keyspace) GameTurnStat(gameID common.GameID, userID string) string {
|
||||
return defaultPrefix + "game_turn_stats:" +
|
||||
encodeKeyComponent(gameID.String()) + ":" +
|
||||
encodeKeyComponent(userID)
|
||||
}
|
||||
|
||||
// GameTurnStatsByGame returns the set key that stores every userID for
|
||||
// which a GameTurnStat key exists for gameID. The set is the lookup
|
||||
// index used by Load and Delete so they avoid a Redis SCAN over the
|
||||
// whole keyspace.
|
||||
func (Keyspace) GameTurnStatsByGame(gameID common.GameID) string {
|
||||
return defaultPrefix + "game_turn_stats_by_game:" +
|
||||
encodeKeyComponent(gameID.String())
|
||||
}
|
||||
|
||||
// CapabilityEvaluationGuard returns the Redis key whose presence marks
|
||||
// gameID as already evaluated by the The capability evaluator
|
||||
// uses SETNX on this key to make replayed `game_finished` events safe.
|
||||
func (Keyspace) CapabilityEvaluationGuard(gameID common.GameID) string {
|
||||
return defaultPrefix + "capability_evaluation:done:" +
|
||||
encodeKeyComponent(gameID.String())
|
||||
}
|
||||
|
||||
// CreatedAtScore returns the frozen sorted-set score representation for
|
||||
// game creation timestamps stored in the status index.
|
||||
func CreatedAtScore(createdAt time.Time) float64 {
|
||||
return float64(createdAt.UTC().UnixMilli())
|
||||
}
|
||||
|
||||
func encodeKeyComponent(value string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// MembershipStore provides Redis-backed durable storage for membership
|
||||
// records.
|
||||
type MembershipStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewMembershipStore constructs one Redis-backed membership store. It
|
||||
// returns an error when client is nil.
|
||||
func NewMembershipStore(client *redis.Client) (*MembershipStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new membership store: nil redis client")
|
||||
}
|
||||
|
||||
return &MembershipStore{
|
||||
client: client,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Save persists a new active membership record. Save is create-only; a
|
||||
// second save against the same membership id returns
|
||||
// membership.ErrConflict.
|
||||
func (store *MembershipStore) Save(ctx context.Context, record membership.Membership) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("save membership: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("save membership: nil context")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("save membership: %w", err)
|
||||
}
|
||||
if record.Status != membership.StatusActive {
|
||||
return fmt.Errorf(
|
||||
"save membership: status must be %q, got %q",
|
||||
membership.StatusActive, record.Status,
|
||||
)
|
||||
}
|
||||
|
||||
payload, err := MarshalMembership(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save membership: %w", err)
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Membership(record.MembershipID)
|
||||
gameIndexKey := store.keys.MembershipsByGame(record.GameID)
|
||||
userIndexKey := store.keys.MembershipsByUser(record.UserID)
|
||||
member := record.MembershipID.String()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
existing, getErr := tx.Exists(ctx, primaryKey).Result()
|
||||
if getErr != nil {
|
||||
return fmt.Errorf("save membership: %w", getErr)
|
||||
}
|
||||
if existing != 0 {
|
||||
return fmt.Errorf("save membership: %w", membership.ErrConflict)
|
||||
}
|
||||
|
||||
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, payload, MembershipRecordTTL)
|
||||
pipe.SAdd(ctx, gameIndexKey, member)
|
||||
pipe.SAdd(ctx, userIndexKey, member)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("save membership: %w", membership.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the record identified by membershipID.
|
||||
func (store *MembershipStore) Get(ctx context.Context, membershipID common.MembershipID) (membership.Membership, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return membership.Membership{}, errors.New("get membership: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return membership.Membership{}, errors.New("get membership: nil context")
|
||||
}
|
||||
if err := membershipID.Validate(); err != nil {
|
||||
return membership.Membership{}, fmt.Errorf("get membership: %w", err)
|
||||
}
|
||||
|
||||
payload, err := store.client.Get(ctx, store.keys.Membership(membershipID)).Bytes()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return membership.Membership{}, membership.ErrNotFound
|
||||
case err != nil:
|
||||
return membership.Membership{}, fmt.Errorf("get membership: %w", err)
|
||||
}
|
||||
|
||||
record, err := UnmarshalMembership(payload)
|
||||
if err != nil {
|
||||
return membership.Membership{}, fmt.Errorf("get membership: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetByGame returns every membership attached to gameID.
|
||||
func (store *MembershipStore) GetByGame(ctx context.Context, gameID common.GameID) ([]membership.Membership, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get memberships by game: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get memberships by game: nil context")
|
||||
}
|
||||
if err := gameID.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("get memberships by game: %w", err)
|
||||
}
|
||||
|
||||
return store.loadMembershipsBySet(ctx,
|
||||
"get memberships by game",
|
||||
store.keys.MembershipsByGame(gameID),
|
||||
)
|
||||
}
|
||||
|
||||
// GetByUser returns every membership held by userID.
|
||||
func (store *MembershipStore) GetByUser(ctx context.Context, userID string) ([]membership.Membership, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return nil, errors.New("get memberships by user: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, errors.New("get memberships by user: nil context")
|
||||
}
|
||||
trimmed := strings.TrimSpace(userID)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("get memberships by user: user id must not be empty")
|
||||
}
|
||||
|
||||
return store.loadMembershipsBySet(ctx,
|
||||
"get memberships by user",
|
||||
store.keys.MembershipsByUser(trimmed),
|
||||
)
|
||||
}
|
||||
|
||||
// loadMembershipsBySet materializes memberships whose ids are stored in
|
||||
// setKey. Stale set members are dropped silently.
|
||||
func (store *MembershipStore) loadMembershipsBySet(ctx context.Context, operation, setKey string) ([]membership.Membership, error) {
|
||||
members, err := store.client.SMembers(ctx, setKey).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
primaryKeys := make([]string, len(members))
|
||||
for index, member := range members {
|
||||
primaryKeys[index] = store.keys.Membership(common.MembershipID(member))
|
||||
}
|
||||
|
||||
payloads, err := store.client.MGet(ctx, primaryKeys...).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
|
||||
records := make([]membership.Membership, 0, len(payloads))
|
||||
for _, entry := range payloads {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
raw, ok := entry.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: unexpected payload type %T", operation, entry)
|
||||
}
|
||||
record, err := UnmarshalMembership([]byte(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// UpdateStatus applies one status transition in a compare-and-swap fashion.
|
||||
func (store *MembershipStore) UpdateStatus(ctx context.Context, input ports.UpdateMembershipStatusInput) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("update membership status: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("update membership status: nil context")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return fmt.Errorf("update membership status: %w", err)
|
||||
}
|
||||
|
||||
if err := membership.Transition(input.ExpectedFrom, input.To); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Membership(input.MembershipID)
|
||||
at := input.At.UTC()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
payload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
return membership.ErrNotFound
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("update membership status: %w", getErr)
|
||||
}
|
||||
|
||||
existing, err := UnmarshalMembership(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update membership status: %w", err)
|
||||
}
|
||||
if existing.Status != input.ExpectedFrom {
|
||||
return fmt.Errorf("update membership status: %w", membership.ErrConflict)
|
||||
}
|
||||
|
||||
existing.Status = input.To
|
||||
removedAt := at
|
||||
existing.RemovedAt = &removedAt
|
||||
|
||||
encoded, err := MarshalMembership(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update membership status: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Set(ctx, primaryKey, encoded, MembershipRecordTTL)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("update membership status: %w", membership.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removes the membership record identified by membershipID from
|
||||
// the primary store and from the per-game and per-user index sets in
|
||||
// one transaction. It returns membership.ErrNotFound when no record
|
||||
// exists for the id and membership.ErrConflict when a concurrent
|
||||
// mutation invalidates the watched key.
|
||||
func (store *MembershipStore) Delete(ctx context.Context, membershipID common.MembershipID) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("delete membership: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("delete membership: nil context")
|
||||
}
|
||||
if err := membershipID.Validate(); err != nil {
|
||||
return fmt.Errorf("delete membership: %w", err)
|
||||
}
|
||||
|
||||
primaryKey := store.keys.Membership(membershipID)
|
||||
member := membershipID.String()
|
||||
|
||||
watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error {
|
||||
payload, getErr := tx.Get(ctx, primaryKey).Bytes()
|
||||
switch {
|
||||
case errors.Is(getErr, redis.Nil):
|
||||
return membership.ErrNotFound
|
||||
case getErr != nil:
|
||||
return fmt.Errorf("delete membership: %w", getErr)
|
||||
}
|
||||
|
||||
existing, err := UnmarshalMembership(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete membership: %w", err)
|
||||
}
|
||||
|
||||
gameIndexKey := store.keys.MembershipsByGame(existing.GameID)
|
||||
userIndexKey := store.keys.MembershipsByUser(existing.UserID)
|
||||
|
||||
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||
pipe.Del(ctx, primaryKey)
|
||||
pipe.SRem(ctx, gameIndexKey, member)
|
||||
pipe.SRem(ctx, userIndexKey, member)
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}, primaryKey)
|
||||
|
||||
switch {
|
||||
case errors.Is(watchErr, redis.TxFailedErr):
|
||||
return fmt.Errorf("delete membership: %w", membership.ErrConflict)
|
||||
case watchErr != nil:
|
||||
return watchErr
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure MembershipStore satisfies the ports.MembershipStore interface at
|
||||
// compile time.
|
||||
var _ ports.MembershipStore = (*MembershipStore)(nil)
|
||||
@@ -0,0 +1,299 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/membership"
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newMembershipTestStore(t *testing.T) (*redisstate.MembershipStore, *miniredis.Miniredis, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
})
|
||||
|
||||
store, err := redisstate.NewMembershipStore(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store, server, client
|
||||
}
|
||||
|
||||
func fixtureMembership(t *testing.T, id common.MembershipID, userID, raceName string, gameID common.GameID) membership.Membership {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
record, err := membership.New(membership.NewMembershipInput{
|
||||
MembershipID: id,
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
RaceName: raceName,
|
||||
CanonicalKey: strings.ToLower(strings.ReplaceAll(raceName, " ", "")),
|
||||
Now: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return record
|
||||
}
|
||||
|
||||
func TestNewMembershipStoreRejectsNilClient(t *testing.T) {
|
||||
_, err := redisstate.NewMembershipStore(nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMembershipStoreSaveAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
got, err := store.Get(ctx, record.MembershipID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.MembershipID, got.MembershipID)
|
||||
assert.Equal(t, "Solar Pilot", got.RaceName)
|
||||
assert.Equal(t, membership.StatusActive, got.Status)
|
||||
assert.Nil(t, got.RemovedAt)
|
||||
|
||||
byGame, err := client.SMembers(ctx, "lobby:game_memberships:"+base64URL(record.GameID.String())).Result()
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{record.MembershipID.String()}, byGame)
|
||||
|
||||
byUser, err := client.SMembers(ctx, "lobby:user_memberships:"+base64URL(record.UserID)).Result()
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{record.MembershipID.String()}, byUser)
|
||||
}
|
||||
|
||||
func TestMembershipStoreGetReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
_, err := store.Get(ctx, common.MembershipID("membership-missing"))
|
||||
require.ErrorIs(t, err, membership.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestMembershipStoreSaveRejectsNonActive(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
record.Status = membership.StatusRemoved
|
||||
removedAt := record.JoinedAt.Add(time.Hour)
|
||||
record.RemovedAt = &removedAt
|
||||
|
||||
err := store.Save(ctx, record)
|
||||
require.Error(t, err)
|
||||
assert.False(t, errors.Is(err, membership.ErrConflict))
|
||||
}
|
||||
|
||||
func TestMembershipStoreSaveRejectsDuplicate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.Save(ctx, record)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, membership.ErrConflict))
|
||||
}
|
||||
|
||||
func TestMembershipStoreUpdateStatusSetsRemovedAt(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
target membership.Status
|
||||
}{
|
||||
{"removed", membership.StatusRemoved},
|
||||
{"blocked", membership.StatusBlocked},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, common.MembershipID("membership-"+tc.name), "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
at := record.JoinedAt.Add(2 * time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
|
||||
MembershipID: record.MembershipID,
|
||||
ExpectedFrom: membership.StatusActive,
|
||||
To: tc.target,
|
||||
At: at,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, record.MembershipID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.target, got.Status)
|
||||
require.NotNil(t, got.RemovedAt)
|
||||
assert.True(t, got.RemovedAt.Equal(at.UTC()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMembershipStoreUpdateStatusRejectsInvalidTransitionWithoutMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
|
||||
MembershipID: record.MembershipID,
|
||||
ExpectedFrom: membership.StatusRemoved,
|
||||
To: membership.StatusBlocked,
|
||||
At: record.JoinedAt.Add(time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, membership.ErrInvalidTransition))
|
||||
|
||||
got, err := store.Get(ctx, record.MembershipID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, membership.StatusActive, got.Status)
|
||||
assert.Nil(t, got.RemovedAt)
|
||||
}
|
||||
|
||||
func TestMembershipStoreUpdateStatusReturnsConflictWhenStatusDiverges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
|
||||
MembershipID: record.MembershipID,
|
||||
ExpectedFrom: membership.StatusActive,
|
||||
To: membership.StatusBlocked,
|
||||
At: record.JoinedAt.Add(time.Minute),
|
||||
}))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
|
||||
MembershipID: record.MembershipID,
|
||||
ExpectedFrom: membership.StatusActive,
|
||||
To: membership.StatusRemoved,
|
||||
At: record.JoinedAt.Add(2 * time.Minute),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, membership.ErrConflict))
|
||||
}
|
||||
|
||||
func TestMembershipStoreUpdateStatusReturnsNotFoundForMissingRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{
|
||||
MembershipID: common.MembershipID("membership-missing"),
|
||||
ExpectedFrom: membership.StatusActive,
|
||||
To: membership.StatusRemoved,
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
require.ErrorIs(t, err, membership.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestMembershipStoreGetByGameAndByUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
m1 := fixtureMembership(t, "membership-a1", "user-1", "Racer A", "game-1")
|
||||
m2 := fixtureMembership(t, "membership-a2", "user-2", "Racer B", "game-1")
|
||||
m3 := fixtureMembership(t, "membership-a3", "user-1", "Racer C", "game-2")
|
||||
|
||||
for _, record := range []membership.Membership{m1, m2, m3} {
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
}
|
||||
|
||||
byGame1, err := store.GetByGame(ctx, "game-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byGame1, 2)
|
||||
|
||||
byUser1, err := store.GetByUser(ctx, "user-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, byUser1, 2)
|
||||
|
||||
ids := collectMembershipIDs(byUser1)
|
||||
sort.Strings(ids)
|
||||
assert.Equal(t, []string{"membership-a1", "membership-a3"}, ids)
|
||||
|
||||
byUserMissing, err := store.GetByUser(ctx, "user-missing")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, byUserMissing)
|
||||
}
|
||||
|
||||
func TestMembershipStoreGetByUserDropsStaleIndexEntries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, server, _ := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
server.Del("lobby:memberships:" + base64URL(record.MembershipID.String()))
|
||||
|
||||
records, err := store.GetByUser(ctx, record.UserID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records)
|
||||
}
|
||||
|
||||
func TestMembershipStoreDeleteRemovesPrimaryAndIndexes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, client := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
require.NoError(t, store.Delete(ctx, record.MembershipID))
|
||||
|
||||
_, err := store.Get(ctx, record.MembershipID)
|
||||
require.ErrorIs(t, err, membership.ErrNotFound)
|
||||
|
||||
byGame, err := client.SMembers(ctx, "lobby:game_memberships:"+base64URL(record.GameID.String())).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, byGame)
|
||||
|
||||
byUser, err := client.SMembers(ctx, "lobby:user_memberships:"+base64URL(record.UserID)).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, byUser)
|
||||
}
|
||||
|
||||
func TestMembershipStoreDeleteReturnsNotFoundForMissingRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
err := store.Delete(ctx, common.MembershipID("membership-missing"))
|
||||
require.ErrorIs(t, err, membership.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestMembershipStoreDeleteIsIdempotentAfterFirstSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, _, _ := newMembershipTestStore(t)
|
||||
|
||||
record := fixtureMembership(t, "membership-a", "user-1", "Solar Pilot", "game-1")
|
||||
require.NoError(t, store.Save(ctx, record))
|
||||
|
||||
require.NoError(t, store.Delete(ctx, record.MembershipID))
|
||||
|
||||
err := store.Delete(ctx, record.MembershipID)
|
||||
require.ErrorIs(t, err, membership.ErrNotFound)
|
||||
}
|
||||
|
||||
func collectMembershipIDs(records []membership.Membership) []string {
|
||||
ids := make([]string, len(records))
|
||||
for index, record := range records {
|
||||
ids[index] = record.MembershipID.String()
|
||||
}
|
||||
return ids
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
package redisstate
|
||||
|
||||
// releaseAllByUserScript atomically clears every registered, reservation,
|
||||
// and pending_registration binding owned by one user. Inputs:
|
||||
//
|
||||
// KEYS[1] — user_registered set key
|
||||
// KEYS[2] — user_reservations set key
|
||||
// KEYS[3] — pending_index sorted-set key
|
||||
// ARGV[1] — Lobby Redis key prefix (e.g. "lobby:")
|
||||
//
|
||||
// The script returns a three-entry table `{registeredCount,
|
||||
// reservationsTotal, pendingCount}` so callers can emit telemetry without
|
||||
// a second round-trip. reservationsTotal includes both reserved and
|
||||
// pending_registration entries; pendingCount is the pending-only subset.
|
||||
const releaseAllByUserScript = `
|
||||
local userRegisteredKey = KEYS[1]
|
||||
local userReservationsKey = KEYS[2]
|
||||
local pendingIndexKey = KEYS[3]
|
||||
local prefix = ARGV[1]
|
||||
|
||||
local registered = redis.call('SMEMBERS', userRegisteredKey)
|
||||
for _, canonical in ipairs(registered) do
|
||||
redis.call('DEL', prefix .. 'race_names:registered:' .. canonical)
|
||||
redis.call('DEL', prefix .. 'race_names:canonical_lookup:' .. canonical)
|
||||
end
|
||||
local registeredCount = #registered
|
||||
if registeredCount > 0 then
|
||||
redis.call('DEL', userRegisteredKey)
|
||||
end
|
||||
|
||||
local reservations = redis.call('SMEMBERS', userReservationsKey)
|
||||
local pendingCount = 0
|
||||
for _, member in ipairs(reservations) do
|
||||
local sep = string.find(member, ':', 1, true)
|
||||
if sep then
|
||||
local encGame = string.sub(member, 1, sep - 1)
|
||||
local encCanonical = string.sub(member, sep + 1)
|
||||
redis.call('DEL', prefix .. 'race_names:reservations:' .. encGame .. ':' .. encCanonical)
|
||||
local pendingRemoved = redis.call('ZREM', pendingIndexKey, member)
|
||||
if pendingRemoved == 1 then
|
||||
pendingCount = pendingCount + 1
|
||||
end
|
||||
redis.call('DEL', prefix .. 'race_names:canonical_lookup:' .. encCanonical)
|
||||
end
|
||||
end
|
||||
local reservationsTotal = #reservations
|
||||
if reservationsTotal > 0 then
|
||||
redis.call('DEL', userReservationsKey)
|
||||
end
|
||||
|
||||
return {registeredCount, reservationsTotal, pendingCount}
|
||||
`
|
||||
@@ -0,0 +1,244 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
"galaxy/lobby/internal/domain/racename"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/ports/racenamedirtest"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newRaceNameDirectoryAdapter(
|
||||
t *testing.T,
|
||||
now func() time.Time,
|
||||
) (*redisstate.RaceNameDirectory, *miniredis.Miniredis, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
})
|
||||
|
||||
policy, err := racename.NewPolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
var opts []redisstate.RaceNameDirectoryOption
|
||||
if now != nil {
|
||||
opts = append(opts, redisstate.WithRaceNameDirectoryClock(now))
|
||||
}
|
||||
directory, err := redisstate.NewRaceNameDirectory(client, policy, opts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
return directory, server, client
|
||||
}
|
||||
|
||||
func TestRaceNameDirectoryContract(t *testing.T) {
|
||||
racenamedirtest.Run(t, func(now func() time.Time) ports.RaceNameDirectory {
|
||||
directory, _, _ := newRaceNameDirectoryAdapter(t, now)
|
||||
return directory
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewRaceNameDirectoryRejectsNilClient(t *testing.T) {
|
||||
policy, err := racename.NewPolicy()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = redisstate.NewRaceNameDirectory(nil, policy)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewRaceNameDirectoryRejectsNilPolicy(t *testing.T) {
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
_, err := redisstate.NewRaceNameDirectory(client, nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRaceNameDirectoryPersistsExactKeyShapes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
directory, server, _ := newRaceNameDirectoryAdapter(t, nil)
|
||||
|
||||
const (
|
||||
gameID = "game-shape"
|
||||
userID = "user-shape"
|
||||
raceName = "PilotNova"
|
||||
)
|
||||
|
||||
require.NoError(t, directory.Reserve(ctx, gameID, userID, raceName))
|
||||
|
||||
canonical, err := directory.Canonicalize(raceName)
|
||||
require.NoError(t, err)
|
||||
|
||||
encGame := base64URL(gameID)
|
||||
encUser := base64URL(userID)
|
||||
encCanonical := base64URL(canonical)
|
||||
|
||||
require.True(t, server.Exists("lobby:race_names:reservations:"+encGame+":"+encCanonical))
|
||||
require.True(t, server.Exists("lobby:race_names:canonical_lookup:"+encCanonical))
|
||||
require.True(t, server.Exists("lobby:race_names:user_reservations:"+encUser))
|
||||
|
||||
members, err := server.SMembers("lobby:race_names:user_reservations:" + encUser)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, members, encGame+":"+encCanonical)
|
||||
|
||||
lookupPayload, err := server.Get("lobby:race_names:canonical_lookup:" + encCanonical)
|
||||
require.NoError(t, err)
|
||||
var lookup map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(lookupPayload), &lookup))
|
||||
assert.Equal(t, ports.KindReservation, lookup["kind"])
|
||||
assert.Equal(t, userID, lookup["holder_user_id"])
|
||||
assert.Equal(t, gameID, lookup["game_id"])
|
||||
}
|
||||
|
||||
func TestRaceNameDirectoryCanonicalLookupUpgradesOnPendingAndRegistered(t *testing.T) {
|
||||
now, _ := fixedNow(t)
|
||||
directory, server, _ := newRaceNameDirectoryAdapter(t, now)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
gameID = "game-upgrade"
|
||||
userID = "user-upgrade"
|
||||
raceName = "UpgradePilot"
|
||||
)
|
||||
|
||||
require.NoError(t, directory.Reserve(ctx, gameID, userID, raceName))
|
||||
|
||||
canonical, err := directory.Canonicalize(raceName)
|
||||
require.NoError(t, err)
|
||||
lookupKey := "lobby:race_names:canonical_lookup:" + base64URL(canonical)
|
||||
|
||||
lookupAfterReserve, err := server.Get(lookupKey)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, lookupAfterReserve, `"kind":"`+ports.KindReservation+`"`)
|
||||
|
||||
eligibleUntil := now().Add(time.Hour)
|
||||
require.NoError(t, directory.MarkPendingRegistration(ctx, gameID, userID, raceName, eligibleUntil))
|
||||
|
||||
lookupAfterPending, err := server.Get(lookupKey)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, lookupAfterPending, `"kind":"`+ports.KindPendingRegistration+`"`)
|
||||
|
||||
require.NoError(t, directory.Register(ctx, gameID, userID, raceName))
|
||||
|
||||
lookupAfterRegister, err := server.Get(lookupKey)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, lookupAfterRegister, `"kind":"`+ports.KindRegistered+`"`)
|
||||
require.NotContains(t, lookupAfterRegister, `"game_id"`, "registered lookup omits the game id")
|
||||
}
|
||||
|
||||
func TestRaceNameDirectoryCanonicalLookupDowngradesOnReleaseCrossGame(t *testing.T) {
|
||||
directory, server, _ := newRaceNameDirectoryAdapter(t, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
gameA = "game-keep-a"
|
||||
gameB = "game-keep-b"
|
||||
userID = "user-keep"
|
||||
raceNam = "KeepPilot"
|
||||
)
|
||||
|
||||
require.NoError(t, directory.Reserve(ctx, gameA, userID, raceNam))
|
||||
require.NoError(t, directory.Reserve(ctx, gameB, userID, raceNam))
|
||||
|
||||
canonical, err := directory.Canonicalize(raceNam)
|
||||
require.NoError(t, err)
|
||||
lookupKey := "lobby:race_names:canonical_lookup:" + base64URL(canonical)
|
||||
|
||||
require.NoError(t, directory.ReleaseReservation(ctx, gameA, userID, raceNam))
|
||||
|
||||
payload, err := server.Get(lookupKey)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, payload, `"kind":"`+ports.KindReservation+`"`)
|
||||
require.Contains(t, payload, `"game_id":"`+gameB+`"`)
|
||||
|
||||
require.NoError(t, directory.ReleaseReservation(ctx, gameB, userID, raceNam))
|
||||
require.False(t, server.Exists(lookupKey))
|
||||
}
|
||||
|
||||
func TestRaceNameDirectoryReleaseAllByUserLua(t *testing.T) {
|
||||
now, _ := fixedNow(t)
|
||||
directory, server, _ := newRaceNameDirectoryAdapter(t, now)
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
userID = "user-lua"
|
||||
otherID = "user-lua-other"
|
||||
raceName = "LuaPilot"
|
||||
otherRN = "LuaVanguard"
|
||||
gameA = "game-lua-a"
|
||||
gameB = "game-lua-b"
|
||||
)
|
||||
|
||||
require.NoError(t, directory.Reserve(ctx, gameA, userID, raceName))
|
||||
require.NoError(t, directory.MarkPendingRegistration(ctx, gameA, userID, raceName, now().Add(time.Hour)))
|
||||
require.NoError(t, directory.Register(ctx, gameA, userID, raceName))
|
||||
require.NoError(t, directory.Reserve(ctx, gameB, userID, otherRN))
|
||||
require.NoError(t, directory.MarkPendingRegistration(ctx, gameB, userID, otherRN, now().Add(2*time.Hour)))
|
||||
|
||||
const isolatedRN = "LuaGoldenChain"
|
||||
require.NoError(t, directory.Reserve(ctx, gameA, otherID, isolatedRN))
|
||||
|
||||
require.NoError(t, directory.ReleaseAllByUser(ctx, userID))
|
||||
|
||||
require.False(t, server.Exists("lobby:race_names:user_registered:"+base64URL(userID)))
|
||||
require.False(t, server.Exists("lobby:race_names:user_reservations:"+base64URL(userID)))
|
||||
pendingMembers, err := server.ZMembers("lobby:race_names:pending_index")
|
||||
if err != nil {
|
||||
require.ErrorContains(t, err, "ERR no such key")
|
||||
} else {
|
||||
require.Empty(t, pendingMembers)
|
||||
}
|
||||
|
||||
otherCanonical, err := directory.Canonicalize(isolatedRN)
|
||||
require.NoError(t, err)
|
||||
require.True(t, server.Exists("lobby:race_names:canonical_lookup:"+base64URL(otherCanonical)))
|
||||
|
||||
reservations, err := directory.ListReservations(ctx, otherID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reservations, 1)
|
||||
}
|
||||
|
||||
func TestRaceNameDirectoryReleaseAllByUserIsSafeOnEmpty(t *testing.T) {
|
||||
directory, _, _ := newRaceNameDirectoryAdapter(t, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, directory.ReleaseAllByUser(ctx, "unknown-user"))
|
||||
}
|
||||
|
||||
func TestRaceNameDirectoryCheckRejectsInvalidName(t *testing.T) {
|
||||
directory, _, _ := newRaceNameDirectoryAdapter(t, nil)
|
||||
|
||||
_, err := directory.Check(context.Background(), "Pilot Nova", "user-x")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, ports.ErrInvalidName))
|
||||
}
|
||||
|
||||
func fixedNow(t *testing.T) (func() time.Time, func(delta time.Duration)) {
|
||||
t.Helper()
|
||||
|
||||
instant := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||
var mu struct {
|
||||
value time.Time
|
||||
}
|
||||
mu.value = instant
|
||||
return func() time.Time { return mu.value },
|
||||
func(delta time.Duration) { mu.value = mu.value.Add(delta) }
|
||||
}
|
||||
|
||||
// base64URL is the package-level helper defined in gamestore_test.go;
|
||||
// race-name adapter tests reuse it via the same test package.
|
||||
var _ = base64.RawURLEncoding
|
||||
@@ -0,0 +1,93 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// StreamLagProbe is the Redis-backed implementation of ports.StreamLagProbe.
|
||||
// It uses XRANGE with an exclusive start to find the oldest entry that
|
||||
// follows the saved consumer offset and parses the ms component of the
|
||||
// returned entry id.
|
||||
type StreamLagProbe struct {
|
||||
client *redis.Client
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
// NewStreamLagProbe constructs one Redis-backed stream-lag probe. clock is
|
||||
// optional; when nil the probe falls back to time.Now.
|
||||
func NewStreamLagProbe(client *redis.Client, clock func() time.Time) (*StreamLagProbe, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new lobby stream lag probe: nil redis client")
|
||||
}
|
||||
if clock == nil {
|
||||
clock = time.Now
|
||||
}
|
||||
return &StreamLagProbe{client: client, clock: clock}, nil
|
||||
}
|
||||
|
||||
// OldestUnprocessedAge returns the age of the first stream entry strictly
|
||||
// after savedOffset. When savedOffset is empty, the probe falls back to the
|
||||
// stream head. The boolean return reports whether an entry was found.
|
||||
func (probe *StreamLagProbe) OldestUnprocessedAge(ctx context.Context, stream, savedOffset string) (time.Duration, bool, error) {
|
||||
if probe == nil || probe.client == nil {
|
||||
return 0, false, errors.New("oldest unprocessed age: nil probe")
|
||||
}
|
||||
if ctx == nil {
|
||||
return 0, false, errors.New("oldest unprocessed age: nil context")
|
||||
}
|
||||
if strings.TrimSpace(stream) == "" {
|
||||
return 0, false, errors.New("oldest unprocessed age: empty stream name")
|
||||
}
|
||||
|
||||
start := "-"
|
||||
if trimmed := strings.TrimSpace(savedOffset); trimmed != "" {
|
||||
start = "(" + trimmed
|
||||
}
|
||||
|
||||
entries, err := probe.client.XRangeN(ctx, stream, start, "+", 1).Result()
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("oldest unprocessed age: %w", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
ms, err := parseStreamEntryMillis(entries[0].ID)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("oldest unprocessed age: %w", err)
|
||||
}
|
||||
|
||||
now := probe.clock()
|
||||
age := now.UnixMilli() - ms
|
||||
if age < 0 {
|
||||
return 0, true, nil
|
||||
}
|
||||
return time.Duration(age) * time.Millisecond, true, nil
|
||||
}
|
||||
|
||||
// parseStreamEntryMillis extracts the ms prefix from a Redis Stream entry
|
||||
// id of the form `<ms>-<seq>`. It returns an error when the format does
|
||||
// not match.
|
||||
func parseStreamEntryMillis(id string) (int64, error) {
|
||||
hyphen := strings.IndexByte(id, '-')
|
||||
if hyphen <= 0 {
|
||||
return 0, fmt.Errorf("malformed stream entry id %q", id)
|
||||
}
|
||||
ms, err := strconv.ParseInt(id[:hyphen], 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("malformed stream entry id %q: %w", id, err)
|
||||
}
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.StreamLagProbe = (*StreamLagProbe)(nil)
|
||||
@@ -0,0 +1,102 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newLagTestProbe(t *testing.T, now time.Time) (*redisstate.StreamLagProbe, *miniredis.Miniredis, *redis.Client) {
|
||||
t.Helper()
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
})
|
||||
probe, err := redisstate.NewStreamLagProbe(client, func() time.Time { return now })
|
||||
require.NoError(t, err)
|
||||
return probe, server, client
|
||||
}
|
||||
|
||||
func TestStreamLagProbeReturnsAgeOfNextEntry(t *testing.T) {
|
||||
now := time.UnixMilli(2_000_000_000_000).UTC()
|
||||
probe, _, client := newLagTestProbe(t, now)
|
||||
ctx := context.Background()
|
||||
|
||||
addEntry := func(ms int64) string {
|
||||
id, err := client.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: "demo",
|
||||
ID: formatEntryID(ms, 0),
|
||||
Values: map[string]any{"k": "v"},
|
||||
}).Result()
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
|
||||
saved := addEntry(now.UnixMilli() - 5_000) // already processed
|
||||
addEntry(now.UnixMilli() - 1_500) // first unprocessed → 1.5s old
|
||||
|
||||
age, ok, err := probe.OldestUnprocessedAge(ctx, "demo", saved)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, (1_500 * time.Millisecond).Milliseconds(), age.Milliseconds(), 50)
|
||||
}
|
||||
|
||||
func TestStreamLagProbeReturnsFalseWhenAtTail(t *testing.T) {
|
||||
now := time.UnixMilli(2_000_000_000_000).UTC()
|
||||
probe, _, client := newLagTestProbe(t, now)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := client.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: "demo",
|
||||
ID: formatEntryID(now.UnixMilli()-2_000, 0),
|
||||
Values: map[string]any{"k": "v"},
|
||||
}).Result()
|
||||
require.NoError(t, err)
|
||||
|
||||
age, ok, err := probe.OldestUnprocessedAge(ctx, "demo", id)
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
assert.Zero(t, age)
|
||||
}
|
||||
|
||||
func TestStreamLagProbeFallsBackToHeadOnEmptyOffset(t *testing.T) {
|
||||
now := time.UnixMilli(2_000_000_000_000).UTC()
|
||||
probe, _, client := newLagTestProbe(t, now)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.XAdd(ctx, &redis.XAddArgs{
|
||||
Stream: "demo",
|
||||
ID: formatEntryID(now.UnixMilli()-3_000, 0),
|
||||
Values: map[string]any{"k": "v"},
|
||||
}).Result()
|
||||
require.NoError(t, err)
|
||||
|
||||
age, ok, err := probe.OldestUnprocessedAge(ctx, "demo", "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, (3 * time.Second).Milliseconds(), age.Milliseconds(), 50)
|
||||
}
|
||||
|
||||
func TestStreamLagProbeReturnsFalseOnEmptyStream(t *testing.T) {
|
||||
now := time.UnixMilli(2_000_000_000_000).UTC()
|
||||
probe, _, _ := newLagTestProbe(t, now)
|
||||
ctx := context.Background()
|
||||
|
||||
age, ok, err := probe.OldestUnprocessedAge(ctx, "demo", "")
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
assert.Zero(t, age)
|
||||
}
|
||||
|
||||
func formatEntryID(ms int64, seq int64) string {
|
||||
return strconv.FormatInt(ms, 10) + "-" + strconv.FormatInt(seq, 10)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/lobby/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// StreamOffsetStore provides the Redis-backed storage used for
|
||||
// persisted Redis Stream consumer progress. The key per stream label is
|
||||
// produced by Keyspace.StreamOffset.
|
||||
type StreamOffsetStore struct {
|
||||
client *redis.Client
|
||||
keys Keyspace
|
||||
}
|
||||
|
||||
// NewStreamOffsetStore constructs one Redis-backed stream-offset store.
|
||||
func NewStreamOffsetStore(client *redis.Client) (*StreamOffsetStore, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("new lobby stream offset store: nil redis client")
|
||||
}
|
||||
return &StreamOffsetStore{
|
||||
client: client,
|
||||
keys: Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Load returns the last processed entry id for streamLabel when one is
|
||||
// stored.
|
||||
func (store *StreamOffsetStore) Load(ctx context.Context, streamLabel string) (string, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return "", false, errors.New("load lobby stream offset: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return "", false, errors.New("load lobby stream offset: nil context")
|
||||
}
|
||||
if strings.TrimSpace(streamLabel) == "" {
|
||||
return "", false, errors.New("load lobby 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 lobby stream offset: %w", err)
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
// Save stores entryID as the new offset for streamLabel.
|
||||
func (store *StreamOffsetStore) Save(ctx context.Context, streamLabel, entryID string) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("save lobby stream offset: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("save lobby stream offset: nil context")
|
||||
}
|
||||
if strings.TrimSpace(streamLabel) == "" {
|
||||
return errors.New("save lobby stream offset: stream label must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(entryID) == "" {
|
||||
return errors.New("save lobby 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 lobby stream offset: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time interface assertion.
|
||||
var _ ports.StreamOffsetStore = (*StreamOffsetStore)(nil)
|
||||
@@ -0,0 +1,65 @@
|
||||
package redisstate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"galaxy/lobby/internal/adapters/redisstate"
|
||||
|
||||
"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) (*redisstate.StreamOffsetStore, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
store, err := redisstate.NewStreamOffsetStore(client)
|
||||
require.NoError(t, err)
|
||||
return store, server
|
||||
}
|
||||
|
||||
func TestStreamOffsetStoreLoadMissing(t *testing.T) {
|
||||
store, _ := newOffsetStore(t)
|
||||
id, found, err := store.Load(context.Background(), "runtime_results")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
assert.Empty(t, id)
|
||||
}
|
||||
|
||||
func TestStreamOffsetStoreSaveLoadRoundTrip(t *testing.T) {
|
||||
store, _ := newOffsetStore(t)
|
||||
|
||||
require.NoError(t, store.Save(context.Background(), "runtime_results", "1700000000000-0"))
|
||||
|
||||
id, found, err := store.Load(context.Background(), "runtime_results")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, "1700000000000-0", id)
|
||||
}
|
||||
|
||||
func TestStreamOffsetStoreOverwrite(t *testing.T) {
|
||||
store, _ := newOffsetStore(t)
|
||||
|
||||
require.NoError(t, store.Save(context.Background(), "runtime_results", "100-0"))
|
||||
require.NoError(t, store.Save(context.Background(), "runtime_results", "200-0"))
|
||||
|
||||
id, found, err := store.Load(context.Background(), "runtime_results")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, "200-0", id)
|
||||
}
|
||||
|
||||
func TestStreamOffsetStoreRejectsInvalidArgs(t *testing.T) {
|
||||
store, _ := newOffsetStore(t)
|
||||
|
||||
require.Error(t, store.Save(context.Background(), "", "100-0"))
|
||||
require.Error(t, store.Save(context.Background(), "runtime_results", ""))
|
||||
|
||||
_, _, err := store.Load(context.Background(), "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user