-
Notifications
You must be signed in to change notification settings - Fork 198
Persist vMCP Session Metadata to Redis on Creation (RC-8) #4211
Description
Description
Update defaultMultiSessionFactory.makeSession() in pkg/vmcp/session/factory.go to persist per-backend session IDs into the transport-layer session metadata when a new MultiSession is created. Specifically, this adds the new constant MetadataKeyBackendSessionPrefix and extends populateBackendMetadata (or an equivalent helper) to write MetadataKeyBackendSessionPrefix+workloadID → backend_session_id for each successfully connected backend, alongside the already-written MetadataKeyBackendIDs.
This task provides the data that downstream tasks (RC-9 / TASK-005 and RC-16 / TASK-007) depend on to reconstruct and clean up backend sessions across replicas: without per-backend session IDs in Redis, RestoreSession cannot pass meaningful backend_session_id hints to the connector when rebuilding a session on a cache miss.
Context
The vMCP horizontal scaling epic (THV-0047) externalizes session state to Redis so that any replica can serve any client request. makeSession already writes MetadataKeyBackendIDs (comma-separated workload IDs of connected backends) via populateBackendMetadata, and it already collects per-backend session IDs into the backendSessions map (local runtime use). However, per-backend session IDs are never written to the serializable transport-session metadata, meaning they are lost on LRU eviction.
RC-8 closes this gap: by writing vmcp.backend.session.{workloadID} into transport-session metadata during makeSession, the per-backend session IDs flow through to Redis (once RC-7 wires RedisStorage into the manager) and can later be retrieved by RestoreSession (RC-9) to reconnect backends.
Parent epic: stacklok/stacklok-epics#262
Dependencies: #266 (RC-6: Redis Storage Backend), #270 (RC-7: Wire Redis Backend Selection into session.Manager)
Blocks: TASK-004 (RC-15: Persist Hijack-Prevention State), TASK-005 (RC-9: Reconstruct Sessions on Cache Miss), TASK-007 (RC-16: Update Redis Metadata on Backend Session Expiry)
Acceptance Criteria
- A new exported constant
MetadataKeyBackendSessionPrefix = "vmcp.backend.session."is defined inpkg/vmcp/session/factory.goalongside the existingMetadataKeyBackendIDs -
populateBackendMetadata(or an equivalent helper) writesMetadataKeyBackendSessionPrefix+workloadID→r.conn.SessionID()for each successfully initialized backend in theresultsslice - The per-backend session ID metadata entries are written as part of
makeSession, beforesecurity.PreventSessionHijackingwraps the session - When no backends connect successfully, no
MetadataKeyBackendSessionPrefix+*keys are written (consistent with the existing behavior forMetadataKeyBackendIDs) - When some backends fail to connect and others succeed, only entries for successful backends are written (partial initialization tolerance)
-
GetMetadata()on the returnedMultiSessioncontains one entry per successful backend with the key"vmcp.backend.session.{workloadID}"and a non-empty value equal to the backend's reported session ID -
MetadataKeyBackendIDsbehavior is unchanged: still written as a comma-separated, sorted list of workload IDs - Unit tests added to
pkg/vmcp/session/verify thatmakeSessionwrites the correct per-backend session ID metadata entries (using a mock connector with a knownSessionID()) - Existing tests in
pkg/vmcp/session/continue to pass without modification - All tests pass (
go test ./pkg/vmcp/session/...) - Code reviewed and approved
Technical Approach
Recommended Implementation
The change is localized to pkg/vmcp/session/factory.go. Two additions are required:
-
Add the new constant in the
constblock alongsideMetadataKeyBackendIDs:// MetadataKeyBackendSessionPrefix is the key prefix for per-backend session IDs. // Full key: MetadataKeyBackendSessionPrefix + workloadID → backend_session_id. MetadataKeyBackendSessionPrefix = "vmcp.backend.session."
-
Extend
populateBackendMetadatato also write the per-backend session IDs. Theresultsslice is already sorted and each entry'sr.conn.SessionID()returns the opaque backend session ID (set bymcpSession.backendSessionID):func populateBackendMetadata(transportSess transportsession.Session, results []initResult) { if len(results) > 0 { ids := make([]string, len(results)) for i, r := range results { ids[i] = r.target.WorkloadID // Persist per-backend session ID so RestoreSession can reconnect with // the correct backend_session_id hint (RC-9). transportSess.SetMetadata( MetadataKeyBackendSessionPrefix+r.target.WorkloadID, r.conn.SessionID(), ) } transportSess.SetMetadata(MetadataKeyBackendIDs, strings.Join(ids, ",")) } }
No other changes to
makeSessionare needed —populateBackendMetadatais already called after theresultsslice is built and sorted, andtransportSessis already thetransportsession.Sessionthat gets embedded indefaultMultiSessionand persisted by the transport-layer storage.
The backendSessions map on defaultMultiSession (runtime use) is unaffected by this change and does not need to be modified.
Patterns & Frameworks
- Follow the existing pattern in
populateBackendMetadata: iterate overresults, useSetMetadataon the transport session; no new dependencies required - Keep metadata keys consistent with the
vmcp.*namespace already used byMetadataKeyBackendIDsandMetadataKeyIdentitySubject - Use
testify/assertandtestify/requirewitht.Parallel()consistent withpkg/vmcp/session/token_binding_test.go - Use the
nilBackendConnector/ mock connector pattern established intoken_binding_test.goanddefault_session_test.go— inject a mockbackendConnectorwith a knownSessionID()return value vianewSessionFactoryWithConnector
Code Pointers
pkg/vmcp/session/factory.go— primary file to modify; add constant and updatepopulateBackendMetadata;makeSessionat line 367;populateBackendMetadataat line 354; constants block at line 26pkg/vmcp/session/internal/backend/session.go—Sessioninterface:SessionID() stringis the method that returns the opaque backend session ID (line 54)pkg/vmcp/session/internal/backend/mcp_session.go—mcpSession.SessionID()returnsc.backendSessionID(line 92) — this is what gets storedpkg/vmcp/session/token_binding_test.go— existing test file in the same package; follow its structure (nilBackendConnector,newSessionFactoryWithConnector, table-driven sub-tests witht.Parallel()) for new unit testspkg/vmcp/session/default_session_test.go— additional test helpers (mockConnectedBackendwithsessIDfield) that can be reused to provide a mock backend connector with a knownSessionID()return valuepkg/transport/session/manager.go— shows howUpsertSession/AddSessioncallsstorage.Store, which will persist the metadata map to Redis once RC-7 wiring is in place; no changes needed here
Component Interfaces
// pkg/vmcp/session/factory.go
const (
// MetadataKeyBackendIDs — existing constant, unchanged
MetadataKeyBackendIDs = "vmcp.backend.ids"
// MetadataKeyBackendSessionPrefix is the key prefix for per-backend session IDs.
// Full key: MetadataKeyBackendSessionPrefix + workloadID → backend_session_id.
// Used by RestoreSession (RC-9) to reconnect backends with the correct session hint.
MetadataKeyBackendSessionPrefix = "vmcp.backend.session."
)
// populateBackendMetadata — updated signature is unchanged; implementation extended.
// Writes MetadataKeyBackendIDs and, for each result, MetadataKeyBackendSessionPrefix+workloadID.
func populateBackendMetadata(transportSess transportsession.Session, results []initResult)No interface changes are required for this task. MultiSessionFactory and MultiSession interfaces are untouched.
Testing Strategy
Add a new test file pkg/vmcp/session/factory_metadata_test.go (or add to an existing file in the package), following the token_binding_test.go pattern.
Unit Tests
-
makeSessionwith two successful backends: verifyGetMetadata()contains"vmcp.backend.session.{workloadID-1}"and"vmcp.backend.session.{workloadID-2}"with the expected session IDs (usemockConnectedBackend.sessID) -
makeSessionwith zero successful backends (all returnnil, nil, nil): verify no"vmcp.backend.session.*"keys are present in metadata -
makeSessionwith partial backend failure (one succeeds, one fails): verify only the successful backend's per-session-ID key is written; the failed backend's key is absent -
makeSessionstill writesMetadataKeyBackendIDscorrectly (sorted, comma-separated) alongside the new per-backend keys (regression check) -
MetadataKeyBackendSessionPrefixconstant has the expected value"vmcp.backend.session."(constant value guard)
Integration Tests
- None required at this layer; unit tests with mock connectors provide sufficient coverage
Edge Cases
- Backend whose
SessionID()returns an empty string: the key is still written with an empty value (no special casing; downstreamRestoreSessionhandles empty hints gracefully) - Two backends with the same workload ID: the second write overwrites the first (last writer wins, consistent with
SetMetadatabehavior); this is a degenerate case that should not occur in production but must not panic
Out of Scope
- Wiring
RedisStorageinto the vMCP server or callingNewManagerWithRedis(deferred) - Implementing
RestoreSession(TASK-005 / RC-9) - Updating Redis metadata when backend sessions expire (TASK-007 / RC-16)
- Re-applying
HijackPreventionDecoratorduringRestoreSession(TASK-004 / RC-15) - Changes to
pkg/transport/sessionorpkg/vmcp/server/sessionmanager - Aggregator state persistence
References
- RFC THV-0047: RFC: Horizontal Scaling for vMCP and Proxy Runner toolhive-rfcs#47
- Parent epic: stacklok/stacklok-epics#262
- Upstream RC-6 (Redis Storage Backend): stacklok/stacklok-epics#266
- Upstream RC-7 (Wire Redis Backend Selection): stacklok/stacklok-epics#270
makeSessionandpopulateBackendMetadata:pkg/vmcp/session/factory.go- Backend
SessionID()interface:pkg/vmcp/session/internal/backend/session.go - Test pattern reference:
pkg/vmcp/session/token_binding_test.go