Skip to content

Implement Redis Storage Backend for transport/session (RC-6) #4205

@yrobla

Description

@yrobla

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.go exists and implements all five Storage interface methods: Store, Load, Delete, DeleteExpired, Close
  • RedisStorage.Store serializes the session using serializeSession and persists it with SET … EX, refreshing the TTL on every call
  • RedisStorage.Load deserializes the session using deserializeSession and returns ErrSessionNotFound when the key does not exist in Redis
  • RedisStorage.Delete removes the Redis key; returns nil (not an error) when the key does not exist
  • RedisStorage.DeleteExpired is implemented as a no-op (returns nil); Redis TTL handles key expiry natively
  • RedisStorage.Close closes the underlying Redis client connection
  • pkg/transport/session/redis_config.go defines RedisConfig, SentinelConfig, and RedisTLSConfig structs as specified in the architecture document
  • RedisConfig supports standalone mode (Addr) and Redis Sentinel mode (SentinelConfig), which are mutually exclusive
  • RedisConfig supports optional TLS configuration via RedisTLSConfig (nil = plaintext; CACert nil = system roots)
  • RedisConfig includes KeyPrefix used to namespace all session keys (e.g. "thv:vmcp:session:")
  • RedisConfig includes configurable DialTimeout, ReadTimeout, WriteTimeout with sensible defaults matching the authserver pattern (5s, 3s, 3s)
  • Key format used by RedisStorage is {KeyPrefix}{sessionID}
  • All serialization goes through serializeSession/deserializeSession exclusively (no second encoding path)
  • Unit tests cover Store, Load, Delete, DeleteExpired, Close, and TTL refresh behavior using miniredis
  • All tests pass (go test ./pkg/transport/session/...)
  • Code reviewed and approved

Technical Approach

Recommended Implementation

Create two new files in pkg/transport/session/:

  1. redis_config.go: Define RedisConfig, SentinelConfig, and RedisTLSConfig structs. Follow the field names and structure specified in architecture.md. Use default timeout constants matching those in pkg/authserver/storage/redis.go (DefaultDialTimeout = 5s, DefaultReadTimeout = 3s, DefaultWriteTimeout = 3s).

  2. storage_redis.go: Implement RedisStorage using github.com/redis/go-redis/v9. Construct either a redis.NewClient (standalone) or redis.NewFailoverClient (Sentinel) based on whether RedisConfig.SentinelConfig is non-nil. Follow the TLS setup pattern from pkg/authserver/storage/redis.go. All Store calls must use SET … EX (i.e. client.Set(ctx, key, value, ttl)) to refresh the TTL. For DeleteExpired, return nil immediately — Redis handles TTL expiry natively. The Close method should call client.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; use redis.NewClient for standalone, redis.NewFailoverClient for Sentinel
  • github.com/alicebob/miniredis/v2 — already a direct dependency; use for unit tests (no live Redis required)
  • Follow pkg/authserver/storage/redis.go for: TLS construction (crypto/tls.Config, x509.CertPool), timeout defaults, key-prefix namespacing pattern, and redis.NewClient / redis.NewFailoverClient construction
  • Follow pkg/transport/session/storage_local.go as the canonical Storage implementation analog — match error messages, nil-check patterns, and ErrSessionNotFound usage
  • Use testify/assert and testify/require consistent with pkg/transport/session/storage_test.go

Code Pointers

  • pkg/transport/session/storage.goStorage interface definition; all five methods must be implemented
  • pkg/transport/session/storage_local.go — canonical Storage implementation to follow for structure, error handling, and nil guards
  • pkg/transport/session/serialization.goserializeSession/deserializeSession: the only serialization path; currently annotated nolint:unused pending this implementation
  • pkg/transport/session/storage_test.go — existing test patterns for Storage implementations; new RedisStorage tests should follow the same style
  • pkg/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 how NewManagerWithStorage accepts a Storage; the manager's ttl field 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() error

Testing 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

  • Store and Load round-trip: store a session, load it back, verify ID, type, and metadata are preserved
  • Store with nil session returns an error
  • Store with empty session ID returns an error
  • Load for a non-existent key returns ErrSessionNotFound
  • Load with empty ID returns an error
  • Delete removes the key; subsequent Load returns ErrSessionNotFound
  • Delete on a non-existent key returns nil (not an error)
  • DeleteExpired returns nil without modifying any keys (no-op)
  • TTL refresh on Store: after a first Store, advance miniredis clock, call Store again, verify the TTL was refreshed (key still alive past the original expiry)
  • Close closes the client; subsequent operations return an error

Integration Tests

  • None required for this task; unit tests with miniredis provide sufficient coverage

Edge Cases

  • Store for a session that already exists overwrites it (idempotent upsert)
  • Key format is correctly {KeyPrefix}{sessionID} (verify with mr.Get(key) in miniredis)
  • All session types (SessionTypeMCP, SessionTypeSSE, SessionTypeStreamable) round-trip correctly through serialization

Out of Scope

  • Wiring RedisStorage into session.Manager (that is TASK-002 / RC-7)
  • Any changes to pkg/vmcp packages
  • Redis Cluster support (only standalone and Sentinel are required per architecture)
  • Operational concerns: Redis provisioning, Kubernetes configuration, deployment manifests
  • Aggregator state or MultiSession persistence

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestscalabilityItems related to scalabilityvmcpVirtual MCP Server related issues

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions