feat: use postgres

This commit is contained in:
Ilia Denisov
2026-04-26 20:34:39 +02:00
committed by GitHub
parent 48b0056b49
commit fe829285a6
365 changed files with 29223 additions and 24049 deletions
+45 -57
View File
@@ -3,15 +3,18 @@
package config
import (
"crypto/tls"
"fmt"
"strings"
"time"
"galaxy/lobby/internal/telemetry"
"galaxy/postgres"
"galaxy/redisconn"
)
const (
envPrefix = "LOBBY"
shutdownTimeoutEnvVar = "LOBBY_SHUTDOWN_TIMEOUT"
logLevelEnvVar = "LOBBY_LOG_LEVEL"
@@ -25,13 +28,6 @@ const (
internalHTTPReadTimeoutEnvVar = "LOBBY_INTERNAL_HTTP_READ_TIMEOUT"
internalHTTPIdleTimeoutEnvVar = "LOBBY_INTERNAL_HTTP_IDLE_TIMEOUT"
redisAddrEnvVar = "LOBBY_REDIS_ADDR"
redisUsernameEnvVar = "LOBBY_REDIS_USERNAME"
redisPasswordEnvVar = "LOBBY_REDIS_PASSWORD"
redisDBEnvVar = "LOBBY_REDIS_DB"
redisTLSEnabledEnvVar = "LOBBY_REDIS_TLS_ENABLED"
redisOperationTimeoutEnvVar = "LOBBY_REDIS_OPERATION_TIMEOUT"
gmEventsStreamEnvVar = "LOBBY_GM_EVENTS_STREAM"
gmEventsReadBlockTimeoutEnvVar = "LOBBY_GM_EVENTS_READ_BLOCK_TIMEOUT"
userLifecycleStreamEnvVar = "LOBBY_USER_LIFECYCLE_STREAM"
@@ -69,8 +65,6 @@ const (
defaultReadHeaderTimeout = 2 * time.Second
defaultReadTimeout = 10 * time.Second
defaultIdleTimeout = time.Minute
defaultRedisDB = 0
defaultRedisOperationTimeout = 2 * time.Second
defaultGMEventsStream = "gm:lobby_events"
defaultGMEventsReadBlockTimeout = 2 * time.Second
defaultUserLifecycleStream = "user:lifecycle_events"
@@ -86,12 +80,13 @@ const (
defaultRaceNameExpirationInterval = time.Hour
defaultOTelServiceName = "galaxy-lobby"
// RaceNameDirectoryBackendRedis selects the Redis-backed Race Name
// Directory adapter. It is the default production backend.
RaceNameDirectoryBackendRedis = "redis"
// RaceNameDirectoryBackendPostgres selects the PostgreSQL-backed
// Race Name Directory adapter. It is the default production backend
// after PG_PLAN.md §6B.
RaceNameDirectoryBackendPostgres = "postgres"
// RaceNameDirectoryBackendStub selects the in-process Race Name
// Directory stub used by unit tests that do not need Redis.
// Directory stub used by unit tests that do not need PostgreSQL.
RaceNameDirectoryBackendStub = "stub"
)
@@ -115,6 +110,10 @@ type Config struct {
// consumed by the runnable service skeleton and its future workers.
Redis RedisConfig
// Postgres configures the PostgreSQL-backed durable store consumed via
// `pkg/postgres`.
Postgres PostgresConfig
// UserService configures the synchronous User Service eligibility client.
UserService UserServiceConfig
@@ -143,7 +142,7 @@ type Config struct {
// is wired into the runtime.
type RaceNameDirectoryConfig struct {
// Backend selects the Race Name Directory adapter. Accepted values
// are RaceNameDirectoryBackendRedis and RaceNameDirectoryBackendStub.
// are RaceNameDirectoryBackendPostgres and RaceNameDirectoryBackendStub.
Backend string
}
@@ -151,14 +150,14 @@ type RaceNameDirectoryConfig struct {
// backend selector.
func (cfg RaceNameDirectoryConfig) Validate() error {
switch cfg.Backend {
case RaceNameDirectoryBackendRedis, RaceNameDirectoryBackendStub:
case RaceNameDirectoryBackendPostgres, RaceNameDirectoryBackendStub:
return nil
case "":
return fmt.Errorf("race name directory backend must not be empty")
default:
return fmt.Errorf("race name directory backend %q must be one of %q or %q",
cfg.Backend,
RaceNameDirectoryBackendRedis,
RaceNameDirectoryBackendPostgres,
RaceNameDirectoryBackendStub)
}
}
@@ -237,26 +236,15 @@ func (cfg InternalHTTPConfig) Validate() error {
}
}
// RedisConfig configures the shared Redis client and the Redis-owned
// Streams keys consumed by the runnable service skeleton.
// RedisConfig configures the Game Lobby Redis connection topology and the
// Redis Stream names Lobby reads from / writes to. Per-call timeouts and
// connection topology live inside `Conn`.
type RedisConfig struct {
// Addr stores the Redis network address.
Addr string
// Username stores the optional Redis ACL username.
Username string
// Password stores the optional Redis ACL password.
Password string
// DB stores the Redis logical database index.
DB int
// TLSEnabled reports whether TLS must be used for Redis connections.
TLSEnabled bool
// OperationTimeout bounds one Redis round trip including the startup PING.
OperationTimeout time.Duration
// Conn carries the connection topology (master, replicas, password, db,
// per-call timeout). Loaded via redisconn.LoadFromEnv("LOBBY"); rejects
// the deprecated LOBBY_REDIS_TLS_ENABLED / LOBBY_REDIS_USERNAME env vars
// at startup.
Conn redisconn.Config
// GMEventsStream stores the Redis Streams key for Game Master runtime
// events consumed by Lobby.
@@ -297,27 +285,12 @@ type RedisConfig struct {
UserLifecycleReadBlockTimeout time.Duration
}
// TLSConfig returns the conservative TLS configuration used by the Redis
// client when TLSEnabled is true.
func (cfg RedisConfig) TLSConfig() *tls.Config {
if !cfg.TLSEnabled {
return nil
}
return &tls.Config{MinVersion: tls.VersionTLS12}
}
// Validate reports whether cfg stores a usable Redis configuration.
func (cfg RedisConfig) Validate() error {
if err := cfg.Conn.Validate(); err != nil {
return err
}
switch {
case strings.TrimSpace(cfg.Addr) == "":
return fmt.Errorf("redis addr must not be empty")
case !isTCPAddr(cfg.Addr):
return fmt.Errorf("redis addr %q must use host:port form", cfg.Addr)
case cfg.DB < 0:
return fmt.Errorf("redis db must not be negative")
case cfg.OperationTimeout <= 0:
return fmt.Errorf("redis operation timeout must be positive")
case strings.TrimSpace(cfg.GMEventsStream) == "":
return fmt.Errorf("redis gm events stream must not be empty")
case cfg.GMEventsReadBlockTimeout <= 0:
@@ -341,6 +314,19 @@ func (cfg RedisConfig) Validate() error {
}
}
// PostgresConfig configures the PostgreSQL-backed durable store consumed via
// `pkg/postgres`. Topology and pool tuning live in `Conn`; loaded via
// `postgres.LoadFromEnv("LOBBY")`.
type PostgresConfig struct {
// Conn carries the primary plus replica DSN topology and pool tuning.
Conn postgres.Config
}
// Validate reports whether cfg stores a usable PostgreSQL configuration.
func (cfg PostgresConfig) Validate() error {
return cfg.Conn.Validate()
}
// UserServiceConfig configures the synchronous User Service eligibility
// client used by the application flow.
type UserServiceConfig struct {
@@ -489,8 +475,7 @@ func DefaultConfig() Config {
IdleTimeout: defaultIdleTimeout,
},
Redis: RedisConfig{
DB: defaultRedisDB,
OperationTimeout: defaultRedisOperationTimeout,
Conn: redisconn.DefaultConfig(),
GMEventsStream: defaultGMEventsStream,
GMEventsReadBlockTimeout: defaultGMEventsReadBlockTimeout,
RuntimeStartJobsStream: defaultRuntimeStartJobsStream,
@@ -501,6 +486,9 @@ func DefaultConfig() Config {
UserLifecycleStream: defaultUserLifecycleStream,
UserLifecycleReadBlockTimeout: defaultUserLifecycleReadBlockTimeout,
},
Postgres: PostgresConfig{
Conn: postgres.DefaultConfig(),
},
UserService: UserServiceConfig{
Timeout: defaultUserServiceTimeout,
},
@@ -511,7 +499,7 @@ func DefaultConfig() Config {
Interval: defaultEnrollmentAutomationInterval,
},
RaceNameDirectory: RaceNameDirectoryConfig{
Backend: RaceNameDirectoryBackendRedis,
Backend: RaceNameDirectoryBackendPostgres,
},
PendingRegistration: PendingRegistrationConfig{
Interval: defaultRaceNameExpirationInterval,
+96 -42
View File
@@ -5,10 +5,21 @@ import (
"testing"
"time"
"galaxy/postgres"
"galaxy/redisconn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testDSN = "postgres://lobbyservice:lobbyservice@127.0.0.1:5432/galaxy?search_path=lobby&sslmode=disable"
testRedisAddr = "127.0.0.1:6379"
testRedisSecret = "secret"
testUserBaseURL = "http://user.internal:8090"
testGMBaseURL = "http://gm.internal:8091"
)
func TestDefaultConfig(t *testing.T) {
t.Parallel()
@@ -18,7 +29,8 @@ func TestDefaultConfig(t *testing.T) {
assert.Equal(t, "info", cfg.Logging.Level)
assert.Equal(t, ":8094", cfg.PublicHTTP.Addr)
assert.Equal(t, ":8095", cfg.InternalHTTP.Addr)
assert.Equal(t, 2*time.Second, cfg.Redis.OperationTimeout)
assert.Equal(t, redisconn.DefaultOperationTimeout, cfg.Redis.Conn.OperationTimeout)
assert.Equal(t, postgres.DefaultOperationTimeout, cfg.Postgres.Conn.OperationTimeout)
assert.Equal(t, "gm:lobby_events", cfg.Redis.GMEventsStream)
assert.Equal(t, "runtime:start_jobs", cfg.Redis.RuntimeStartJobsStream)
assert.Equal(t, "runtime:stop_jobs", cfg.Redis.RuntimeStopJobsStream)
@@ -35,16 +47,20 @@ func TestDefaultConfig(t *testing.T) {
func TestLoadFromEnvAppliesRequiredFields(t *testing.T) {
clearAllEnv(t)
t.Setenv("LOBBY_REDIS_ADDR", "127.0.0.1:6379")
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", "http://user.internal:8090")
t.Setenv("LOBBY_GM_BASE_URL", "http://gm.internal:8091")
t.Setenv("LOBBY_REDIS_MASTER_ADDR", testRedisAddr)
t.Setenv("LOBBY_REDIS_PASSWORD", testRedisSecret)
t.Setenv("LOBBY_POSTGRES_PRIMARY_DSN", testDSN)
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", testUserBaseURL)
t.Setenv("LOBBY_GM_BASE_URL", testGMBaseURL)
cfg, err := LoadFromEnv()
require.NoError(t, err)
assert.Equal(t, "127.0.0.1:6379", cfg.Redis.Addr)
assert.Equal(t, "http://user.internal:8090", cfg.UserService.BaseURL)
assert.Equal(t, "http://gm.internal:8091", cfg.GM.BaseURL)
assert.Equal(t, testRedisAddr, cfg.Redis.Conn.MasterAddr)
assert.Equal(t, testRedisSecret, cfg.Redis.Conn.Password)
assert.Equal(t, testDSN, cfg.Postgres.Conn.PrimaryDSN)
assert.Equal(t, testUserBaseURL, cfg.UserService.BaseURL)
assert.Equal(t, testGMBaseURL, cfg.GM.BaseURL)
}
func TestLoadFromEnvMissingRequiredFields(t *testing.T) {
@@ -52,21 +68,48 @@ func TestLoadFromEnvMissingRequiredFields(t *testing.T) {
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "redis addr must not be empty")
require.Contains(t, err.Error(), "LOBBY_REDIS_MASTER_ADDR")
}
func TestLoadFromEnvRejectsDeprecatedRedisVars(t *testing.T) {
tests := []struct {
name string
envName string
}{
{name: "TLS_ENABLED", envName: "LOBBY_REDIS_TLS_ENABLED"},
{name: "USERNAME", envName: "LOBBY_REDIS_USERNAME"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clearAllEnv(t)
t.Setenv("LOBBY_REDIS_MASTER_ADDR", testRedisAddr)
t.Setenv("LOBBY_REDIS_PASSWORD", testRedisSecret)
t.Setenv("LOBBY_POSTGRES_PRIMARY_DSN", testDSN)
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", testUserBaseURL)
t.Setenv("LOBBY_GM_BASE_URL", testGMBaseURL)
t.Setenv(tt.envName, "anything")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), tt.envName)
})
}
}
func TestLoadFromEnvOverrides(t *testing.T) {
clearAllEnv(t)
t.Setenv("LOBBY_REDIS_ADDR", "127.0.0.1:6379")
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", "http://user.internal:8090")
t.Setenv("LOBBY_GM_BASE_URL", "http://gm.internal:8091")
t.Setenv("LOBBY_REDIS_MASTER_ADDR", testRedisAddr)
t.Setenv("LOBBY_REDIS_PASSWORD", testRedisSecret)
t.Setenv("LOBBY_POSTGRES_PRIMARY_DSN", testDSN)
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", testUserBaseURL)
t.Setenv("LOBBY_GM_BASE_URL", testGMBaseURL)
t.Setenv("LOBBY_SHUTDOWN_TIMEOUT", "12s")
t.Setenv("LOBBY_LOG_LEVEL", "debug")
t.Setenv("LOBBY_PUBLIC_HTTP_ADDR", "127.0.0.1:9001")
t.Setenv("LOBBY_INTERNAL_HTTP_ADDR", "127.0.0.1:9002")
t.Setenv("LOBBY_REDIS_DB", "5")
t.Setenv("LOBBY_REDIS_TLS_ENABLED", "true")
t.Setenv("LOBBY_REDIS_OPERATION_TIMEOUT", "300ms")
t.Setenv("LOBBY_GM_EVENTS_STREAM", "alt:gm_events")
t.Setenv("LOBBY_NOTIFICATION_INTENTS_STREAM", "alt:intents")
t.Setenv("LOBBY_ENROLLMENT_AUTOMATION_INTERVAL", "45s")
@@ -80,21 +123,22 @@ func TestLoadFromEnvOverrides(t *testing.T) {
assert.Equal(t, "debug", cfg.Logging.Level)
assert.Equal(t, "127.0.0.1:9001", cfg.PublicHTTP.Addr)
assert.Equal(t, "127.0.0.1:9002", cfg.InternalHTTP.Addr)
assert.Equal(t, 5, cfg.Redis.DB)
assert.True(t, cfg.Redis.TLSEnabled)
assert.Equal(t, 5, cfg.Redis.Conn.DB)
assert.Equal(t, 300*time.Millisecond, cfg.Redis.Conn.OperationTimeout)
assert.Equal(t, "alt:gm_events", cfg.Redis.GMEventsStream)
assert.Equal(t, "alt:intents", cfg.Redis.NotificationIntentsStream)
assert.Equal(t, 45*time.Second, cfg.EnrollmentAutomation.Interval)
assert.Equal(t, 15*time.Minute, cfg.PendingRegistration.Interval)
assert.Equal(t, "galaxy-lobby-test", cfg.Telemetry.ServiceName)
assert.NotNil(t, cfg.Redis.TLSConfig())
}
func TestLoadFromEnvInvalidDuration(t *testing.T) {
clearAllEnv(t)
t.Setenv("LOBBY_REDIS_ADDR", "127.0.0.1:6379")
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", "http://user.internal:8090")
t.Setenv("LOBBY_GM_BASE_URL", "http://gm.internal:8091")
t.Setenv("LOBBY_REDIS_MASTER_ADDR", testRedisAddr)
t.Setenv("LOBBY_REDIS_PASSWORD", testRedisSecret)
t.Setenv("LOBBY_POSTGRES_PRIMARY_DSN", testDSN)
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", testUserBaseURL)
t.Setenv("LOBBY_GM_BASE_URL", testGMBaseURL)
t.Setenv("LOBBY_SHUTDOWN_TIMEOUT", "not-a-duration")
_, err := LoadFromEnv()
@@ -153,7 +197,8 @@ func TestRedisConfigValidate(t *testing.T) {
t.Parallel()
base := DefaultConfig().Redis
base.Addr = "127.0.0.1:6379"
base.Conn.MasterAddr = testRedisAddr
base.Conn.Password = testRedisSecret
require.NoError(t, base.Validate())
tests := []struct {
@@ -161,10 +206,10 @@ func TestRedisConfigValidate(t *testing.T) {
mutate func(*RedisConfig)
wantErr string
}{
{name: "empty addr", mutate: func(cfg *RedisConfig) { cfg.Addr = "" }, wantErr: "addr must not be empty"},
{name: "bad addr", mutate: func(cfg *RedisConfig) { cfg.Addr = "weird" }, wantErr: "must use host:port"},
{name: "negative db", mutate: func(cfg *RedisConfig) { cfg.DB = -1 }, wantErr: "must not be negative"},
{name: "zero op timeout", mutate: func(cfg *RedisConfig) { cfg.OperationTimeout = 0 }, wantErr: "operation timeout"},
{name: "empty master addr", mutate: func(cfg *RedisConfig) { cfg.Conn.MasterAddr = "" }, wantErr: "master addr"},
{name: "empty password", mutate: func(cfg *RedisConfig) { cfg.Conn.Password = "" }, wantErr: "password"},
{name: "negative db", mutate: func(cfg *RedisConfig) { cfg.Conn.DB = -1 }, wantErr: "must not be negative"},
{name: "zero op timeout", mutate: func(cfg *RedisConfig) { cfg.Conn.OperationTimeout = 0 }, wantErr: "operation timeout"},
{name: "empty gm stream", mutate: func(cfg *RedisConfig) { cfg.GMEventsStream = "" }, wantErr: "gm events stream"},
{name: "zero gm block", mutate: func(cfg *RedisConfig) { cfg.GMEventsReadBlockTimeout = 0 }, wantErr: "gm events read block timeout"},
{name: "empty start jobs", mutate: func(cfg *RedisConfig) { cfg.RuntimeStartJobsStream = "" }, wantErr: "runtime start jobs"},
@@ -188,6 +233,18 @@ func TestRedisConfigValidate(t *testing.T) {
}
}
func TestPostgresConfigValidate(t *testing.T) {
t.Parallel()
base := DefaultConfig().Postgres
base.Conn.PrimaryDSN = testDSN
require.NoError(t, base.Validate())
bad := base
bad.Conn.PrimaryDSN = ""
require.ErrorContains(t, bad.Validate(), "primary DSN")
}
func TestUserServiceConfigValidate(t *testing.T) {
t.Parallel()
@@ -255,7 +312,9 @@ func TestConfigValidateLogLevel(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.Redis.Addr = "127.0.0.1:6379"
cfg.Redis.Conn.MasterAddr = testRedisAddr
cfg.Redis.Conn.Password = testRedisSecret
cfg.Postgres.Conn.PrimaryDSN = testDSN
cfg.UserService.BaseURL = "http://u:1"
cfg.GM.BaseURL = "http://gm:1"
require.NoError(t, cfg.Validate())
@@ -266,18 +325,6 @@ func TestConfigValidateLogLevel(t *testing.T) {
require.Contains(t, err.Error(), "slog level")
}
func TestLoadFromEnvBoolParseError(t *testing.T) {
clearAllEnv(t)
t.Setenv("LOBBY_REDIS_ADDR", "127.0.0.1:6379")
t.Setenv("LOBBY_USER_SERVICE_BASE_URL", "http://u:1")
t.Setenv("LOBBY_GM_BASE_URL", "http://gm:1")
t.Setenv("LOBBY_REDIS_TLS_ENABLED", "not-bool")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "LOBBY_REDIS_TLS_ENABLED")
}
// clearAllEnv unsets every environment variable the config package reads so
// tests can configure their expected values explicitly.
func clearAllEnv(t *testing.T) {
@@ -294,12 +341,19 @@ func clearAllEnv(t *testing.T) {
internalHTTPReadHeaderTimeoutEnvVar,
internalHTTPReadTimeoutEnvVar,
internalHTTPIdleTimeoutEnvVar,
redisAddrEnvVar,
redisUsernameEnvVar,
redisPasswordEnvVar,
redisDBEnvVar,
redisTLSEnabledEnvVar,
redisOperationTimeoutEnvVar,
"LOBBY_REDIS_MASTER_ADDR",
"LOBBY_REDIS_REPLICA_ADDRS",
"LOBBY_REDIS_PASSWORD",
"LOBBY_REDIS_DB",
"LOBBY_REDIS_OPERATION_TIMEOUT",
"LOBBY_REDIS_TLS_ENABLED",
"LOBBY_REDIS_USERNAME",
"LOBBY_POSTGRES_PRIMARY_DSN",
"LOBBY_POSTGRES_REPLICA_DSNS",
"LOBBY_POSTGRES_OPERATION_TIMEOUT",
"LOBBY_POSTGRES_MAX_OPEN_CONNS",
"LOBBY_POSTGRES_MAX_IDLE_CONNS",
"LOBBY_POSTGRES_CONN_MAX_LIFETIME",
gmEventsStreamEnvVar,
gmEventsReadBlockTimeoutEnvVar,
runtimeStartJobsStreamEnvVar,
+9 -9
View File
@@ -6,6 +6,9 @@ import (
"strconv"
"strings"
"time"
"galaxy/postgres"
"galaxy/redisconn"
)
// LoadFromEnv builds Config from environment variables and validates the
@@ -50,21 +53,18 @@ func LoadFromEnv() (Config, error) {
return Config{}, err
}
cfg.Redis.Addr = stringEnv(redisAddrEnvVar, cfg.Redis.Addr)
cfg.Redis.Username = stringEnv(redisUsernameEnvVar, cfg.Redis.Username)
cfg.Redis.Password = stringEnv(redisPasswordEnvVar, cfg.Redis.Password)
cfg.Redis.DB, err = intEnv(redisDBEnvVar, cfg.Redis.DB)
redisConn, err := redisconn.LoadFromEnv(envPrefix)
if err != nil {
return Config{}, err
}
cfg.Redis.TLSEnabled, err = boolEnv(redisTLSEnabledEnvVar, cfg.Redis.TLSEnabled)
if err != nil {
return Config{}, err
}
cfg.Redis.OperationTimeout, err = durationEnv(redisOperationTimeoutEnvVar, cfg.Redis.OperationTimeout)
cfg.Redis.Conn = redisConn
pgConn, err := postgres.LoadFromEnv(envPrefix)
if err != nil {
return Config{}, err
}
cfg.Postgres.Conn = pgConn
cfg.Redis.GMEventsStream = stringEnv(gmEventsStreamEnvVar, cfg.Redis.GMEventsStream)
cfg.Redis.GMEventsReadBlockTimeout, err = durationEnv(gmEventsReadBlockTimeoutEnvVar, cfg.Redis.GMEventsReadBlockTimeout)
if err != nil {