Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces WebSocket connection pooling/multiplexing for graphql-transport-ws subgraph operations, allowing multiple GraphQL operation streams to share a single physical WebSocket per subgraph and inbound connection identity. It also adds traffic-shaping configuration that controls whether WebSocket connections are reused and whether queries/mutations can execute over WebSocket.
Changes:
- Add
traffic_shaping.*.websocketconfiguration (reuse_connections,execute_mode) plus helpers to resolve effective per-subgraph values. - Implement a shared WebSocket pool/executor to single-flight initialization and multiplex logical operations over one connection.
- Add WebSocket pool telemetry (metrics + logging target) and extensive e2e coverage for pooling, reuse behavior, and idle timeout behavior.
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/router-config/src/traffic_shaping.rs | Adds WebSocket traffic-shaping config types and helper accessors (reuse, execute mode, idle timeout). |
| lib/internal/src/telemetry/metrics/websocket_pool_metrics.rs | New metrics wrapper for pool connection/operation lifecycle and lookup/initialization events. |
| lib/internal/src/telemetry/metrics/mod.rs | Wires WebSocketPoolMetrics into the shared Metrics struct. |
| lib/internal/src/telemetry/metrics/catalog.rs | Registers new metric names/labels and their allowed attribute sets. |
| lib/internal/src/telemetry/logging/targets.rs | Adds router::websocket_pool logging target. |
| lib/executor/src/executors/websocket.rs | Routes execution/subscribe through the pool when reuse is enabled; updates WsClient API usage and timeout handling. |
| lib/executor/src/executors/websocket_pool.rs | New pooled WebSocket implementation: single-flight init, multiplexing, idle expiry, and operation submission/dispatch. |
| lib/executor/src/executors/websocket_client.rs | Refactors WsClient into connected/initialized states; improves error propagation and dispatcher completion signaling. |
| lib/executor/src/executors/subscription_buffer.rs | Ensures upstream drains cancel promptly when downstream receiver closes. |
| lib/executor/src/executors/mod.rs | Exposes the new websocket_pool module. |
| lib/executor/src/executors/map.rs | Adds query/mutation routing based on WebSocketExecuteMode and integrates pool lookups. |
| lib/executor/src/executors/http.rs | Updates tests for new connection_fingerprint field on SubgraphExecutionRequest. |
| lib/executor/src/executors/graphql_transport_ws.rs | Replaces payload builder with From<SubgraphExecutionRequest> for subscribe payload creation. |
| lib/executor/src/executors/error.rs | Adds typed WebSocket client operation error variant. |
| lib/executor/src/executors/common.rs | Introduces ConnectionFingerprint and InboundRequestFingerprint; threads fingerprint through execution requests. |
| lib/executor/src/execution/plan.rs | Plumbs connection_fingerprint through plan execution options and subgraph execution requests. |
| e2e/src/websocket.rs | Updates e2e tests to new WsClient API and stream item error type. |
| e2e/src/websocket_pool.rs | New end-to-end test suite validating pooling, reuse modes, and idle-timeout behavior. |
| e2e/src/testkit/mod.rs | Adds async per-path delay support for deterministic pool initialization timing tests. |
| e2e/src/telemetry/subscription_metrics.rs | Updates tests to new WsClient subscribe API and item error handling. |
| e2e/src/subscriptions.rs | Updates tests to new WsClient subscribe API and item error handling. |
| e2e/src/lib.rs | Registers the new websocket pool e2e module. |
| e2e/src/demand_control/enforcement.rs | Updates demand-control tests to new WsClient subscribe API and item error handling. |
| e2e/configs/websocket_pool.yaml | New config fixture for websocket pool e2e tests. |
| bin/router/src/shared_state.rs | Switches in-flight request map keying to InboundRequestFingerprint. |
| bin/router/src/pipeline/websocket_server.rs | Computes/threads connection fingerprint into WS request handling and dedupe fingerprinting. |
| bin/router/src/pipeline/mod.rs | Splits connection vs request fingerprinting; threads connection_fingerprint through pipeline execution. |
| bin/router/src/pipeline/execution.rs | Plumbs connection_fingerprint into planned execution. |
| bench/subgraphs/lib.rs | Adds /ws subscription routes for additional subgraphs in bench server. |
| .changeset/multiplex_and_reuse_websocket_subgraph_connections.md | Release notes for multiplexing/reuse behavior and telemetry. |
| .changeset/add_websocket_connection_reuse_and_execution_mode_configuration.md | Release notes for new traffic shaping WebSocket configuration fields. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (3)
lib/executor/src/executors/map.rs:419
endpoint_stris already resolved once earlier inexecutespecifically to avoid evaluating endpoint expressions multiple times. Re-resolving it here can evaluate expressions twice and cause HTTP/WebSocket transport selection to use different destinations than the initial resolution.
let endpoint_str = self.resolve_endpoint(subgraph_name, client_request)?;
executor =
self.get_or_create_subscription_executor(subgraph_name, &endpoint_str)?;
bin/router/src/pipeline/mod.rs:415
any_websocket_connection_reuse_enabled()defaults totrue, so this will compute aconnection_fingerprinton every HTTP request even when no subgraph WebSocket endpoints are configured (i.e.,subscriptions.websocketis unset) and request dedupe is disabled. That adds avoidable hashing/allocation overhead to the hot path. Consider gating WebSocket reuse fingerprinting on having anysubscriptions.websocketconfiguration present.
let websocket_reuse_enabled = shared_state
.router_config
.traffic_shaping
.any_websocket_connection_reuse_enabled();
bin/router/src/pipeline/websocket_server.rs:508
any_websocket_connection_reuse_enabled()defaults totrue, so this will compute aconnection_fingerprintfor inbound WebSocket operations even when no subgraph WebSocket endpoints are configured (nosubscriptions.websocket) and request dedupe is disabled. Gate fingerprinting for multiplexing onsubscriptions.websocket.is_some()to avoid unnecessary work.
let websocket_reuse_enabled = shared_state
.router_config
.traffic_shaping
.any_websocket_connection_reuse_enabled();
|
🐋 This PR was built and pushed to the following Docker images: Image Names: Platforms: Image Tags: |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (3)
bin/router/src/pipeline/mod.rs:425
any_websocket_connection_reuse_enabled()defaults totrue(viaunwrap_or(true)), sowebsocket_reuse_enabledbecomestrueeven when there are no WebSocket-enabled subgraphs configured. That makes the router computeconnection_fingerprintfor every request (even with request dedupe disabled), which looks like an avoidable per-request hashing/sorting cost.
Consider additionally gating WebSocket reuse fingerprinting on whether subscriptions.websocket is configured for any subgraph (i.e. websocket.all.is_some() || !websocket.subgraphs.is_empty()), so the default reuse_connections: true doesn’t impose overhead when WebSocket transport isn’t in use at all.
let websocket_reuse_enabled = shared_state
.router_config
.traffic_shaping
.any_websocket_connection_reuse_enabled();
// establish a connection fingerprint only if dedupe or multiplexing is enabled
let connection_fingerprint = (request_dedupe_enabled || websocket_reuse_enabled)
.then(|| connection_fingerprint(
req.method(),
req.path(),
&request_headers,
&shared_state.in_flight_requests_header_policy,
supergraph.snapshot.cache_id,
));
bin/router/src/pipeline/websocket_server.rs:517
- Same as the HTTP pipeline:
websocket_reuse_enabledis derived only from traffic shaping, and becausereuse_connectionsdefaults totrue, this path computes aconnection_fingerprintfor every inbound client WebSocket operation even when no subgraph has WebSocket transport configured.
Consider gating this on whether any WebSocket subgraph config exists under subscriptions.websocket (all set or subgraphs non-empty), so WebSocket clients don’t pay fingerprint hashing cost unless subgraph WebSocket reuse can actually happen.
let request_dedupe_enabled =
shared_state.router_config.traffic_shaping.router.dedupe.enabled;
let websocket_reuse_enabled = shared_state
.router_config
.traffic_shaping
.any_websocket_connection_reuse_enabled();
let connection_fingerprint = (request_dedupe_enabled || websocket_reuse_enabled)
.then(|| connection_fingerprint(
&Method::POST,
ws_uri.path(),
headers.as_ref(),
&shared_state.in_flight_requests_header_policy,
supergraph.snapshot.cache_id,
));
lib/executor/src/executors/websocket.rs:176
- The per-operation
timeoutis applied only while awaiting the oneshot receiver (tokio::time::timeout(timeout, response)), but the actual WebSocket task spawned on the ntex runtime keeps running if the timeout fires (it continues connecting/handshaking/subscribing and will only fail totx.sendbecause the receiver was dropped). This can leave a live WebSocket connection/task doing work after the request has already timed out.
Consider enforcing the timeout inside the spawned task (wrapping the whole connect/init/subscribe flow) or adding an explicit cancellation signal so the spawned task drops the WsClient promptly when the caller times out.
let response = async {
rx.await
.map_err(|_| SubgraphExecutorError::WebSocketArbiterChannelClosed)?
};
match timeout {
Some(timeout) => tokio::time::timeout(timeout, response).await?,
None => response.await,
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
lib/executor/src/executors/websocket_pool.rs:510
- In
execute, the WebSocket pool metrics guard is created beforesubmit()succeeds, sooperations.started_total(incremented insideactive_operation) will also be incremented for operations that fail to start (e.g., command channel closed / subscribe send failed). Also,responses.recv()yieldsSubscriptionItem = Result<SubgraphResponse, SubgraphExecutorError>, but the code returns that innerResultwithout unwrapping it, which should fail to type-check (or would otherwise return a nestedResult). Create the guard only aftersubmit()succeeds and flatten the received item with an extra?.
let _operation_guard = self
.telemetry_context
.metrics
.websocket_pool
.active_operation(&self.id.subgraph_name, WebSocketPoolOperationType::Execute);
bin/router/src/pipeline/mod.rs:415
any_websocket_connection_reuse_enabled()currently evaluates totrueby default (reuse_connections.unwrap_or(true)), which makeswebsocket_reuse_enabledtrue even when no subgraph WebSocket endpoints are configured. That causesconnection_fingerprintto be computed for every request (even when request dedupe is off and WebSocket routing is impossible), adding avoidable per-request hashing overhead. Gatewebsocket_reuse_enabledon whether anysubscriptions.websocketsubgraph configuration exists.
let websocket_reuse_enabled = shared_state
.router_config
.traffic_shaping
.any_websocket_connection_reuse_enabled();
bin/router/src/pipeline/websocket_server.rs:508
- Same issue as the HTTP pipeline:
websocket_reuse_enabledis derived only from traffic-shaping defaults and istrueby default, which makesconnection_fingerprintcomputation happen even when nosubscriptions.websocketsubgraphs are configured (so the fingerprint cannot be used for pooling). Gate this on whether any WebSocket subgraph subscription config exists to avoid unnecessary hashing on every inbound WS operation.
let websocket_reuse_enabled = shared_state
.router_config
.traffic_shaping
.any_websocket_connection_reuse_enabled();
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
bin/router/src/pipeline/websocket_server.rs:508
- As written,
websocket_reuse_enabledbecomes true by default (becausereuse_connectionsdefaults to true), which causesconnection_fingerprintcomputation for inbound router WebSocket operations even when there are no WebSocket-enabled subgraphs configured undersubscriptions.websocket. This is unnecessary work and can be avoided by gating reuse on whether any WebSocket subgraph config exists.
let websocket_reuse_enabled = shared_state
.router_config
.traffic_shaping
.any_websocket_connection_reuse_enabled();
bin/router/src/pipeline/mod.rs:415
any_websocket_connection_reuse_enabled()defaults totrue(whenreuse_connectionsis omitted), sowebsocket_reuse_enabledbecomes true even when no subgraph has WebSocket subscriptions configured. That makes the router compute aconnection_fingerprintfor every HTTP request even when it can never be used (no WebSocket pool lookups/initializations possible), adding avoidable hashing overhead.
let websocket_reuse_enabled = shared_state
.router_config
.traffic_shaping
.any_websocket_connection_reuse_enabled();
Closes #1236
Subgraph
graphql-transport-wsoperations can share an initialized connection when they have the same subgraph, resolved WebSocket endpoint, and inbound connection identity. This replaces one physical WebSocket per operation with one multiplexed connection while preserving independent GraphQL operation streams.The implementation also adds explicit traffic-shaping controls for whether connections are reused and how queries and mutations are transported.
Configuration
WebSocket behavior is configured under
traffic_shaping.all.websocketor overridden per subgraph:reuse_connectionsdefaults totrueand controls whether WebSocket operations multiplex over matching pooled connections. Setting it tofalsepreserves one connection per WebSocket operation.execute_modecontrols queries and mutations:httpis the default and always uses HTTPreuse_existingprefers a matching initialized WebSocket and immediately uses HTTP when none exists or initialization is still in progresswebsocketexecutes over WebSocket, initializing or joining a pooled connection when reuse is enabled, or opening a dedicated connection when reuse is disabledPer-subgraph WebSocket fields inherit omitted values from
traffic_shaping.all.websocket.Pooled WebSockets use the existing effective
pool_idle_timeout. A per-subgraph timeout overridestraffic_shaping.all.pool_idle_timeoutfor both HTTP and WebSocket pools. Active WebSocket operations prevent idle expiry.Configuration examples and routing behavior
Preserve HTTP execution and pool subscriptions
What happens:
reviewsuse WebSocketreuse_connectionsdefaults totrueandexecute_modedefaults tohttpOpportunistically reuse a subscription connection
What happens:
connection_ackSend every operation over WebSocket
What happens:
Force a dedicated WebSocket per operation
What happens:
Override one subgraph
What happens:
paymentssends queries, mutations, and subscriptions over dedicated WebSocketspaymentsinherits nothing for the two explicitly overridden WebSocket fieldspool_idle_timeoutalso remains the effective timeout for its HTTP connection poolConnection identity
Request identity is now split into two typed fingerprints:
ConnectionFingerprintcovers the inbound method, path, selected headers, and supergraph identityInboundRequestFingerprintextends it with the normalized operation, variables, and extensions for request deduplicationThis keeps request deduplication separate from connection reuse. Different operations are not treated as the same request, but they can still multiplex over the same connection.
Header selection follows
traffic_shaping.router.dedupe.headers, including when request deduplication is disabled. Operators using a custom header policy must include every inbound header that can affect connection-scoped authentication, authorization, cookies, or tenant identity.The router avoids computing a connection fingerprint when neither request deduplication nor WebSocket reuse needs one. Inbound client WebSockets compute it once and reuse it across operations.
WebSocket pool
The shared pool coordinates connections per subgraph, resolved WebSocket endpoint, and connection fingerprint.
Concurrent operations that are allowed to initialize a matching connection join one initialization attempt. The executor becomes available only after
connection_ack. Failed or canceled attempts wake their waiters and remove only their own pool generation.The underlying ntex WebSocket client remains on its local runtime because it is not
Send. Shared executors communicate with that owner through a bounded command channel. Operations are registered serially and their response streams drain concurrently through the existing subscription buffer.Pool removal checks exact command-channel ownership, so an expired or failed connection cannot remove a newer replacement.
Query and mutation routing
Plugin executor decisions continue to win. Pool routing happens only when hooks did not return a response or replace the original HTTP executor.
For
execute_mode: reuse_existing, the router resolves the endpoint once and uses the corresponding cached subscription executor's parsed WebSocket endpoint to look up an initialized matching connection. This keeps endpoint expressions and overrides isolated without parsing the endpoint again. A missing subscription executor or connecting pool entry is an immediate miss, and execution stays on HTTP.For
execute_mode: websocket, the configured WebSocket executor handles queries and mutations. With reuse enabled it can initialize or join the pool. With reuse disabled it opens a dedicated connection for the operation.After WebSocket execution is selected, command, timeout, transport, dispatcher, and empty-stream failures are returned to the client. The router does not retry the operation over HTTP, avoiding mutation replay after an uncertain WebSocket send.
WebSocket client lifecycle
WsClientnow has explicit connected and initialized states. Only a connected client can run protocol initialization, and only an initialized client can start operations.The client now:
errormessages as GraphQL responsesExecute timeout now covers WebSocket execution, including connection initialization where applicable, command submission, and the first response.
Buffering and cancellation
Pooled operations preserve the existing bounded buffering and drop-on-backpressure behavior.
The drainer now also waits for the downstream channel to close. This immediately drops a silent upstream stream when its consumer disappears.
For pooled execute, dropping the receiver after the first response sends protocol
complete, ends only that logical operation, and allows the physical connection to become idle without waiting for another subgraph message.Telemetry
The pool exposes the following low-cardinality metrics:
hive.router.websocket_pool.connections.activesubgraph.namehive.router.websocket_pool.connections.initializations_totalsubgraph.name,result=success|errorhive.router.websocket_pool.connections.initialization_waiters_totalsubgraph.namehive.router.websocket_pool.connections.lookups_totalsubgraph.name,result=hit|missreuse_existingattempts and whether an initialized connection was foundhive.router.websocket_pool.connections.closed_totalsubgraph.name,websocket_pool.connection.close_reason=idle|dispatcher|pool_droppedhive.router.websocket_pool.operations.activesubgraph.name,websocket_pool.operation.type=execute|subscribehive.router.websocket_pool.operations.started_totalsubgraph.name,websocket_pool.operation.type=execute|subscribeThese metrics distinguish physical connection lifetime from logical operation lifetime. That makes it possible to derive and monitor:
hit lookups / all lookups. A low ratio inreuse_existingmode means most queries and mutations still use HTTP. This can indicate that subscriptions rarely overlap with those requests, connection fingerprints are too fragmented, or the idle timeout is too short.error initializations / all initializations. This surfaces DNS, TCP, TLS, WebSocket upgrade,connection_init, and acknowledgement problems before they are hidden among ordinary operation failures.idleclosures suggests increasingpool_idle_timeoutmay improve reuse. Highdispatcherclosure rates point to subgraph or network instability instead.operations.activeandoperations.started_total. This shows whether the pool is serving only its original subscription workload or is also carrying queries and mutations.connections.activegrouped bysubgraph.name. This identifies subgraphs or identity fragmentation that create unexpectedly many physical connections.Metrics are labeled by subgraph and bounded status values only. Fingerprints, headers, and endpoints are intentionally excluded to avoid leaking identity data and creating unbounded cardinality.
Pool initialization, reuse, lookup, eviction, idle closure, dispatcher failure, and stale initialization events also use the dedicated
router::websocket_poollogging target. Logs provide the endpoint and event context needed to investigate a metric anomaly without turning endpoint or connection identity into metric dimensions.Should we move the
websocketsubgraph config?WebSocket endpoint paths still live at
subscriptions.websocket.subgraphs.<name>.path. WebSocket is now also a transport for queries and mutations, so that hierarchy may no longer describe the full feature accurately.Consider moving the path to a transport-level location such as
websocket.subgraphs.<name>.path.TODO