Session-Affinity: Candidate Solutions for #4557ΒΆ
Issue #4557 reports a severe multi-worker session-affinity regression: 180 RPS β 9 RPS on the 3 Γ 24 reference stack, with tools/call p99 pinned at the 30 s forward timeout. The #4674 reproducer revealed ~24Γ amplification: one request per second drove the per-user rate-limit counter up at 24Γ, suggesting every request was being processed by all 24 workers.
This doc lays out the candidate solutions, walks through where each one shines and breaks down, and recommends a starting point with a path for further improvement.
The Core ProblemΒΆ
A stateful MCP request carrying Mcp-Session-Id can land on any of N workers, but only one worker holds the live upstream session. The UpstreamSessionRegistry entry contains a live ClientSession with an open connection, which is not serializable and not movable. When the request lands on the wrong worker, the architecture has exactly three structural choices:
- Route correctly upstream, so the request always lands on the right worker.
- Externalize session ownership, so any worker can serve any session.
- Forward across workers, accepting that the wrong worker may receive the request and pass it along.
The three approaches below walk through each option in turn.
What any candidate solution must preserveΒΆ
- The #4205 upstream-session isolation invariant: one upstream session per downstream session, no cross-session state leakage.
- Multi-worker / multi-container deployment shape (
SO_REUSEPORT, gunicorn--preload, nginx fronting multiple replicas). - All authentication shapes that
streamable_http_auth()validates: ContextForge JWT, virtual-server OAuth verifier (RFC 9728), andMCP_REQUIRE_AUTH=falsepublic-only mode. - Existing observability: structured logs, OTEL spans, the
mcpgw:*Redis state surface that operators read. - Graceful behaviour on worker failure (no cluster-wide outage when one worker dies).
Approach 1 β Sticky Load BalancingΒΆ
Status: validated in experimental conditions.
hash $http_authorizationconfig-only variant measured at 352 RPS, 0% failures, p99 530 ms on a 3 single-worker-pod prototype with per-user JWTs. ~21Γ per-worker efficiency vs the current affinity-layer baseline. See empirical summary and caveats below.
Stop forwarding at the application layer. Route correctly at the LB layer.
Client β nginx hashes on $http_authorization β pod β /rpc executes locally. No forwarding.
Original architecture diagram (Mcp-Session-Id variant)
Client Mcp-Session-Id: sess-abc
β
βΌ
nginx hash $http_mcp_session_id consistent; βββ Layer-1: pin to container
β
βΌ
Container N (deterministic from session id)
β
βΌ
Worker M βββ Layer-2: need intra-container stickiness too
β (disable SO_REUSEPORT, OR run 1 worker per container)
βΌ
/rpc executes β session is always here, no forward needed, no Redis pub/sub
Pros and Cons
**Pros** - No forward path. No pub/sub. No `WORKER_ID`. No `post_fork` hook. - Lower latency: no Redis on the hot path. - Simplest architecture once wired. - Easy to reason about: session X on worker Y, always. **Cons** - Two layers of stickiness required. nginx handles Layer-1; Layer-2 (intra-container) needs more work (see options below). - Worker failures reshuffle sessions; sticky LB has no per-session migration. Today's pub/sub model handles this transparently via heartbeat-based dead-worker reclaim. - SSE / GET streams don't benefit (#4334). - Capacity coupled to stickiness ratio: heavy users concentrate on one worker.Layer-2 stickiness options
- **Disable `SO_REUSEPORT`** and use one worker per container; scale by running more containers. Costs per-container parallelism. - **Port-per-worker:** each gunicorn worker binds a distinct port; nginx upstream lists `container:portN` for every worker. Operationally unusual (N entries per container, per-port health checks, separate gunicorn instances). Few teams run this. - **Intra-container router** (sidecar or coordinator). Adds complexity and a new failure mode.Bootstrap routing. The initialize request doesn't yet carry Mcp-Session-Id, so hashing on that header can't pin the bootstrap itself. Solved empirically by hashing on Authorization instead (see empirical summary).
Bootstrap design alternatives considered (before the auth-hash variant was tested)
Two known mitigations for the `Mcp-Session-Id` variant of the problem: - **(a) Server-side session-id minting that encodes routing.** The worker generating the session id constructs it so the LB's hash function returns this worker (e.g., the id contains a prefix or slot identifier the LB hash respects). Requires a tight contract between the LB hash and the gateway's session-id format; brittle to LB config changes. - **(b) Bootstrap-then-pin.** `initialize` lands on any worker via non-hashed routing (least-conn or round-robin), the response returns the session id, and from then on the LB hashes on the session id. Requires the LB hash to be deterministic across requests AND deterministic from the worker's point of view at session-creation time (typically achieved by hashing into a slot ring known to both sides). Failure mode: if a worker dies before the bootstrap completes, the session id may map elsewhere. Neither mitigation is structurally complex, but both require the gateway to know (and round-trip through) the LB's hash function. The MCP Python SDK doesn't expose the session-id generator as a hook (today it's a hardcoded `uuid4().hex` inside `StreamableHTTPSessionManager`), so mitigation (a) needs either a small SDK patch upstreamed or a startup-time monkey-patch in the gateway. Both were superseded by the simpler answer: hash on `Authorization`, which is present on every request from the start.Why the sid-hash variant fails for ContextForge specifically (and might work for other gateways)
The MCP spec doesn't say how the upstream server generates the session id. ContextForge inherits the Python SDK's `StreamableHTTPSessionManager`, which generates `uuid4().hex` β completely opaque, no routing information encoded. nginx hashing that sid has no way to reverse-engineer which pod minted it, so the bootstrap β follow-up mismatch is unavoidable. A gateway shipping the same sticky-on-sid config could still have it work end-to-end if any of these is true: - **Server generates routing-encoded sids** (e.g., `pod-3-abcβ¦` or `s7-abcβ¦`). Same idea as mitigation (a) above β the sid format itself tells nginx where to route. Some MCP gateway products do this; the Python SDK does not. - **Clients supply the sid on `initialize`.** nginx then hashes the same value on every request including the bind, so the same pod handles both. Some MCP client libraries do this; ContextForge's reference clients do not. - **LB owns the stickiness via cookie or stick-table.** AWS ALB, nginx Plus, HAProxy, or Envoy with a stateful filter can remember which pod handled the initialize and route follow-ups there regardless of the sid hash. Requires the client to honour LB-set cookies; not all MCP clients do. - **A session-routing coordinator sits in front of workers.** A dedicated pod maintains the sid β backend map and forwards. Essentially relocates the affinity problem into a proxy layer (the Approach 2 shape). - **The upstream is stateless.** No per-session memory means the routing question doesn't exist. ContextForge holds long-lived `ClientSession` objects, so this doesn't apply. The `Authorization`-hash variant we measured sidesteps all of this by routing on a header the client already holds, at the cost of the public-only / token-rotation caveats listed below.| Variant | Status | Notes |
|---|---|---|
hash $http_mcp_session_id (naive) | failed | Bootstrap mismatch without gateway-side sid encoding or client-supplied sids. Single-worker experiment README. |
hash $http_authorization (variant that works) | passed | 352 RPS / 0% fail / p99 530 ms on 3 single-worker pods. 5/5 correctness tests pass. ~21Γ per-worker efficiency vs Approach 3. Scope: authenticated clients with stable Authorization, one worker per pod, 60 s benchmark, per-user JWTs. Auth-hash experiment README. |
Net: for deployments that meet the scope above, Approach 1 ships as a config-only change when the LB hash key is Authorization rather than Mcp-Session-Id.
Caveats (apply to the auth-hash variant):
- Public-only mode (
MCP_REQUIRE_AUTH=false) is not covered. Unauthenticated requests have noAuthorizationheader, so all anonymous traffic hashes to the empty string and concentrates on a single backend. Public-only deployments need a separate stickiness key (a deliberate cookie, a request id, or another stable header). The auth-hash variant on its own is not sufficient for them. - Token rotation strands sessions. A refreshed JWT is a different string and may hash to a different pod. This happens during normal token refresh, not just on failover, so clients must be able to detect a session error and re-initialize routinely.
- One worker per pod is structural, not optional. nginx can only pin to the pod; if the pod has multiple gunicorn workers sharing a socket via
SO_REUSEPORT, the request still scatters inside the pod and the conclusion above doesn't hold.
When to pick: moving to one-worker-per-pod (or already there). Auth-hash variant is the recommended config.
Approach 2 β Coordinator-Worker ModelΒΆ
Status: paper design only. Significant architectural change (new process type, IPC layer, ~22h prototype estimated). Not implemented. See paper design below.
Move session ownership out of workers. A single coordinator process per replica owns all upstream MCP sessions; workers become stateless and proxy through the coordinator via cheap local IPC.
nginx β any worker β coordinator (per replica, owns sessions) β upstream MCP server.
Architecture diagram
βββββββββββββββββββββββββββββββββββ
β Coordinator (1 per replica) β
β owns UpstreamSessionRegistry β
ββββ¬βββββββββββ¬βββββββββββ¬βββββββββ
β² β² β²
UDS / shared-mem / localhost gRPC
β β β
ββββ΄βββ ββββ΄βββ ββββ΄βββ
β W1 β β W2 β β W24 β βββ workers stateless
βββββββ βββββββ βββββββ
β²
nginx βββΊ gunicorn socket (any worker takes the request)
Pros and Cons
**Pros** - Removes the affinity problem at the source. Workers don't own sessions. - No `WORKER_ID`, no pub/sub, no Redis ownership keys, no `post_fork` hook. - UDS IPC (~10 ΞΌs) is much faster than Redis pub/sub (~1β2 ms). - Clean separation: stateful and stateless sides are explicit. - Opens the door to a Rust/PyO3 coordinator later. **Cons** - New process type to deploy, monitor, version-skew-test. - Single point of failure per replica: coordinator crash = 100% of in-replica sessions lost (vs ~4% on a worker crash today). - Throughput ceiling: one GIL per replica; every request crosses the IPC boundary. - Significant refactor: `UpstreamSessionRegistry`, RPC dispatch, transport, lifecycle. - No cluster-wide session migration; coordinator-per-replica is local only.Paper design. Full design (IPC framing, per-session locking, request-flow walk-through, failure-mode comparison, SSE / ADR-052 open question, env-gated coexistence, ~22h prototype estimate) in the coordinator-worker design doc.
When to pick: when cluster-wide session migration becomes a hard requirement (blue/green deploys, auto-scale without session loss, multi-region failover). Not justified today.
Approach 3 β Redis-Based Cross-Worker ForwardingΒΆ
Status: in-flight hardening. Three PRs (#4981, #4987, #4997) implement the four invariants below. Validated end-to-end on integration branch
fix/session-affinity-multiworker-forwarding(~390 RPS, 0% failures on the 3 Γ 24 reference stack). Production-ready when the PRs land.
Redis stores sid β owner_worker_id; the receiving worker forwards the payload to the owner over an IPC transport, and the response comes back the same way. The architecture has no delta from the gateway's current design: the Redis directory, per-worker channels, and dead-worker reclaim are all already in place. The #4557 regression came from invariants not being honoured, not from the architecture being wrong. The four invariants below are what the in-flight PRs are fixing.
Invariants any Approach-3 implementation must satisfyΒΆ
These are non-negotiable properties of the design. The existing code violated several of them, which is what produced the regression in #4557.
- Unique per-worker
WORKER_IDafter fork.--preloadcaptures the master's id at import; workers must recompute inpost_fork. A sharedWORKER_IDcollapses every worker onto one Redis channel, the source of the 24Γ amplification in #4557. - Exactly one subscriber per per-worker channel. Follows from invariant 1, but worth stating independently because operators can verify it directly:
PUBSUB NUMSUB mcpgw:pool_http:{worker_id}andPUBSUB NUMSUB mcpgw:pool_rpc:{worker_id}must each return 1, not N. Anything > 1 means aWORKER_IDcollision is amplifying forwards on that transport. - Forwarded requests execute in the owner process. Network loopback to
127.0.0.1hits the shared gunicorn socket, whereSO_REUSEPORTscatters the call to a random worker that doesn't hold the bound upstream session. In-process dispatch (httpx.ASGITransport(app=app)) keeps execution on the correct worker. - Forwarded requests preserve the original
streamable_http_auth()context AND the internal endpoint accepts the trusted-internal forwarding contract. The originating worker already validated the inbound credentials (ContextForge JWT, virtual-server OAuth, or public-only mode). The owner must trust that decision rather than re-authenticate: OAuth bearers fail internal JWT verification; public-only requests have no token to verify at all. For public-only mode specifically, the inner endpoint must also accept the trusted-internal HMAC marker without depending on the bearer short-circuit for CSRF; otherwise public-only forwards fail 403 on CSRF rather than 401 on auth. Either failure manifests on the inner dispatch even though the edge auth was correct.
Transport choice is independent of these invariants; none of the sub-options below compensate for an invariant being violated.
Worker X reads mcpgw:pool_owner:{sid} from Redis to find the owner, then forwards the payload to that worker over the configured transport.
Forwarding flow diagram
The transport options below all share this ownership lookup. They differ only in how the request/response payload travels between workers.
Sub-option 3a β Redis pub/sub over TCP (the baseline)ΒΆ
Baseline transport. Operationally simplest; ~1β2 ms per round-trip. Being hardened by #4981 / #4987 / #4997.
Diagram, pros, cons
**Pros** - Operationally simplest: Redis already in the stack. - Smallest mental model: everything goes through one substrate (also the observability and rate-limit layer). - Point-to-point constrained from broadcast by per-worker channels (invariant 2). **Cons** - Latency ~1β2 ms per round-trip (transport + Redis fanout + ASGI dispatch). - Fire-and-forget: no persistence; message lost if the owner is restarting at publish time. The other sub-options swap the transport without changing the surrounding architecture (Redis directory, worker subscriptions, dead-worker reclaim).Sub-option 3d β Direct TCP per workerΒΆ
Each worker binds an internal TCP port; forward directly to it. Works cross-container. 2β10Γ faster than 3a.
Diagram, pros, cons
Each worker binds an additional internal TCP port (e.g., `5000 + worker_idx`). Forwarding is a direct HTTP POST to that port. **Pros** - Works across containers (UDS doesn't). - Still 2β10Γ faster than Redis pub/sub for the common case (~50 ΞΌs intra-host, ~500 ΞΌs cross-container same-node, ~1β5 ms cross-node). **Cons** - More attack surface: every worker exposes an internal port. Needs network policy + per-port auth (mTLS or HMAC, like the trusted-internal endpoint). - Port allocation contract: 24 workers per container = 24 ports per container. - More complex than UDS for the intra-container case (which is most traffic). Pays for cross-container support that may not be used. **When to pick:** when cross-container forwarding is a significant fraction of total forwards (low workers-per-container, many containers, no sticky LB).Sub-option 3e β ZeroMQ point-to-point messagingΒΆ
ZMQ REQ/REP over ipc:// (UDS) or tcp://. Purpose-built point-to-point messaging. New dependency; bypasses ASGI.
Diagram, pros, cons
Use ZMQ's `REQ/REP` pattern over `ipc://` (UDS) or `tcp://`. Discovery still in Redis. Worker X Worker 7
ββββββββββββ ββββββββββββ
β ZMQ REQ β ββββ tcp://10.0.0.5:5007 βββΊ REP β
ββββββββββββ ββββββββββββ
βββ reply ββββββββββββββββββββββββ
Approach 4 β Redis-Resident SessionsΒΆ
Status: ruled out for ContextForge. Requires every upstream MCP server to support cross-connection session resumption, which most don't (rmcp / Python SDK tie state to the TCP connection). Not viable for a federating gateway over arbitrary third-party upstreams.
Externalise session state to Redis so any worker can serve any session. Workers re-open upstream connections on each request and resume via the stored session id.
Diagram, pros, cons
βββββββββββββββββββββββββββββββββββββββββ
β Redis (data path, not just directory)β
β β
β mcpgw:session:{sid} β β
β { upstream_url, upstream_sid, β
β last_seq, capabilities, ... } β
βββββββββββββββββββββββββββββββββββββββββ
β² β² β²
β β β
(read/write per request)
β β β
ββββββββββ΄ββββ ββββββ΄ββββ ββββββ΄ββββ
β Worker 1 β β W 2 β β W N β
β stateless β β β β β
βββββββ¬βββββββ ββββββ¬ββββ ββββββ¬ββββ
β β β
(each opens its own upstream connection
on each request and resumes via upstream_sid)
β β β
βΌ βΌ βΌ
βββββββββββββββββββββββββββββββββββββββ
β Upstream MCP server (rmcp etc.) β
β must support: resume by sid β
βββββββββββββββββββββββββββββββββββββββ
Comparison MatrixΒΆ
Side-by-side comparison of all 6 variants: latency, cross-container support, operational delta, code-change size, and pub/sub dependency.
Comparison table
> **Latency figures are order-of-magnitude estimates** drawn from typical commodity hardware, included to support relative comparison between the approaches. They are sensitive to deployment specifics (kernel, container runtime, Redis version, network path, payload size) and must be measured against the gateway benchmark stack before being used for capacity planning or SLA commitments. | Approach | Latency / forward (est.) | Cross-container | Operational delta | Code change | Pub/sub still needed | |---|---|---|---|---|---| | **1. Sticky LB** | 0 (no forward); 352 RPS / p99 530 ms measured on a 3-pod sticky-on-`Authorization` prototype | n/a | nginx config + 1-worker-per-container | small | no | | **2. Coordinator-worker** | ~10 ΞΌs UDS to coordinator | yes | new process type, lifecycle, monitoring | very large | no | | **3a. Redis pub/sub TCP** | ~1β2 ms | yes | none | bounded (honour the Approach-3 invariants) | yes | | **3d. Direct TCP per worker** | ~50 ΞΌsβ5 ms | yes | per-worker port allocation, auth | medium | no | | **3e. ZeroMQ** | ~20β50 ΞΌs over ipc | yes | new dependency, custom observability | medium-large | no | | **4. Redis-resident sessions** | n/a (no forward; +50β500 ms per call to re-establish upstream) | yes | Redis becomes data path | very large | no |RecommendationΒΆ
Priority order (try in this sequence; fall through if the constraints don't fit):
-
Approach 3: Redis pub/sub forwarding (current architecture). Smallest architectural delta. In-flight PRs (#4981, #4987, #4997) already address the four invariants. The Redis directory, per-worker channels, and dead-worker reclaim are all in place. Try this first because no deployment-shape change is required and the work is already underway.
-
Approach 1: Sticky LB on
Authorization. Empirically validated at 117 RPS/worker (~21Γ the current per-worker efficiency). Try this if you can move to one-worker-per-pod and accept the user-pinning trade-offs (heavy-user concentration, session loss on token refresh). Smallest code change of any non-trivial option. -
Approach 2: Coordinator-Worker model. Paper design ready (~22h prototype estimated). Try this only if Approaches 1 and 3 both prove unworkable: for instance if cluster-wide session migration becomes a hard requirement, or if you're willing to invest in a separate Rust/PyO3 coordinator process.
-
Approach 4: Redis-resident sessions. Last resort. Only viable if every upstream MCP server supports cross-connection session resumption AND the per-call reconnect cost (50β500 ms) is acceptable. Most rmcp / Python SDK upstreams don't support this today.
Within Approach 3, the transport sub-options (3d / 3e) can be adopted incrementally as performance demands grow. See Approach 3 above. They don't change the priority order.
Action PlanΒΆ
Based on the recommendation above, the execution plan is:
ImmediateΒΆ
Take Approach 3 β Redis-based cross-worker forwarding forward now. This means landing the hardening work for the current architecture and treating the four Approach 3 invariants as the implementation contract: unique per-worker WORKER_ID, exactly one subscriber per worker channel, in-process owner execution, and preserved streamable_http_auth() context through trusted-internal forwarding.
Try NextΒΆ
Try Approach 1 β sticky load balancing on Authorization after Approach 3, if Redis forwarding still leaves a performance or operational gap. Before recommending it broadly, validate the public-only traffic case, token-rotation behaviour, and the one-worker-per-pod deployment trade-off.
Hold Off For NowΒΆ
Hold off Approach 2 β coordinator-worker model unless cluster-wide session migration, blue/green deploys without session loss, or multi-region failover becomes a hard requirement.
Hold off Approach 4 β Redis-resident sessions because it depends on cross-connection session resumption support that arbitrary upstream MCP servers generally do not provide.