-
Notifications
You must be signed in to change notification settings - Fork 198
Implement Redis Storage Backend for transport/session (RC-6) #4205
Description
Description
Implement a Redis-backed Storage implementation (RedisStorage) for the pkg/transport/session package, along with its companion RedisConfig struct. This is the foundational storage layer required for horizontally scaling the vMCP component: session metadata must be readable by any replica, not just the one that created the session.
This is the first task in the THV-0047 epic. All subsequent vMCP horizontal scaling tasks (RC-7 through RC-16) depend on RedisStorage being available as a reliable, interface-compliant storage backend.
Context
The vMCP component currently stores all session state in-process (LocalStorage). To support multiple replicas behind a load balancer, session metadata must be externalized to a shared store. This task implements RedisStorage in pkg/transport/session/storage_redis.go, fulfilling the existing Storage interface using github.com/redis/go-redis/v9, and adds the RedisConfig struct in pkg/transport/session/redis_config.go to configure the backend.
Parent epic: stacklok/stacklok-epics#262
Dependencies: None (root task)
Blocks: TASK-002 (RC-7: Wire Redis Backend Selection into session.Manager)
Acceptance Criteria
-
pkg/transport/session/storage_redis.goexists and implements all fiveStorageinterface methods:Store,Load,Delete,DeleteExpired,Close -
RedisStorage.Storeserializes the session usingserializeSessionand persists it withSET … EX, refreshing the TTL on every call -
RedisStorage.Loaddeserializes the session usingdeserializeSessionand returnsErrSessionNotFoundwhen the key does not exist in Redis -
RedisStorage.Deleteremoves the Redis key; returnsnil(not an error) when the key does not exist -
RedisStorage.DeleteExpiredis implemented as a no-op (returnsnil); Redis TTL handles key expiry natively -
RedisStorage.Closecloses the underlying Redis client connection -
pkg/transport/session/redis_config.godefinesRedisConfig,SentinelConfig, andRedisTLSConfigstructs as specified in the architecture document -
RedisConfigsupports standalone mode (Addr) and Redis Sentinel mode (SentinelConfig), which are mutually exclusive -
RedisConfigsupports optional TLS configuration viaRedisTLSConfig(nil = plaintext;CACertnil = system roots) -
RedisConfigincludesKeyPrefixused to namespace all session keys (e.g."thv:vmcp:session:") -
RedisConfigincludes configurableDialTimeout,ReadTimeout,WriteTimeoutwith sensible defaults matching the authserver pattern (5s,3s,3s) - Key format used by
RedisStorageis{KeyPrefix}{sessionID} - All serialization goes through
serializeSession/deserializeSessionexclusively (no second encoding path) - Unit tests cover
Store,Load,Delete,DeleteExpired,Close, and TTL refresh behavior usingminiredis - All tests pass (
go test ./pkg/transport/session/...) - Code reviewed and approved
Technical Approach
Recommended Implementation
Create two new files in pkg/transport/session/:
-
redis_config.go: DefineRedisConfig,SentinelConfig, andRedisTLSConfigstructs. Follow the field names and structure specified inarchitecture.md. Use default timeout constants matching those inpkg/authserver/storage/redis.go(DefaultDialTimeout = 5s,DefaultReadTimeout = 3s,DefaultWriteTimeout = 3s). -
storage_redis.go: ImplementRedisStorageusinggithub.com/redis/go-redis/v9. Construct either aredis.NewClient(standalone) orredis.NewFailoverClient(Sentinel) based on whetherRedisConfig.SentinelConfigis non-nil. Follow the TLS setup pattern frompkg/authserver/storage/redis.go. AllStorecalls must useSET … EX(i.e.client.Set(ctx, key, value, ttl)) to refresh the TTL. ForDeleteExpired, returnnilimmediately — Redis handles TTL expiry natively. TheClosemethod should callclient.Close().
The nolint:unused annotations on serializeSession/deserializeSession in serialization.go will be resolved once RedisStorage calls them.
Patterns & Frameworks
github.com/redis/go-redis/v9— already a direct dependency; useredis.NewClientfor standalone,redis.NewFailoverClientfor Sentinelgithub.com/alicebob/miniredis/v2— already a direct dependency; use for unit tests (no live Redis required)- Follow
pkg/authserver/storage/redis.gofor: TLS construction (crypto/tls.Config,x509.CertPool), timeout defaults, key-prefix namespacing pattern, andredis.NewClient/redis.NewFailoverClientconstruction - Follow
pkg/transport/session/storage_local.goas the canonicalStorageimplementation analog — match error messages, nil-check patterns, andErrSessionNotFoundusage - Use
testify/assertandtestify/requireconsistent withpkg/transport/session/storage_test.go
Code Pointers
pkg/transport/session/storage.go—Storageinterface definition; all five methods must be implementedpkg/transport/session/storage_local.go— canonicalStorageimplementation to follow for structure, error handling, and nil guardspkg/transport/session/serialization.go—serializeSession/deserializeSession: the only serialization path; currently annotatednolint:unusedpending this implementationpkg/transport/session/storage_test.go— existing test patterns forStorageimplementations; newRedisStoragetests should follow the same stylepkg/authserver/storage/redis.go— reference for Redis client construction (TLS setup, Sentinel vs. standalone, timeout defaults, key-prefix pattern)pkg/transport/session/manager.go— shows howNewManagerWithStorageaccepts aStorage; the manager'sttlfield is the value to pass as the Redis key TTL
Component Interfaces
// redis_config.go
// RedisConfig configures the Redis storage backend for session storage.
// Addr is used for standalone; SentinelConfig activates Sentinel mode (mutually exclusive).
type RedisConfig struct {
Addr string // standalone: "host:port"
SentinelConfig *SentinelConfig // non-nil activates Sentinel mode
Password string
DB int
KeyPrefix string // e.g. "thv:vmcp:session:"
DialTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
TLS *RedisTLSConfig // nil = plaintext
}
type SentinelConfig struct {
MasterName string
SentinelAddrs []string
}
type RedisTLSConfig struct {
InsecureSkipVerify bool
CACert []byte // PEM; nil = system roots
}
// storage_redis.go
// RedisStorage implements the Storage interface backed by Redis.
type RedisStorage struct {
client redis.UniversalClient
keyPrefix string
ttl time.Duration
}
// NewRedisStorage constructs a RedisStorage from a RedisConfig.
// ttl is the expiry applied to every key on Store.
func NewRedisStorage(cfg RedisConfig, ttl time.Duration) (*RedisStorage, error)
// Store serializes the session with serializeSession and calls SET key value EX ttl.
// TTL is refreshed on every Store call.
func (s *RedisStorage) Store(ctx context.Context, session Session) error
// Load calls GET on the key and deserializes with deserializeSession.
// Returns ErrSessionNotFound when the key is absent.
func (s *RedisStorage) Load(ctx context.Context, id string) (Session, error)
// Delete removes the key. A missing key is not an error.
func (s *RedisStorage) Delete(ctx context.Context, id string) error
// DeleteExpired is a no-op. Redis TTL handles expiry natively.
func (s *RedisStorage) DeleteExpired(_ context.Context, _ time.Time) error
// Close closes the underlying Redis client.
func (s *RedisStorage) Close() errorTesting Strategy
Use github.com/alicebob/miniredis/v2 to run an in-process Redis server — no external Redis dependency required. Follow the helper pattern from pkg/authserver/storage/redis_test.go (newTestRedisStorage, withRedisStorage).
Unit Tests
-
StoreandLoadround-trip: store a session, load it back, verify ID, type, and metadata are preserved -
Storewith nil session returns an error -
Storewith empty session ID returns an error -
Loadfor a non-existent key returnsErrSessionNotFound -
Loadwith empty ID returns an error -
Deleteremoves the key; subsequentLoadreturnsErrSessionNotFound -
Deleteon a non-existent key returnsnil(not an error) -
DeleteExpiredreturnsnilwithout modifying any keys (no-op) - TTL refresh on
Store: after a firstStore, advance miniredis clock, callStoreagain, verify the TTL was refreshed (key still alive past the original expiry) -
Closecloses the client; subsequent operations return an error
Integration Tests
- None required for this task; unit tests with miniredis provide sufficient coverage
Edge Cases
-
Storefor a session that already exists overwrites it (idempotent upsert) - Key format is correctly
{KeyPrefix}{sessionID}(verify withmr.Get(key)in miniredis) - All session types (
SessionTypeMCP,SessionTypeSSE,SessionTypeStreamable) round-trip correctly through serialization
Out of Scope
- Wiring
RedisStorageintosession.Manager(that is TASK-002 / RC-7) - Any changes to
pkg/vmcppackages - Redis Cluster support (only standalone and Sentinel are required per architecture)
- Operational concerns: Redis provisioning, Kubernetes configuration, deployment manifests
- Aggregator state or
MultiSessionpersistence
References
- RFC THV-0047: RFC: Horizontal Scaling for vMCP and Proxy Runner toolhive-rfcs#47
- Parent epic: stacklok/stacklok-epics#262
Storageinterface:pkg/transport/session/storage.go- Authserver Redis reference implementation:
pkg/authserver/storage/redis.go - Serialization functions:
pkg/transport/session/serialization.go