Subscription deduplication, active subscriptions registry and limits#895
Conversation
fast
There was a problem hiding this comment.
Code Review
This pull request introduces a robust mechanism for subscription deduplication and concurrent connection limiting. It implements an ActiveSubscriptions registry that allows multiple clients with identical fingerprints to share a single upstream subscription via broadcast channels, supporting cross-transport deduplication between WebSockets and HTTP streams. A new LongLivedClientLimitService middleware is added to enforce a maximum number of concurrent streaming connections. Additionally, the PR refactors the execution pipeline to unify response handling and ensures active subscriptions are gracefully terminated during schema reloads. Feedback highlights a critical bug in the connection limit middleware where the client counter is decremented prematurely for streaming responses; the guard must be attached to the response extensions to persist for the duration of the connection.
|
🐋 This PR was built and pushed to the following Docker images: Image Names: Platforms: Image Tags: Docker metadata{
"buildx.build.provenance/linux/amd64": {
"builder": {
"id": "https://github.com/graphql-hive/router/actions/runs/23987464339/attempts/1"
},
"buildType": "https://mobyproject.org/buildkit@v1",
"materials": [
{
"uri": "pkg:docker/docker/dockerfile@1.22",
"digest": {
"sha256": "4a43a54dd1fedceb30ba47e76cfcf2b47304f4161c0caeac2db1c61804ea3c91"
}
},
{
"uri": "pkg:docker/gcr.io/distroless/cc-debian12@latest?platform=linux%2Famd64",
"digest": {
"sha256": "329e54034ce498f9c6b345044e8f530c6691f99e94a92446f68c0adf9baa8464"
}
}
],
"invocation": {
"configSource": {
"entryPoint": "router.Dockerfile"
},
"parameters": {
"frontend": "gateway.v0",
"args": {
"cmdline": "docker/dockerfile:1.22",
"label:org.opencontainers.image.created": "2026-04-04T21:09:14.533Z",
"label:org.opencontainers.image.description": "Open-source (MIT) GraphQL Federation Router. Built with Rust for maximum performance and robustness.",
"label:org.opencontainers.image.licenses": "MIT",
"label:org.opencontainers.image.revision": "250e6da9cda80b809f12c473f838b657d6e817dd",
"label:org.opencontainers.image.source": "https://github.com/graphql-hive/router",
"label:org.opencontainers.image.title": "router",
"label:org.opencontainers.image.url": "https://github.com/graphql-hive/router",
"label:org.opencontainers.image.vendor": "theguild",
"label:org.opencontainers.image.version": "pr-895",
"source": "docker/dockerfile:1.22"
},
"locals": [
{
"name": "context"
},
{
"name": "dockerfile"
}
]
},
"environment": {
"github_actor": "enisdenjo",
"github_actor_id": "11807600",
"github_event_name": "pull_request",
"github_event_payload": {
"action": "synchronize",
"after": "8314410f8a17429289e0146ce4277dadba14507f",
"before": "7e8c6aea41043d5fcde73ba46b8115e5e2cdd39e",
"enterprise": {
"avatar_url": "https://avatars.githubusercontent.com/b/187753?v=4",
"created_at": "2024-07-02T08:52:28Z",
"description": "",
"html_url": "https://github.com/enterprises/the-guild",
"id": 187753,
"name": "The Guild",
"node_id": "E_kgDOAALdaQ",
"slug": "the-guild",
"updated_at": "2026-03-11T16:47:15Z",
"website_url": "https://the-guild.dev/"
},
"number": 895,
"organization": {
"avatar_url": "https://avatars.githubusercontent.com/u/182742256?v=4",
"description": "Schema registry, analytics and gateway for GraphQL federation and other GraphQL APIs.",
"events_url": "https://api.github.com/orgs/graphql-hive/events",
"hooks_url": "https://api.github.com/orgs/graphql-hive/hooks",
"id": 182742256,
"issues_url": "https://api.github.com/orgs/graphql-hive/issues",
"login": "graphql-hive",
"members_url": "https://api.github.com/orgs/graphql-hive/members{/member}",
"node_id": "O_kgDOCuRs8A",
"public_members_url": "https://api.github.com/orgs/graphql-hive/public_members{/member}",
"repos_url": "https://api.github.com/orgs/graphql-hive/repos",
"url": "https://api.github.com/orgs/graphql-hive"
},
"pull_request": {
"_links": {
"comments": {
"href": "https://api.github.com/repos/graphql-hive/router/issues/895/comments"
},
"commits": {
"href": "https://api.github.com/repos/graphql-hive/router/pulls/895/commits"
},
"html": {
"href": "https://github.com/graphql-hive/router/pull/895"
},
"issue": {
"href": "https://api.github.com/repos/graphql-hive/router/issues/895"
},
"review_comment": {
"href": "https://api.github.com/repos/graphql-hive/router/pulls/comments{/number}"
},
"review_comments": {
"href": "https://api.github.com/repos/graphql-hive/router/pulls/895/comments"
},
"self": {
"href": "https://api.github.com/repos/graphql-hive/router/pulls/895"
},
"statuses": {
"href": "https://api.github.com/repos/graphql-hive/router/statuses/8314410f8a17429289e0146ce4277dadba14507f"
}
},
"active_lock_reason": null,
"additions": 1393,
"assignee": null,
"assignees": [],
"author_association": "MEMBER",
"auto_merge": null,
"base": {
"label": "graphql-hive:not-kamil-subs",
"ref": "not-kamil-subs",
"repo": {
"allow_auto_merge": false,
"allow_forking": true,
"allow_merge_commit": false,
"allow_rebase_merge": false,
"allow_squash_merge": true,
"allow_update_branch": true,
"archive_url": "https://api.github.com/repos/graphql-hive/router/{archive_format}{/ref}",
"archived": false,
"assignees_url": "https://api.github.com/repos/graphql-hive/router/assignees{/user}",
"blobs_url": "https://api.github.com/repos/graphql-hive/router/git/blobs{/sha}",
"branches_url": "https://api.github.com/repos/graphql-hive/router/branches{/branch}",
"clone_url": "https://github.com/graphql-hive/router.git",
"collaborators_url": "https://api.github.com/repos/graphql-hive/router/collaborators{/collaborator}",
"comments_url": "https://api.github.com/repos/graphql-hive/router/comments{/number}",
"commits_url": "https://api.github.com/repos/graphql-hive/router/commits{/sha}",
"compare_url": "https://api.github.com/repos/graphql-hive/router/compare/{base}...{head}",
"contents_url": "https://api.github.com/repos/graphql-hive/router/contents/{+path}",
"contributors_url": "https://api.github.com/repos/graphql-hive/router/contributors",
"created_at": "2024-11-20T16:16:12Z",
"default_branch": "main",
"delete_branch_on_merge": true,
"deployments_url": "https://api.github.com/repos/graphql-hive/router/deployments",
"description": "Open-source (MIT) GraphQL Federation Router. Built with Rust for maximum performance and robustness.",
"disabled": false,
"downloads_url": "https://api.github.com/repos/graphql-hive/router/downloads",
"events_url": "https://api.github.com/repos/graphql-hive/router/events",
"fork": false,
"forks": 9,
"forks_count": 9,
"forks_url": "https://api.github.com/repos/graphql-hive/router/forks",
"full_name": "graphql-hive/router",
"git_commits_url": "https://api.github.com/repos/graphql-hive/router/git/commits{/sha}",
"git_refs_url": "https://api.github.com/repos/graphql-hive/router/git/refs{/sha}",
"git_tags_url": "https://api.github.com/repos/graphql-hive/router/git/tags{/sha}",
"git_url": "git://github.com/graphql-hive/router.git",
"has_discussions": false,
"has_downloads": true,
"has_issues": true,
"has_pages": false,
"has_projects": false,
"has_pull_requests": true,
"has_wiki": false,
"homepage": "https://the-guild.dev/graphql/hive/docs/router",
"hooks_url": "https://api.github.com/repos/graphql-hive/router/hooks",
"html_url": "https://github.com/graphql-hive/router",
"id": 891604244,
"is_template": false,
"issue_comment_url": "https://api.github.com/repos/graphql-hive/router/issues/comments{/number}",
"issue_events_url": "https://api.github.com/repos/graphql-hive/router/issues/events{/number}",
"issues_url": "https://api.github.com/repos/graphql-hive/router/issues{/number}",
"keys_url": "https://api.github.com/repos/graphql-hive/router/keys{/key_id}",
"labels_url": "https://api.github.com/repos/graphql-hive/router/labels{/name}",
"language": "Rust",
"languages_url": "https://api.github.com/repos/graphql-hive/router/languages",
"license": {
"key": "mit",
"name": "MIT License",
"node_id": "MDc6TGljZW5zZTEz",
"spdx_id": "MIT",
"url": "https://api.github.com/licenses/mit"
},
"merge_commit_message": "PR_TITLE",
"merge_commit_title": "MERGE_MESSAGE",
"merges_url": "https://api.github.com/repos/graphql-hive/router/merges",
"milestones_url": "https://api.github.com/repos/graphql-hive/router/milestones{/number}",
"mirror_url": null,
"name": "router",
"node_id": "R_kgDONSTNFA",
"notifications_url": "https://api.github.com/repos/graphql-hive/router/notifications{?since,all,participating}",
"open_issues": 64,
"open_issues_count": 64,
"owner": {
"avatar_url": "https://avatars.githubusercontent.com/u/182742256?v=4",
"events_url": "https://api.github.com/users/graphql-hive/events{/privacy}",
"followers_url": "https://api.github.com/users/graphql-hive/followers",
"following_url": "https://api.github.com/users/graphql-hive/following{/other_user}",
"gists_url": "https://api.github.com/users/graphql-hive/gists{/gist_id}",
"gravatar_id": "",
"html_url": "https://github.com/graphql-hive",
"id": 182742256,
"login": "graphql-hive",
"node_id": "O_kgDOCuRs8A",
"organizations_url": "https://api.github.com/users/graphql-hive/orgs",
"received_events_url": "https://api.github.com/users/graphql-hive/received_events",
"repos_url": "https://api.github.com/users/graphql-hive/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/graphql-hive/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/graphql-hive/subscriptions",
"type": "Organization",
"url": "https://api.github.com/users/graphql-hive",
"user_view_type": "public"
},
"private": false,
"pull_request_creation_policy": "all",
"pulls_url": "https://api.github.com/repos/graphql-hive/router/pulls{/number}",
"pushed_at": "2026-04-04T20:59:14Z",
"releases_url": "https://api.github.com/repos/graphql-hive/router/releases{/id}",
"size": 6185,
"squash_merge_commit_message": "PR_BODY",
"squash_merge_commit_title": "PR_TITLE",
"ssh_url": "git@github.com:graphql-hive/router.git",
"stargazers_count": 81,
"stargazers_url": "https://api.github.com/repos/graphql-hive/router/stargazers",
"statuses_url": "https://api.github.com/repos/graphql-hive/router/statuses/{sha}",
"subscribers_url": "https://api.github.com/repos/graphql-hive/router/subscribers",
"subscription_url": "https://api.github.com/repos/graphql-hive/router/subscription",
"svn_url": "https://github.com/graphql-hive/router",
"tags_url": "https://api.github.com/repos/graphql-hive/router/tags",
"teams_url": "https://api.github.com/repos/graphql-hive/router/teams",
"topics": [
"apollo-federation",
"federation",
"federation-gateway",
"graphql",
"graphql-federation",
"router"
],
"trees_url": "https://api.github.com/repos/graphql-hive/router/git/trees{/sha}",
"updated_at": "2026-04-03T14:14:41Z",
"url": "https://api.github.com/repos/graphql-hive/router",
"use_squash_pr_title_as_default": true,
"visibility": "public",
"watchers": 81,
"watchers_count": 81,
"web_commit_signoff_required": false
},
"sha": "b4ea23158161f09757110bbc2bbe6d4a8bab5092",
"user": {
"avatar_url": "https://avatars.githubusercontent.com/u/182742256?v=4",
"events_url": "https://api.github.com/users/graphql-hive/events{/privacy}",
"followers_url": "https://api.github.com/users/graphql-hive/followers",
"following_url": "https://api.github.com/users/graphql-hive/following{/other_user}",
"gists_url": "https://api.github.com/users/graphql-hive/gists{/gist_id}",
"gravatar_id": "",
"html_url": "https://github.com/graphql-hive",
"id": 182742256,
"login": "graphql-hive",
"node_id": "O_kgDOCuRs8A",
"organizations_url": "https://api.github.com/users/graphql-hive/orgs",
"received_events_url": "https://api.github.com/users/graphql-hive/received_events",
"repos_url": "https://api.github.com/users/graphql-hive/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/graphql-hive/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/graphql-hive/subscriptions",
"type": "Organization",
"url": "https://api.github.com/users/graphql-hive",
"user_view_type": "public"
}
},
"body": "This is a stacked PR based on #620.\r\n\r\nThis PR adds subscription deduplication at the router level - the same mechanism that already existed for queries now also works for subscriptions (both HTTP streaming and WebSocket). It also introduces a global limit on concurrent long-lived clients (WebSocket connections and HTTP streaming responses), and cleans up naming throughout the codebase to better distinguish the two separate kinds of subscription tracking that now coexist.\r\n\r\n# Two kinds of subscriptions\r\n\r\nIt is worth being explicit about this because the codebase now has two distinct subscription concepts and they should not be confused.\r\n\r\n**Inbound subscriptions** (`ActiveSubscriptions`) are subscriptions from clients to the router. Each one represents a client that is currently connected and receiving events. This is the new registry introduced in this PR.\r\n\r\n**Outbound subscriptions** (`CallbackSubscriptions`, `WsSubgraphExecutor`) are subscriptions the router opens toward subgraphs in the background, either over HTTP callback protocol or WebSocket. These are invisible to the client - they are the upstream data sources that feed events into the router. Previously these were also called \"active subscriptions\" which made things ambiguous. They are now consistently named with the `callback_` prefix or the `ws_` prefix to make clear they are subgraph-facing, not client-facing.\r\n\r\n# What changed\r\n\r\n## Subscriptions are now deduplicated\r\n\r\nSubscriptions use the same fingerprint key as queries (method + path + selected headers + schema checksum + normalized operation hash + variables + extensions) and share the same in-flight map. The dedup mechanism is fundamentally different though because subscriptions are long-lived streams, not one-shot responses.\r\n\r\nThe first client (the leader) starts an upstream subscription and its events are pumped into a `tokio::broadcast` channel registered in the `ActiveSubscriptions` registry. Every subsequent client that arrives with the same fingerprint while that subscription is still active subscribes to the same broadcast channel instead of opening a new upstream connection. When the upstream finishes (or all listeners drop), the `ProducerHandle` drops, which removes the entry from both the registry and the in-flight map. The next client then starts a fresh upstream.\r\n\r\nThis means N clients subscribing to the same query with the same variables against the same schema version result in exactly one upstream subgraph connection.\r\n\r\n## Transport-agnostic subscriptions deduplication\r\n\r\nSubscriptions deduplication is completely transport-agnostic. HTTP streams and WebSocket requests share the same fingerprint space and the same in-flight map. This works for both queries and subscriptions.\r\n\r\nThe key enabler is that WebSocket subscribe messages are processed through a synthetic request - when a subscribe WebSocket message arrives, a synthetic `POST` is assembled using the WebSocket path and the connection's headers (filtered by the dedupe header policy). This synthetic request goes through the exact same `execute_planned_request` path as a real HTTP request, producing the same fingerprint. As a result:\r\n\r\n- A subscription started over HTTP and an identical one over WebSocket deduplicate against each other and share a single upstream connection\r\n- A query sent over WebSocket deduplicates with the same query sent over HTTP\r\n\r\nNo special casing is needed for transport anywhere in the dedup logic.\r\n\r\nOne important nuance: `accept` and other transport-specific headers (`connection: upgrade` for WebSockets) are part of the fingerprint when the `headers` dedupe config is set to `all` (the default). Since HTTP streaming clients send `accept: text/event-stream` or `accept: multipart/mixed` while WebSocket clients do not send an `accept` header at all, those requests will produce different fingerprints and will not deduplicate against each other by default. To get true cross-transport deduplication - where a WebSocket subscription and an SSE subscription with the same operation share one upstream connection and the events fan out to both - configure `headers: none` or explicitly exclude the transport-specific headers from the key:\r\n\r\n```yaml\r\ntraffic_shaping:\r\n router:\r\n dedupe:\r\n enabled: true\r\n headers: none\r\n```\r\n\r\nor with an explicit include list that omits transport-specific headers:\r\n\r\n```yaml\r\ntraffic_shaping:\r\n router:\r\n dedupe:\r\n enabled: true\r\n headers:\r\n include:\r\n - authorization\r\n - x-tenant-id\r\n```\r\n\r\n## Single `execute_planned_request` for all transports\r\n\r\nBefore this PR, the WebSocket handler had its own copy of the entire execution pipeline - JWT validation, variable coercion, client request details assembly, plan execution, usage reporting - all duplicated from the HTTP handler. This was the main reason WebSocket subscriptions could not participate in deduplication.\r\n\r\nNow both HTTP and WebSocket handlers call a single `execute_planned_request` function. The WebSocket handler constructs a synthetic `Method`, `Uri`, and `HeaderMap` from the WebSocket connection context and passes those in. The function has no knowledge of the transport - it just sees a method, URL, and headers, same as if it came from HTTP. This eliminates the duplication and is what makes transport-agnostic dedup possible.\r\n\r\n## The caller-owned `InFlightCleanupGuard`\r\n\r\nInflight map gains a new method `get_or_try_init_with_guard` where the guard is handed to the init closure as an argument. It is used only for subscriptions, and there - it is moved into the upstream pump task and lives as long as the stream, so joiners can find the entry and connect to the broadcast channel.\r\n\r\n### Why not change `get_or_try_init`?\r\n\r\nPassing the guard into init also for queries and dropping it when init resolves introduced a 2x performance regression. I just couldn't figure out why. If you know, please share ([browse code](https://github.com/graphql-hive/router/tree/0329884fadc3391d764d73e1a3d9a4f8b4aa67c3) | [see 2x regression](https://github.com/graphql-hive/router/actions/runs/23985118763/job/69955716903)).\r\n\r\n### No clones in `InFlightMap`\r\n\r\nThe inflight map's `get_or_try_init` previously cloned `key` and `map` unconditionally on every call (both leader and joiner paths) so they could be captured by the closure. By destructuring self upfront into its three fields, all three can be moved independently - `cell` directly into the `get_or_try_init` call, `key` and `map` directly into the guard inside the closure - eliminating all clones on this path.\r\n\r\n## Active subscriptions registry\r\n\r\nA new `ActiveSubscriptions` registry sits in shared router state. Every active upstream subscription is registered there. The registry holds the broadcast sender for each subscription identified by a [ULID](https://github.com/ulid/spec). `ProducerHandle` is a RAII wrapper: when it drops, it removes the entry from the registry and drops the in-flight cleanup guard.\r\n\r\nThe registry is also the mechanism for graceful shutdown on schema reload: when a new supergraph is loaded, all active subscriptions are closed with a `SUBSCRIPTION_SCHEMA_RELOAD` error before the schema is swapped in. Clients receive this as a final error event and are expected to reconnect. This is the same error code Apollo Router uses.\r\n\r\n## `SharedRouterResponse` is now an enum\r\n\r\nBefore this PR `SharedRouterResponse` only represented a single buffered response body. Streaming responses bypassed it entirely and were returned as a separate `PlannedResponse::Direct` variant (from #620) that could not participate in the dedup path.\r\n\r\nNow `SharedRouterResponse` is an enum with two variants:\r\n\r\n- `Single` - a buffered body with status code and headers\r\n- `Stream` - a broadcast sender, the pre-subscribed leader receiver, the stream content type, and headers\r\n\r\nThis unification means the whole pipeline collapses to a single `execute_planned_request` call for all transports and both operation types. The `PlannedResponse` enum is gone. Error count, usage reporting, and metric writing all work through the enum regardless of response type.\r\n\r\n## WebSocket subscription loop is now spawned\r\n\r\nPreviously the WebSocket frame handler drove the subscription event loop inline using \"select\" to race the upstream `BoxStream` against a cancel channel. This worked because the frame handler owned the stream directly - it could poll it and still break out on cancel.\r\n\r\nNow the upstream is no longer held by the frame handler. `execute_planned_request` spawns a pump task that drains the upstream into a broadcast channel before returning. By the time the frame handler gets back a `SharedRouterResponse::Stream` it only has a broadcast receiver, not the stream itself. If the subscription loop then runs inline, the frame handler blocks on `receiver.recv()` and never reads another frame from the client - so a `ClientMessage::Complete` sent by the client sits unread in the WebSocket buffer forever and cancellation is broken.\r\n\r\nThe fix is to spawn the subscription loop as a separate task. The frame handler returns immediately and remains free to process incoming frames. When it eventually reads a `Complete`, it sends on the cancel channel which the spawned loop is selecting on. `ServerMessage::complete` is sent from inside the spawned task once the loop exits.\r\n\r\n## Long-lived client limit\r\n\r\nA new middleware counts active WebSocket and HTTP streaming connections and rejects new ones with `503 Service Unavailable` + `Retry-After: 5` once the limit is reached. It is entirely branch-free on the hot path for non-streaming requests: the `enabled` flag is resolved once at app construction, and the streaming check uses cheap header lookups with a fast substring pre-filter before falling back to full Accept header parsing.\r\n\r\nThe limit defaults to 128 and only activates when at least one of WebSocket or Subscriptions is enabled and the limit is greater than zero. Configurable via `traffic_shaping.router.max_long_lived_clients`:\r\n\r\n```yaml\r\ntraffic_shaping:\r\n router:\r\n max_long_lived_clients: 256\r\n```\r\n\r\n## Naming cleanup\r\n\r\nThe HTTP callback subscription infrastructure was renamed throughout to include the word `callback` so it is clearly distinct from the new inbound active subscriptions registry:\r\n\r\n- `ActiveSubscription` -> `CallbackSubscription`\r\n- `ActiveSubscriptionsMap` -> `CallbackSubscriptionsMap`\r\n- `active_callback_subscriptions` field -> `callback_subscriptions`\r\n- `HeartbeatEnforcerTask` -> `CallbackHeartbeatEnforcerTask`\r\n\r\n## ID generation switched to ULID\r\n\r\nSubscription IDs in both the active subscriptions registry and the HTTP callback executor now use ULIDs instead of UUID v4. The HTTP callback protocol does not mandate any particular ID format - the spec only requires that the subscription ID and verifier are strings agreed upon between the router and the subgraph, so we are free to use whatever we want. ULID was chosen because it is [extremely fast to generate](https://github.com/dylanhart/ulid-rs).\r\n\r\n## Broadcast capacity is configurable\r\n\r\nThe broadcast channel buffer size is configurable via `subscriptions.broadcast_capacity` (default 32). When a consumer falls behind and the buffer fills up, it skips missed messages and continues from the latest available event - there is no error, just a trace log. This is intentional: the alternative (blocking the pump) would stall all other consumers, or dropping the client would be destructive (maybe it recovers).\r\n\r\n```yaml\r\nsubscriptions:\r\n enabled: true\r\n broadcast_capacity: 64\r\n```\r\n\r\nThe internal mpsc buffers between the subgraph executor and the pump task are reduced from 256 to 16 now that back-pressure is handled by the broadcast channel's own buffer.\r\n\r\n# Open questions\r\n\r\n**Do we actually want cross-transport deduplication?** The mechanism supports it, but it means a WebSocket client and an SSE client end up sharing the same broadcast channel and receiving the same raw event bytes. The bytes are valid for both because subscription events are just JSON, but it is worth deciding whether this is intentional behavior or an accidental side effect of the unified pipeline.\r\n\r\n**Should single responses deduplicate across transports too?** The body is always JSON so it fits either transport. The awkward part is the status code and the `content-type` headers. For example, if one client accepts `application/json` and another `application/graphql-response+json` - they must get matching content types respectivelly. This might be an issue when the user excludes `accept` from deduplication - but its rare to have many parallel different transports - so its up for debate.\r\n\r\n# Gotchas\r\n\r\n**Late joiners do not get replayed events.** A client that joins an already-running subscription receives events from the moment it subscribes onward. There is no replay of events already delivered to earlier clients. The promotion e2e test explicitly asserts this behavior.\r\n\r\n**The leader receiver is pre-subscribed before the pump spawns.** The pump is spawned after the broadcast channel is created, but the leader's receiver is taken before the spawn. This closes the window where the channel could have zero receivers between spawn and the first consumer subscribing, which would cause events to be silently dropped by tokio's broadcast channel.\r\n\r\n**No outbound subscriptions deduplication.** Often enough users emit an initial event on subscribe allowing clients to sync their state on subscribe. If we were to deduplicate outbound subscriptions (those going to subgraphs), clients would miss the initial events and their state could go out of sync. Maybe we can have an option? No, because subgraph teams are separate to gateway teams, and tracking down why an initial event is not propagated is time wasted.\r\n\r\n# TODOs\r\n\r\n- [x] Performance regression (Read \"Why not change get_or_try_init?\" section)\r\n- [x] Test max_long_lived_clients limits\r\n- [ ] Test supergraph schema reload kickoff\r\n- [ ] Can we buffer 1 the subgraph executor of http callbacks and websockets because the active subscriptions broadcaster handles backpressure?\r\n- [ ] Document\r\n",
"changed_files": 21,
"closed_at": null,
"comments": 1,
"comments_url": "https://api.github.com/repos/graphql-hive/router/issues/895/comments",
"commits": 42,
"commits_url": "https://api.github.com/repos/graphql-hive/router/pulls/895/commits",
"created_at": "2026-04-04T00:21:26Z",
"deletions": 409,
"diff_url": "https://github.com/graphql-hive/router/pull/895.diff",
"draft": false,
"head": {
"label": "graphql-hive:not-kamil-subs-single-subs",
"ref": "not-kamil-subs-single-subs",
"repo": {
"allow_auto_merge": false,
"allow_forking": true,
"allow_merge_commit": false,
"allow_rebase_merge": false,
"allow_squash_merge": true,
"allow_update_branch": true,
"archive_url": "https://api.github.com/repos/graphql-hive/router/{archive_format}{/ref}",
"archived": false,
"assignees_url": "https://api.github.com/repos/graphql-hive/router/assignees{/user}",
"blobs_url": "https://api.github.com/repos/graphql-hive/router/git/blobs{/sha}",
"branches_url": "https://api.github.com/repos/graphql-hive/router/branches{/branch}",
"clone_url": "https://github.com/graphql-hive/router.git",
"collaborators_url": "https://api.github.com/repos/graphql-hive/router/collaborators{/collaborator}",
"comments_url": "https://api.github.com/repos/graphql-hive/router/comments{/number}",
"commits_url": "https://api.github.com/repos/graphql-hive/router/commits{/sha}",
"compare_url": "https://api.github.com/repos/graphql-hive/router/compare/{base}...{head}",
"contents_url": "https://api.github.com/repos/graphql-hive/router/contents/{+path}",
"contributors_url": "https://api.github.com/repos/graphql-hive/router/contributors",
"created_at": "2024-11-20T16:16:12Z",
"default_branch": "main",
"delete_branch_on_merge": true,
"deployments_url": "https://api.github.com/repos/graphql-hive/router/deployments",
"description": "Open-source (MIT) GraphQL Federation Router. Built with Rust for maximum performance and robustness.",
"disabled": false,
"downloads_url": "https://api.github.com/repos/graphql-hive/router/downloads",
"events_url": "https://api.github.com/repos/graphql-hive/router/events",
"fork": false,
"forks": 9,
"forks_count": 9,
"forks_url": "https://api.github.com/repos/graphql-hive/router/forks",
"full_name": "graphql-hive/router",
"git_commits_url": "https://api.github.com/repos/graphql-hive/router/git/commits{/sha}",
"git_refs_url": "https://api.github.com/repos/graphql-hive/router/git/refs{/sha}",
"git_tags_url": "https://api.github.com/repos/graphql-hive/router/git/tags{/sha}",
"git_url": "git://github.com/graphql-hive/router.git",
"has_discussions": false,
"has_downloads": true,
"has_issues": true,
"has_pages": false,
"has_projects": false,
"has_pull_requests": true,
"has_wiki": false,
"homepage": "https://the-guild.dev/graphql/hive/docs/router",
"hooks_url": "https://api.github.com/repos/graphql-hive/router/hooks",
"html_url": "https://github.com/graphql-hive/router",
"id": 891604244,
"is_template": false,
"issue_comment_url": "https://api.github.com/repos/graphql-hive/router/issues/comments{/number}",
"issue_events_url": "https://api.github.com/repos/graphql-hive/router/issues/events{/number}",
"issues_url": "https://api.github.com/repos/graphql-hive/router/issues{/number}",
"keys_url": "https://api.github.com/repos/graphql-hive/router/keys{/key_id}",
"labels_url": "https://api.github.com/repos/graphql-hive/router/labels{/name}",
"language": "Rust",
"languages_url": "https://api.github.com/repos/graphql-hive/router/languages",
"license": {
"key": "mit",
"name": "MIT License",
"node_id": "MDc6TGljZW5zZTEz",
"spdx_id": "MIT",
"url": "https://api.github.com/licenses/mit"
},
"merge_commit_message": "PR_TITLE",
"merge_commit_title": "MERGE_MESSAGE",
"merges_url": "https://api.github.com/repos/graphql-hive/router/merges",
"milestones_url": "https://api.github.com/repos/graphql-hive/router/milestones{/number}",
"mirror_url": null,
"name": "router",
"node_id": "R_kgDONSTNFA",
"notifications_url": "https://api.github.com/repos/graphql-hive/router/notifications{?since,all,participating}",
"open_issues": 64,
"open_issues_count": 64,
"owner": {
"avatar_url": "https://avatars.githubusercontent.com/u/182742256?v=4",
"events_url": "https://api.github.com/users/graphql-hive/events{/privacy}",
"followers_url": "https://api.github.com/users/graphql-hive/followers",
"following_url": "https://api.github.com/users/graphql-hive/following{/other_user}",
"gists_url": "https://api.github.com/users/graphql-hive/gists{/gist_id}",
"gravatar_id": "",
"html_url": "https://github.com/graphql-hive",
"id": 182742256,
"login": "graphql-hive",
"node_id": "O_kgDOCuRs8A",
"organizations_url": "https://api.github.com/users/graphql-hive/orgs",
"received_events_url": "https://api.github.com/users/graphql-hive/received_events",
"repos_url": "https://api.github.com/users/graphql-hive/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/graphql-hive/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/graphql-hive/subscriptions",
"type": "Organization",
"url": "https://api.github.com/users/graphql-hive",
"user_view_type": "public"
},
"private": false,
"pull_request_creation_policy": "all",
"pulls_url": "https://api.github.com/repos/graphql-hive/router/pulls{/number}",
"pushed_at": "2026-04-04T20:59:14Z",
"releases_url": "https://api.github.com/repos/graphql-hive/router/releases{/id}",
"size": 6185,
"squash_merge_commit_message": "PR_BODY",
"squash_merge_commit_title": "PR_TITLE",
"ssh_url": "git@github.com:graphql-hive/router.git",
"stargazers_count": 81,
"stargazers_url": "https://api.github.com/repos/graphql-hive/router/stargazers",
"statuses_url": "https://api.github.com/repos/graphql-hive/router/statuses/{sha}",
"subscribers_url": "https://api.github.com/repos/graphql-hive/router/subscribers",
"subscription_url": "https://api.github.com/repos/graphql-hive/router/subscription",
"svn_url": "https://github.com/graphql-hive/router",
"tags_url": "https://api.github.com/repos/graphql-hive/router/tags",
"teams_url": "https://api.github.com/repos/graphql-hive/router/teams",
"topics": [
"apollo-federation",
"federation",
"federation-gateway",
"graphql",
"graphql-federation",
"router"
],
"trees_url": "https://api.github.com/repos/graphql-hive/router/git/trees{/sha}",
"updated_at": "2026-04-03T14:14:41Z",
"url": "https://api.github.com/repos/graphql-hive/router",
"use_squash_pr_title_as_default": true,
"visibility": "public",
"watchers": 81,
"watchers_count": 81,
"web_commit_signoff_required": false
},
"sha": "8314410f8a17429289e0146ce4277dadba14507f",
"user": {
"avatar_url": "https://avatars.githubusercontent.com/u/182742256?v=4",
"events_url": "https://api.github.com/users/graphql-hive/events{/privacy}",
"followers_url": "https://api.github.com/users/graphql-hive/followers",
"following_url": "https://api.github.com/users/graphql-hive/following{/other_user}",
"gists_url": "https://api.github.com/users/graphql-hive/gists{/gist_id}",
"gravatar_id": "",
"html_url": "https://github.com/graphql-hive",
"id": 182742256,
"login": "graphql-hive",
"node_id": "O_kgDOCuRs8A",
"organizations_url": "https://api.github.com/users/graphql-hive/orgs",
"received_events_url": "https://api.github.com/users/graphql-hive/received_events",
"repos_url": "https://api.github.com/users/graphql-hive/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/graphql-hive/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/graphql-hive/subscriptions",
"type": "Organization",
"url": "https://api.github.com/users/graphql-hive",
"user_view_type": "public"
}
},
"html_url": "https://github.com/graphql-hive/router/pull/895",
"id": 3487910106,
"issue_url": "https://api.github.com/repos/graphql-hive/router/issues/895",
"labels": [],
"locked": false,
"maintainer_can_modify": false,
"merge_commit_sha": "cfe356494bbea4d296b4b84da4368dae1ceaec59",
"mergeable": null,
"mergeable_state": "unknown",
"merged": false,
"merged_at": null,
"merged_by": null,
"milestone": null,
"node_id": "PR_kwDONSTNFM7P5Uja",
"number": 895,
"patch_url": "https://github.com/graphql-hive/router/pull/895.patch",
"rebaseable": null,
"requested_reviewers": [],
"requested_teams": [],
"review_comment_url": "https://api.github.com/repos/graphql-hive/router/pulls/comments{/number}",
"review_comments": 2,
"review_comments_url": "https://api.github.com/repos/graphql-hive/router/pulls/895/comments",
"state": "open",
"statuses_url": "https://api.github.com/repos/graphql-hive/router/statuses/8314410f8a17429289e0146ce4277dadba14507f",
"title": "Subscription deduplication, active subscriptions registry and limits",
"updated_at": "2026-04-04T20:59:16Z",
"url": "https://api.github.com/repos/graphql-hive/router/pulls/895",
"user": {
"avatar_url": "https://avatars.githubusercontent.com/u/11807600?v=4",
"events_url": "https://api.github.com/users/enisdenjo/events{/privacy}",
"followers_url": "https://api.github.com/users/enisdenjo/followers",
"following_url": "https://api.github.com/users/enisdenjo/following{/other_user}",
"gists_url": "https://api.github.com/users/enisdenjo/gists{/gist_id}",
"gravatar_id": "",
"html_url": "https://github.com/enisdenjo",
"id": 11807600,
"login": "enisdenjo",
"node_id": "MDQ6VXNlcjExODA3NjAw",
"organizations_url": "https://api.github.com/users/enisdenjo/orgs",
"received_events_url": "https://api.github.com/users/enisdenjo/received_events",
"repos_url": "https://api.github.com/users/enisdenjo/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/enisdenjo/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/enisdenjo/subscriptions",
"type": "User",
"url": "https://api.github.com/users/enisdenjo",
"user_view_type": "public"
}
},
"repository": {
"allow_forking": true,
"archive_url": "https://api.github.com/repos/graphql-hive/router/{archive_format}{/ref}",
"archived": false,
"assignees_url": "https://api.github.com/repos/graphql-hive/router/assignees{/user}",
"blobs_url": "https://api.github.com/repos/graphql-hive/router/git/blobs{/sha}",
"branches_url": "https://api.github.com/repos/graphql-hive/router/branches{/branch}",
"clone_url": "https://github.com/graphql-hive/router.git",
"collaborators_url": "https://api.github.com/repos/graphql-hive/router/collaborators{/collaborator}",
"comments_url": "https://api.github.com/repos/graphql-hive/router/comments{/number}",
"commits_url": "https://api.github.com/repos/graphql-hive/router/commits{/sha}",
"compare_url": "https://api.github.com/repos/graphql-hive/router/compare/{base}...{head}",
"contents_url": "https://api.github.com/repos/graphql-hive/router/contents/{+path}",
"contributors_url": "https://api.github.com/repos/graphql-hive/router/contributors",
"created_at": "2024-11-20T16:16:12Z",
"custom_properties": {
"vanta_production_branch_name": "main"
},
"default_branch": "main",
"deployments_url": "https://api.github.com/repos/graphql-hive/router/deployments",
"description": "Open-source (MIT) GraphQL Federation Router. Built with Rust for maximum performance and robustness.",
"disabled": false,
"downloads_url": "https://api.github.com/repos/graphql-hive/router/downloads",
"events_url": "https://api.github.com/repos/graphql-hive/router/events",
"fork": false,
"forks": 9,
"forks_count": 9,
"forks_url": "https://api.github.com/repos/graphql-hive/router/forks",
"full_name": "graphql-hive/router",
"git_commits_url": "https://api.github.com/repos/graphql-hive/router/git/commits{/sha}",
"git_refs_url": "https://api.github.com/repos/graphql-hive/router/git/refs{/sha}",
"git_tags_url": "https://api.github.com/repos/graphql-hive/router/git/tags{/sha}",
"git_url": "git://github.com/graphql-hive/router.git",
"has_discussions": false,
"has_downloads": true,
"has_issues": true,
"has_pages": false,
"has_projects": false,
"has_pull_requests": true,
"has_wiki": false,
"homepage": "https://the-guild.dev/graphql/hive/docs/router",
"hooks_url": "https://api.github.com/repos/graphql-hive/router/hooks",
"html_url": "https://github.com/graphql-hive/router",
"id": 891604244,
"is_template": false,
"issue_comment_url": "https://api.github.com/repos/graphql-hive/router/issues/comments{/number}",
"issue_events_url": "https://api.github.com/repos/graphql-hive/router/issues/events{/number}",
"issues_url": "https://api.github.com/repos/graphql-hive/router/issues{/number}",
"keys_url": "https://api.github.com/repos/graphql-hive/router/keys{/key_id}",
"labels_url": "https://api.github.com/repos/graphql-hive/router/labels{/name}",
"language": "Rust",
"languages_url": "https://api.github.com/repos/graphql-hive/router/languages",
"license": {
"key": "mit",
"name": "MIT License",
"node_id": "MDc6TGljZW5zZTEz",
"spdx_id": "MIT",
"url": "https://api.github.com/licenses/mit"
},
"merges_url": "https://api.github.com/repos/graphql-hive/router/merges",
"milestones_url": "https://api.github.com/repos/graphql-hive/router/milestones{/number}",
"mirror_url": null,
"name": "router",
"node_id": "R_kgDONSTNFA",
"notifications_url": "https://api.github.com/repos/graphql-hive/router/notifications{?since,all,participating}",
"open_issues": 64,
"open_issues_count": 64,
"owner": {
"avatar_url": "https://avatars.githubusercontent.com/u/182742256?v=4",
"events_url": "https://api.github.com/users/graphql-hive/events{/privacy}",
"followers_url": "https://api.github.com/users/graphql-hive/followers",
"following_url": "https://api.github.com/users/graphql-hive/following{/other_user}",
"gists_url": "https://api.github.com/users/graphql-hive/gists{/gist_id}",
"gravatar_id": "",
"html_url": "https://github.com/graphql-hive",
"id": 182742256,
"login": "graphql-hive",
"node_id": "O_kgDOCuRs8A",
"organizations_url": "https://api.github.com/users/graphql-hive/orgs",
"received_events_url": "https://api.github.com/users/graphql-hive/received_events",
"repos_url": "https://api.github.com/users/graphql-hive/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/graphql-hive/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/graphql-hive/subscriptions",
"type": "Organization",
"url": "https://api.github.com/users/graphql-hive",
"user_view_type": "public"
},
"private": false,
"pull_request_creation_policy": "all",
"pulls_url": "https://api.github.com/repos/graphql-hive/router/pulls{/number}",
"pushed_at": "2026-04-04T20:59:14Z",
"releases_url": "https://api.github.com/repos/graphql-hive/router/releases{/id}",
"size": 6185,
"ssh_url": "git@github.com:graphql-hive/router.git",
"stargazers_count": 81,
"stargazers_url": "https://api.github.com/repos/graphql-hive/router/stargazers",
"statuses_url": "https://api.github.com/repos/graphql-hive/router/statuses/{sha}",
"subscribers_url": "https://api.github.com/repos/graphql-hive/router/subscribers",
"subscription_url": "https://api.github.com/repos/graphql-hive/router/subscription",
"svn_url": "https://github.com/graphql-hive/router",
"tags_url": "https://api.github.com/repos/graphql-hive/router/tags",
"teams_url": "https://api.github.com/repos/graphql-hive/router/teams",
"topics": [
"apollo-federation",
"federation",
"federation-gateway",
"graphql",
"graphql-federation",
"router"
],
"trees_url": "https://api.github.com/repos/graphql-hive/router/git/trees{/sha}",
"updated_at": "2026-04-03T14:14:41Z",
"url": "https://api.github.com/repos/graphql-hive/router",
"visibility": "public",
"watchers": 81,
"watchers_count": 81,
"web_commit_signoff_required": false
},
"sender": {
"avatar_url": "https://avatars.githubusercontent.com/u/11807600?v=4",
"events_url": "https://api.github.com/users/enisdenjo/events{/privacy}",
"followers_url": "https://api.github.com/users/enisdenjo/followers",
"following_url": "https://api.github.com/users/enisdenjo/following{/other_user}",
"gists_url": "https://api.github.com/users/enisdenjo/gists{/gist_id}",
"gravatar_id": "",
"html_url": "https://github.com/enisdenjo",
"id": 11807600,
"login": "enisdenjo",
"node_id": "MDQ6VXNlcjExODA3NjAw",
"organizations_url": "https://api.github.com/users/enisdenjo/orgs",
"received_events_url": "https://api.github.com/users/enisdenjo/received_events",
"repos_url": "https://api.github.com/users/enisdenjo/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/enisdenjo/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/enisdenjo/subscriptions",
"type": "User",
"url": "https://api.github.com/users/enisdenjo",
"user_view_type": "public"
}
},
"github_job": "docker",
"github_ref": "refs/pull/895/merge",
"github_ref_name": "895/merge",
"github_ref_protected": "false",
"github_ref_type": "branch",
"github_repository": "graphql-hive/router",
"github_repository_id": "891604244",
"github_repository_owner": "graphql-hive",
"github_repository_owner_id": "182742256",
"github_run_attempt": "1",
"github_run_id": "23987464339",
"github_run_number": "2206",
"github_runner_arch": "X64",
"github_runner_environment": "github-hosted",
"github_runner_image_os": "ubuntu24",
"github_runner_image_version": "20260329.72.1",
"github_runner_name": "GitHub Actions 1000718830",
"github_runner_os": "Linux",
"github_runner_tracking_id": "github_eb84de7f-bc43-4bd7-b2a1-2ef75656989f",
"github_server_url": "https://github.com",
"github_triggering_actor": "enisdenjo",
"github_workflow": "build-router",
"github_workflow_ref": "graphql-hive/router/.github/workflows/build-router.yaml@refs/pull/895/merge",
"github_workflow_sha": "250e6da9cda80b809f12c473f838b657d6e817dd",
"platform": "linux/amd64"
}
}
},
"buildx.build.provenance/linux/arm64": {
"builder": {
"id": "https://github.com/graphql-hive/router/actions/runs/23987464339/attempts/1"
},
"buildType": "https://mobyproject.org/buildkit@v1",
"materials": [
{
"uri": "pkg:docker/docker/dockerfile@1.22",
"digest": {
"sha256": "4a43a54dd1fedceb30ba47e76cfcf2b47304f4161c0caeac2db1c61804ea3c91"
}
},
{
"uri": "pkg:docker/gcr.io/distroless/cc-debian12@latest?platform=linux%2Farm64",
"digest": {
"sha256": "329e54034ce498f9c6b345044e8f530c6691f99e94a92446f68c0adf9baa8464"
}
}
],
"invocation": {
"configSource": {
"entryPoint": "router.Dockerfile"
},
"parameters": {
"frontend": "gateway.v0",
"args": {
"cmdline": "docker/dockerfile:1.22",
"label:org.opencontainers.image.created": "2026-04-04T21:09:14.533Z",
"label:org.opencontainers.image.description": "Open-source (MIT) GraphQL Federation Router. Built with Rust for maximum performance and robustness.",
"label:org.opencontainers.image.licenses": "MIT",
"label:org.opencontainers.image.revision": "250e6da9cda80b809f12c473f838b657d6e817dd",
"label:org.opencontainers.image.source": "https://github.com/graphql-hive/router",
"label:org.opencontainers.image.title": "router",
"label:org.opencontainers.image.url": "https://github.com/graphql-hive/router",
"label:org.opencontainers.image.vendor": "theguild",
"label:org.opencontainers.image.version": "pr-895",
"source": "docker/dockerfile:1.22"
},
"locals": [
{
"name": "context"
},
{
"name": "dockerfile"
}
]
},
"environment": {
"github_actor": "enisdenjo",
"github_actor_id": "11807600",
"github_event_name": "pull_request",
"github_event_payload": {
"action": "synchronize",
"after": "8314410f8a17429289e0146ce4277dadba14507f",
"before": "7e8c6aea41043d5fcde73ba46b8115e5e2cdd39e",
"enterprise": {
"avatar_url": "https://avatars.githubusercontent.com/b/187753?v=4",
"created_at": "2024-07-02T08:52:28Z",
"description": "",
"html_url": "https://github.com/enterprises/the-guild",
"id": 187753,
"name": "The Guild",
"node_id": "E_kgDOAALdaQ",
"slug": "the-guild",
"updated_at": "2026-03-11T16:47:15Z",
"website_url": "https://the-guild.dev/"
},
"number": 895,
"organization": {
"avatar_url": "https://avatars.githubusercontent.com/u/182742256?v=4",
"description": "Schema registry, analytics and gateway for GraphQL federation and other GraphQL APIs.",
"events_url": "https://api.github.com/orgs/graphql-hive/events",
"hooks_url": "https://api.github.com/orgs/graphql-hive/hooks",
"id": 182742256,
"issues_url": "https://api.github.com/orgs/graphql-hive/issues",
"login": "graphql-hive",
"members_url": "https://api.github.com/orgs/graphql-hive/members{/member}",
"node_id": "O_kgDOCuRs8A",
"public_members_url": "https://api.github.com/orgs/graphql-hive/public_members{/member}",
"repos_url": "https://api.github.com/orgs/graphql-hive/repos",
"url": "https://api.github.com/orgs/graphql-hive"
},
"pull_request": {
"_links": {
"comments": {
"href": "https://api.github.com/repos/graphql-hive/router/issues/895/comments"
},
"commits": {
"href": "https://api.github.com/repos/graphql-hive/router/pulls/895/commits"
},
"html": {
"href": "https://github.com/graphql-hive/router/pull/895"
},
"issue": {
"href": "https://api.github.com/repos/graphql-hive/router/issues/895"
},
"review_comment": {
"href": "https://api.github.com/repos/graphql-hive/router/pulls/comments{/number}"
},
"review_comments": {
"href": "https://api.github.com/repos/graphql-hive/router/pulls/895/comments"
},
"self": {
"href": "https://api.github.com/repos/graphql-hive/router/pulls/895"
},
"statuses": {
"href": "https://api.github.com/repos/graphql-hive/router/statuses/8314410f8a17429289e0146ce4277dadba14507f"
}
},
"active_lock_reason": null,
"additions": 1393,
"assignee": null,
"assignees": [],
"author_association": "MEMBER",
"auto_merge": null,
"base": {
"label": "graphql-hive:not-kamil-subs",
"ref": "not-kamil-subs",
"repo": {
"allow_auto_merge": false,
"allow_forking": true,
"allow_merge_commit": false,
"allow_rebase_merge": false,
"allow_squash_merge": true,
"allow_update_branch": true,
"archive_url": "https://api.github.com/repos/graphql-hive/router/{archive_format}{/ref}",
"archived": false,
"assignees_url": "https://api.github.com/repos/graphql-hive/router/assignees{/user}",
"blobs_url": "https://api.github.com/repos/graphql-hive/router/git/blobs{/sha}",
"branches_url": "https://api.github.com/repos/graphql-hive/router/branches{/branch}",
"clone_url": "https://github.com/graphql-hive/router.git",
"collaborators_url": "https://api.github.com/repos/graphql-hive/router/collaborators{/collaborator}",
"comments_url": "https://api.github.com/repos/graphql-hive/router/comments{/number}",
"commits_url": "https://api.github.com/repos/graphql-hive/router/commits{/sha}",
"compare_url": "https://api.github.com/repos/graphql-hive/router/compare/{base}...{head}",
"contents_url": "https://api.github.com/repos/graphql-hive/router/contents/{+path}",
"contributors_url": "https://api.github.com/repos/graphql-hive/router/contributors",
"created_at": "2024-11-20T16:16:12Z",
"default_branch": "main",
"delete_branch_on_merge": true,
"deployments_url": "https://api.github.com/repos/graphql-hive/router/deployments",
"description": "Open-source (MIT) GraphQL Federation Router. Built with Rust for maximum performance and robustness.",
"disabled": false,
"downloads_url": "https://api.github.com/repos/graphql-hive/router/downloads",
"events_url": "https://api.github.com/repos/graphql-hive/router/events",
"fork": false,
"forks": 9,
"forks_count": 9,
"forks_url": "https://api.github.com/repos/graphql-hive/router/forks",
"full_name": "graphql-hive/router",
"git_commits_url": "https://api.github.com/repos/graphql-hive/router/git/commits{/sha}",
"git_refs_url": "https://api.github.com/repos/graphql-hive/router/git/refs{/sha}",
"git_tags_url": "https://api.github.com/repos/graphql-hive/router/git/tags{/sha}",
"git_url": "git://github.com/graphql-hive/router.git",
"has_discussions": false,
"has_downloads": true,
"has_issues": true,
"has_pages": false,
"has_projects": false,
"has_pull_requests": true,
"has_wiki": false,
"homepage": "https://the-guild.dev/graphql/hive/docs/router",
"hooks_url": "https://api.github.com/repos/graphql-hive/router/hooks",
"html_url": "https://github.com/graphql-hive/router",
"id": 891604244,
"is_template": false,
"issue_comment_url": "https://api.github.com/repos/graphql-hive/router/issues/comments{/number}",
"issue_events_url": "https://api.github.com/repos/graphql-hive/router/issues/events{/number}",
"issues_url": "https://api.github.com/repos/graphql-hive/router/issues{/number}",
"keys_url": "https://api.github.com/repos/graphql-hive/router/keys{/key_id}",
"labels_url": "https://api.github.com/repos/graphql-hive/router/labels{/name}",
"language": "Rust",
"languages_url": "https://api.github.com/repos/graphql-hive/router/languages",
"license": {
"key": "mit",
"name": "MIT License",
"node_id": "MDc6TGljZW5zZTEz",
"spdx_id": "MIT",
"url": "https://api.github.com/licenses/mit"
},
"merge_commit_message": "PR_TITLE",
"merge_commit_title": "MERGE_MESSAGE",
"merges_url": "https://api.github.com/repos/graphql-hive/router/merges",
"milestones_url": "https://api.github.com/repos/graphql-hive/router/milestones{/number}",
"mirror_url": null,
"name": "router",
"node_id": "R_kgDONSTNFA",
"notifications_url": "https://api.github.com/repos/graphql-hive/router/notifications{?since,all,participating}",
"open_issues": 64,
"open_issues_count": 64,
"owner": {
"avatar_url": "https://avatars.githubusercontent.com/u/182742256?v=4",
"events_url": "https://api.github.com/users/graphql-hive/events{/privacy}",
"followers_url": "https://api.github.com/users/graphql-hive/followers",
"following_url": "https://api.github.com/users/graphql-hive/following{/other_user}",
"gists_url": "https://api.github.com/users/graphql-hive/gists{/gist_id}",
"gravatar_id": "",
"html_url": "https://github.com/graphql-hive",
"id": 182742256,
"login": "graphql-hive",
"node_id": "O_kgDOCuRs8A",
"organizations_url": "https://api.github.com/users/graphql-hive/orgs",
"received_events_url": "https://api.github.com/users/graphql-hive/received_events",
"repos_url": "https://api.github.com/users/graphql-hive/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/graphql-hive/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/graphql-hive/subscriptions",
"type": "Organization",
"url": "https://api.github.com/users/graphql-hive",
"user_view_type": "public"
},
"private": false,
"pull_request_creation_policy": "all",
"pulls_url": "https://api.github.com/repos/graphql-hive/router/pulls{/number}",
"pushed_at": "2026-04-04T20:59:14Z",
"releases_url": "https://api.github.com/repos/graphql-hive/router/releases{/id}",
"size": 6185,
"squash_merge_commit_message": "PR_BODY",
"squash_merge_commit_title": "PR_TITLE",
"ssh_url": "git@github.com:graphql-hive/router.git",
"stargazers_count": 81,
"stargazers_url": "https://api.github.com/repos/graphql-hive/router/stargazers",
"statuses_url": "https://api.github.com/repos/graphql-hive/router/statuses/{sha}",
"subscribers_url": "https://api.github.com/repos/graphql-hive/router/subscribers",
"subscription_url": "https://api.github.com/repos/graphql-hive/router/subscription",
"svn_url": "https://github.com/graphql-hive/router",
"tags_url": "https://api.github.com/repos/graphql-hive/router/tags",
"teams_url": "https://api.github.com/repos/graphql-hive/router/teams",
"topics": [
"apollo-federation",
"federation",
"federation-gateway",
"graphql",
"graphql-federation",
"router"
],
"trees_url": "https://api.github.com/repos/graphql-hive/router/git/trees{/sha}",
"updated_at": "2026-04-03T14:14:41Z",
"url": "https://api.github.com/repos/graphql-hive/router",
"use_squash_pr_title_as_default": true,
"visibility": "public",
"watchers": 81,
"watchers_count": 81,
"web_commit_signoff_required": false
},
"sha": "b4ea23158161f09757110bbc2bbe6d4a8bab5092",
"user": {
"avatar_url": "https://avatars.githubusercontent.com/u/182742256?v=4",
"events_url": "https://api.github.com/users/graphql-hive/events{/privacy}",
"followers_url": "https://api.github.com/users/graphql-hive/followers",
"following_url": "https://api.github.com/users/graphql-hive/following{/other_user}",
"gists_url": "https://api.github.com/users/graphql-hive/gists{/gist_id}",
"gravatar_id": "",
"html_url": "https://github.com/graphql-hive",
"id": 182742256,
"login": "graphql-hive",
"node_id": "O_kgDOCuRs8A",
"organizations_url": "https://api.github.com/users/graphql-hive/orgs",
"received_events_url": "https://api.github.com/users/graphql-hive/received_events",
"repos_url": "https://api.github.com/users/graphql-hive/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/graphql-hive/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/graphql-hive/subscriptions",
"type": "Organization",
"url": "https://api.github.com/users/graphql-hive",
"user_view_type": "public"
}
},
"body": "This is a stacked PR based on #620.\r\n\r\nThis PR adds subscription deduplication at the router level - the same mechanism that already existed for queries now also works for subscriptions (both HTTP streaming and WebSocket). It also introduces a global limit on concurrent long-lived clients (WebSocket connections and HTTP streaming responses), and cleans up naming throughout the codebase to better distinguish the two separate kinds of subscription tracking that now coexist.\r\n\r\n# Two kinds of subscriptions\r\n\r\nIt is worth being explicit about this because the codebase now has two distinct subscription concepts and they should not be confused.\r\n\r\n**Inbound subscriptions** (`ActiveSubscriptions`) are subscriptions from clients to the router. Each one represents a client that is currently connected and receiving events. This is the new registry introduced in this PR.\r\n\r\n**Outbound subscriptions** (`CallbackSubscriptions`, `WsSubgraphExecutor`) are subscriptions the router opens toward subgraphs in the background, either over HTTP callback protocol or WebSocket. These are invisible to the client - they are the upstream data sources that feed events into the router. Previously these were also called \"active subscriptions\" which made things ambiguous. They are now consistently named with the `callback_` prefix or the `ws_` prefix to make clear they are subgraph-facing, not client-facing.\r\n\r\n# What changed\r\n\r\n## Subscriptions are now deduplicated\r\n\r\nSubscriptions use the same fingerprint key as queries (method + path + selected headers + schema checksum + normalized operation hash + variables + extensions) and share the same in-flight map. The dedup mechanism is fundamentally different though because subscriptions are long-lived streams, not one-shot responses.\r\n\r\nThe first client (the leader) starts an upstream subscription and its events are pumped into a `tokio::broadcast` channel registered in the `ActiveSubscriptions` registry. Every subsequent client that arrives with the same fingerprint while that subscription is still active subscribes to the same broadcast channel instead of opening a new upstream connection. When the upstream finishes (or all listeners drop), the `ProducerHandle` drops, which removes the entry from both the registry and the in-flight map. The next client then starts a fresh upstream.\r\n\r\nThis means N clients subscribing to the same query with the same variables against the same schema version result in exactly one upstream subgraph connection.\r\n\r\n## Transport-agnostic subscriptions deduplication\r\n\r\nSubscriptions deduplication is completely transport-agnostic. HTTP streams and WebSocket requests share the same fingerprint space and the same in-flight map. This works for both queries and subscriptions.\r\n\r\nThe key enabler is that WebSocket subscribe messages are processed through a synthetic request - when a subscribe WebSocket message arrives, a synthetic `POST` is assembled using the WebSocket path and the connection's headers (filtered by the dedupe header policy). This synthetic request goes through the exact same `execute_planned_request` path as a real HTTP request, producing the same fingerprint. As a result:\r\n\r\n- A subscription started over HTTP and an identical one over WebSocket deduplicate against each other and share a single upstream connection\r\n- A query sent over WebSocket deduplicates with the same query sent over HTTP\r\n\r\nNo special casing is needed for transport anywhere in the dedup logic.\r\n\r\nOne important nuance: `accept` and other transport-specific headers (`connection: upgrade` for WebSockets) are part of the fingerprint when the `headers` dedupe config is set to `all` (the default). Since HTTP streaming clients send `accept: text/event-stream` or `accept: multipart/mixed` while WebSocket clients do not send an `accept` header at all, those requests will produce different fingerprints and will not deduplicate against each other by default. To get true cross-transport deduplication - where a WebSocket subscription and an SSE subscription with the same operation share one upstream connection and the events fan out to both - configure `headers: none` or explicitly exclude the transport-specific headers from the key:\r\n\r\n```yaml\r\ntraffic_shaping:\r\n router:\r\n dedupe:\r\n enabled: true\r\n headers: none\r\n```\r\n\r\nor with an explicit include list that omits transport-specific headers:\r\n\r\n```yaml\r\ntraffic_shaping:\r\n router:\r\n dedupe:\r\n enabled: true\r\n headers:\r\n include:\r\n - authorization\r\n - x-tenant-id\r\n```\r\n\r\n## Single `execute_planned_request` for all transports\r\n\r\nBefore this PR, the WebSocket handler had its own copy of the entire execution pipeline - JWT validation, variable coercion, client request details assembly, plan execution, usage reporting - all duplicated from the HTTP handler. This was the main reason WebSocket subscriptions could not participate in deduplication.\r\n\r\nNow both HTTP and WebSocket handlers call a single `execute_planned_request` function. The WebSocket handler constructs a synthetic `Method`, `Uri`, and `HeaderMap` from the WebSocket connection context and passes those in. The function has no knowledge of the transport - it just sees a method, URL, and headers, same as if it came from HTTP. This eliminates the duplication and is what makes transport-agnostic dedup possible.\r\n\r\n## The caller-owned `InFlightCleanupGuard`\r\n\r\nInflight map gains a new method `get_or_try_init_with_guard` where the guard is handed to the init closure as an argument. It is used only for subscriptions, and there - it is moved into the upstream pump task and lives as long as the stream, so joiners can find the entry and connect to the broadcast channel.\r\n\r\n### Why not change `get_or_try_init`?\r\n\r\nPassing the guard into init also for queries and dropping it when init resolves introduced a 2x performance regression. I just couldn't figure out why. If you know, please share ([browse code](https://github.com/graphql-hive/router/tree/0329884fadc3391d764d73e1a3d9a4f8b4aa67c3) | [see 2x regression](https://github.com/graphql-hive/router/actions/runs/23985118763/job/69955716903)).\r\n\r\n### No clones in `InFlightMap`\r\n\r\nThe inflight map's `get_or_try_init` previously cloned `key` and `map` unconditionally on every call (both leader and joiner paths) so they could be captured by the closure. By destructuring self upfront into its three fields, all three can be moved independently - `cell` directly into the `get_or_try_init` call, `key` and `map` directly into the guard inside the closure - eliminating all clones on this path.\r\n\r\n## Active subscriptions registry\r\n\r\nA new `ActiveSubscriptions` registry sits in shared router state. Every active upstream subscription is registered there. The registry holds the broadcast sender for each subscription identified by a [ULID](https://github.com/ulid/spec). `ProducerHandle` is a RAII wrapper: when it drops, it removes the entry from the registry and drops the in-flight cleanup guard.\r\n\r\nThe registry is also the mechanism for graceful shutdown on schema reload: when a new supergraph is loaded, all active subscriptions are closed with a `SUBSCRIPTION_SCHEMA_RELOAD` error before the schema is swapped in. Clients receive this as a final error event and are expected to reconnect. This is the same error code Apollo Router uses.\r\n\r\n## `SharedRouterResponse` is now an enum\r\n\r\nBefore this PR `SharedRouterResponse` only represented a single buffered response body. Streaming responses bypassed it entirely and were returned as a separate `PlannedResponse::Direct` variant (from #620) that could not participate in the dedup path.\r\n\r\nNow `SharedRouterResponse` is an enum with two variants:\r\n\r\n- `Single` - a buffered body with status code and headers\r\n- `Stream` - a broadcast sender, the pre-subscribed leader receiver, the stream content type, and headers\r\n\r\nThis unification means the whole pipeline collapses to a single `execute_planned_request` call for all transports and both operation types. The `PlannedResponse` enum is gone. Error count, usage reporting, and metric writing all work through the enum regardless of response type.\r\n\r\n## WebSocket subscription loop is now spawned\r\n\r\nPreviously the WebSocket frame handler drove the subscription event loop inline using \"select\" to race the upstream `BoxStream` against a cancel channel. This worked because the frame handler owned the stream directly - it could poll it and still break out on cancel.\r\n\r\nNow the upstream is no longer held by the frame handler. `execute_planned_request` spawns a pump task that drains the upstream into a broadcast channel before returning. By the time the frame handler gets back a `SharedRouterResponse::Stream` it only has a broadcast receiver, not the stream itself. If the subscription loop then runs inline, the frame handler blocks on `receiver.recv()` and never reads another frame from the client - so a `ClientMessage::Complete` sent by the client sits unread in the WebSocket buffer forever and cancellation is broken.\r\n\r\nThe fix is to spawn the subscription loop as a separate task. The frame handler returns immediately and remains free to process incoming frames. When it eventually reads a `Complete`, it sends on the cancel channel which the spawned loop is selecting on. `ServerMessage::complete` is sent from inside the spawned task once the loop exits.\r\n\r\n## Long-lived client limit\r\n\r\nA new middleware counts active WebSocket and HTTP streaming connections and rejects new ones with `503 Service Unavailable` + `Retry-After: 5` once the limit is reached. It is entirely branch-free on the hot path for non-streaming requests: the `enabled` flag is resolved once at app construction, and the streaming check uses cheap header lookups with a fast substring pre-filter before falling back to full Accept header parsing.\r\n\r\nThe limit defaults to 128 and only activates when at least one of WebSocket or Subscriptions is enabled and the limit is greater than zero. Configurable via `traffic_shaping.router.max_long_lived_clients`:\r\n\r\n```yaml\r\ntraffic_shaping:\r\n router:\r\n max_long_lived_clients: 256\r\n```\r\n\r\n## Naming cleanup\r\n\r\nThe HTTP callback subscription infrastructure was renamed throughout to include the word `callback` so it is clearly distinct from the new inbound active subscriptions registry:\r\n\r\n- `ActiveSubscription` -> `CallbackSubscription`\r\n- `ActiveSubscriptionsMap` -> `CallbackSubscriptionsMap`\r\n- `active_callback_subscriptions` field -> `callback_subscriptions`\r\n- `HeartbeatEnforcerTask` -> `CallbackHeartbeatEnforcerTask`\r\n\r\n## ID generation switched to ULID\r\n\r\nSubscription IDs in both the active subscriptions registry and the HTTP callback executor now use ULIDs instead of UUID v4. The HTTP callback protocol does not mandate any particular ID format - the spec only requires that the subscription ID and verifier are strings agreed upon between the router and the subgraph, so we are free to use whatever we want. ULID was chosen because it is [extremely fast to generate](https://github.com/dylanhart/ulid-rs).\r\n\r\n## Broadcast capacity is configurable\r\n\r\nThe broadcast channel buffer size is configurable via `subscriptions.broadcast_capacity` (default 32). When a consumer falls behind and the buffer fills up, it skips missed messages and continues from the latest available event - there is no error, just a trace log. This is intentional: the alternative (blocking the pump) would stall all other consumers, or dropping the client would be destructive (maybe it recovers).\r\n\r\n```yaml\r\nsubscriptions:\r\n enabled: true\r\n broadcast_capacity: 64\r\n```\r\n\r\nThe internal mpsc buffers between the subgraph executor and the pump task are reduced from 256 to 16 now that back-pressure is handled by the broadcast channel's own buffer.\r\n\r\n# Open questions\r\n\r\n**Do we actually want cross-transport deduplication?** The mechanism supports it, but it means a WebSocket client and an SSE client end up sharing the same broadcast channel and receiving the same raw event bytes. The bytes are valid for both because subscription events are just JSON, but it is worth deciding whether this is intentional behavior or an accidental side effect of the unified pipeline.\r\n\r\n**Should single responses deduplicate across transports too?** The body is always JSON so it fits either transport. The awkward part is the status code and the `content-type` headers. For example, if one client accepts `application/json` and another `application/graphql-response+json` - they must get matching content types respectivelly. This might be an issue when the user excludes `accept` from deduplication - but its rare to have many parallel different transports - so its up for debate.\r\n\r\n# Gotchas\r\n\r\n**Late joiners do not get replayed events.** A client that joins an already-running subscription receives events from the moment it subscribes onward. There is no replay of events already delivered to earlier clients. The promotion e2e test explicitly asserts this behavior.\r\n\r\n**The leader receiver is pre-subscribed before the pump spawns.** The pump is spawned after the broadcast channel is created, but the leader's receiver is taken before the spawn. This closes the window where the channel could have zero receivers between spawn and the first consumer subscribing, which would cause events to be silently dropped by tokio's broadcast channel.\r\n\r\n**No outbound subscriptions deduplication.** Often enough users emit an initial event on subscribe allowing clients to sync their state on subscribe. If we were to deduplicate outbound subscriptions (those going to subgraphs), clients would miss the initial events and their state could go out of sync. Maybe we can have an option? No, because subgraph teams are separate to gateway teams, and tracking down why an initial event is not propagated is time wasted.\r\n\r\n# TODOs\r\n\r\n- [x] Performance regression (Read \"Why not change get_or_try_init?\" section)\r\n- [x] Test max_long_lived_clients limits\r\n- [ ] Test supergraph schema reload kickoff\r\n- [ ] Can we buffer 1 the subgraph executor of http callbacks and websockets because the active subscriptions broadcaster handles backpressure?\r\n- [ ] Document\r\n",
"changed_files": 21,
"closed_at": null,
"comments": 1,
"comments_url": "https://api.github.com/repos/graphql-hive/router/issues/895/comments",
"commits": 42,
"commits_url": "https://api.github.com/repos/graphql-hive/router/pulls/895/commits",
"created_at": "2026-04-04T00:21:26Z",
"deletions": 409,
"diff_url": "https://github.com/graphql-hive/router/pull/895.diff",
"draft": false,
"head": {
"label": "graphql-hive:not-kamil-subs-single-subs",
"ref": "not-kamil-subs-single-subs",
"repo": {
"allow_auto_merge": false,
"allow_forking": true,
"allow_merge_commit": false,
"allow_rebase_merge": false,
"allow_squash_merge": true,
"allow_update_branch": true,
"archive_url": "https://api.github.com/repos/graphql-hive/router/{archive_format}{/ref}",
"archived": false,
"assignees_url": "https://api.github.com/repos/graphql-hive/router/assignees{/user}",
"blobs_url": "https://api.github.com/repos/graphql-hive/router/git/blobs{/sha}",
"branches_url": "https://api.github.com/repos/graphql-hive/router/branches{/branch}",
"clone_url": "https://github.com/graphql-hive/router.git",
"collaborators_url": "https://api.github.com/repos/graphql-hive/router/collaborators{/collaborator}",
"comments_url": "https://api.github.com/repos/graphql-hive/router/comments{/number}",
"commits_url": "https://api.github.com/repos/graphql-hive/router/commits{/sha}",
"compare_url": "https://api.github.com/repos/graphql-hive/router/compare/{base}...{head}",
"contents_url": "https://api.github.com/repos/graphql-hive/router/contents/{+path}",
"contributors_url": "https://api.github.com/repos/graphql-hive/router/contributors",
"created_at": "2024-11-20T16:16:12Z",
"default_branch": "main",
"delete_branch_on_merge": true,
"deployments_url": "https://api.github.com/repos/graphql-hive/router/deployments",
"description": "Open-source (MIT) GraphQL Federation Router. Built with Rust for maximum performance and robustness.",
"disabled": false,
"downloads_url": "https://api.github.com/repos/graphql-hive/router/downloads",
"events_url": "https://api.github.com/repos/graphql-hive/router/events",
"fork": false,
"forks": 9,
"forks_count": 9,
"forks_url": "https://api.github.com/repos/graphql-hive/router/forks",
"full_name": "graphql-hive/router",
"git_commits_url": "https://api.github.com/repos/graphql-hive/router/git/commits{/sha}",
"git_refs_url": "https://api.github.com/repos/graphql-hive/router/git/refs{/sha}",
"git_tags_url": "https://api.github.com/repos/graphql-hive/router/git/tags{/sha}",
"git_url": "git://github.com/graphql-hive/router.git",
"has_discussions": false,
"has_downloads": true,
"has_issues": true,
"has_pages": false,
"has_projects": false,
"has_pull_requests": true,
"has_wiki": false,
"homepage": "https://the-guild.dev/graphql/hive/docs/router",
"hooks_url": "https://api.github.com/repos/graphql-hive/router/hooks",
"html_url": "https://github.com/graphql-hive/router",
"id": 891604244,
"is_template": false,
"issue_comment_url": "https://api.github.com/repos/graphql-hive/router/issues/comments{/number}",
"issue_events_url": "https://api.github.com/repos/graphql-hive/router/issues/events{/number}",
"issues_url": "https://api.github.com/repos/graphql-hive/router/issues{/number}",
"keys_url": "https://api.github.com/repos/graphql-hive/router/keys{/key_id}",
"labels_url": "https://api.github.com/repos/graphql-hive/router/labels{/name}",
"language": "Rust",
"languages_url": "https://api.github.com/repos/graphql-hive/router/languages",
"license": {
"key": "mit",
"name": "MIT License",
"node_id": "MDc6TGljZW5zZTEz",
"spdx_id": "MIT",
"url": "https://api.github.com/licenses/mit"
},
"merge_commit_message": "PR_TITLE",
"merge_commit_title": "MERGE_MESSAGE",
"merges_url": "https://api.github.com/repos/graphql-hive/router/merges",
"milestones_url": "https://api.github.com/repos/graphql-hive/router/milestones{/number}",
"mirror_url": null,
"name": "router",
"node_id": "R_kgDONSTNFA",
"notifications_url": "https://api.github.com/repos/graphql-hive/router/notifications{?since,all,participating}",
"open_issues": 64,
"open_issues_count": 64,
"owner": {
"avatar_url": "https://avatars.githubusercontent.com/u/182742256?v=4",
"events_url": "https://api.github.com/users/graphql-hive/events{/privacy}",
"followers_url": "https://api.github.com/users/graphql-hive/followers",
"following_url": "https://api.github.com/users/graphql-hive/following{/other_user}",
"gists_url": "https://api.github.com/users/graphql-hive/gists{/gist_id}",
"gravatar_id": "",
"html_url": "https://github.com/graphql-hive",
"id": 182742256,
"login": "graphql-hive",
"node_id": "O_kgDOCuRs8A",
"organizations_url": "https://api.github.com/users/graphql-hive/orgs",
"received_events_url": "https://api.github.com/users/graphql-hive/received_events",
"repos_url": "https://api.github.com/users/graphql-hive/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/graphql-hive/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/graphql-hive/subscriptions",
"type": "Organization",
"url": "https://api.github.com/users/graphql-hive",
"user_view_type": "public"
},
"private": false,
"pull_request_creation_policy": "all",
"pulls_url": "https://api.github.com/repos/graphql-hive/router/pulls{/number}",
"pushed_at": "2026-04-04T20:59:14Z",
"releases_url": "https://api.github.com/repos/graphql-hive/router/releases{/id}",
"size": 6185,
"squash_merge_commit_message": "PR_BODY",
"squash_merge_commit_title": "PR_TITLE",
"ssh_url": "git@github.com:graphql-hive/router.git",
"stargazers_count": 81,
"stargazers_url": "https://api.github.com/repos/graphql-hive/router/stargazers",
"statuses_url": "https://api.github.com/repos/graphql-hive/router/statuses/{sha}",
"subscribers_url": "https://api.github.com/repos/graphql-hive/router/subscribers",
"subscription_url": "https://api.github.com/repos/graphql-hive/router/subscription",
"svn_url": "https://github.com/graphql-hive/router",
"tags_url": "https://api.github.com/repos/graphql-hive/router/tags",
"teams_url": "https://api.github.com/repos/graphql-hive/router/teams",
"topics": [
"apollo-federation",
"federation",
"federation-gateway",
"graphql",
"graphql-federation",
"router"
],
"trees_url": "https://api.github.com/repos/graphql-hive/router/git/trees{/sha}",
"updated_at": "2026-04-03T14:14:41Z",
"url": "https://api.github.com/repos/graphql-hive/router",
"use_squash_pr_title_as_default": true,
"visibility": "public",
"watchers": 81,
"watchers_count": 81,
"web_commit_signoff_required": false
},
"sha": "8314410f8a17429289e0146ce4277dadba14507f",
"user": {
"avatar_url": "https://avatars.githubusercontent.com/u/182742256?v=4",
"events_url": "https://api.github.com/users/graphql-hive/events{/privacy}",
"followers_url": "https://api.github.com/users/graphql-hive/followers",
"following_url": "https://api.github.com/users/graphql-hive/following{/other_user}",
"gists_url": "https://api.github.com/users/graphql-hive/gists{/gist_id}",
"gravatar_id": "",
"html_url": "https://github.com/graphql-hive",
"id": 182742256,
"login": "graphql-hive",
"node_id": "O_kgDOCuRs8A",
"organizations_url": "https://api.github.com/users/graphql-hive/orgs",
"received_events_url": "https://api.github.com/users/graphql-hive/received_events",
"repos_url": "https://api.github.com/users/graphql-hive/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/graphql-hive/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/graphql-hive/subscriptions",
"type": "Organization",
"url": "https://api.github.com/users/graphql-hive",
"user_view_type": "public"
}
},
"html_url": "https://github.com/graphql-hive/router/pull/895",
"id": 3487910106,
"issue_url": "https://api.github.com/repos/graphql-hive/router/issues/895",
"labels": [],
"locked": false,
"maintainer_can_modify": false,
"merge_commit_sha": "cfe356494bbea4d296b4b84da4368dae1ceaec59",
"mergeable": null,
"mergeable_state": "unknown",
"merged": false,
"merged_at": null,
"merged_by": null,
"milestone": null,
"node_id": "PR_kwDONSTNFM7P5Uja",
"number": 895,
"patch_url": "https://github.com/graphql-hive/router/pull/895.patch",
"rebaseable": null,
"requested_reviewers": [],
"requested_teams": [],
"review_comment_url": "https://api.github.com/repos/graphql-hive/router/pulls/comments{/number}",
"review_comments": 2,
"review_comments_url": "https://api.github.com/repos/graphql-hive/router/pulls/895/comments",
"state": "open",
"statuses_url": "https://api.github.com/repos/graphql-hive/router/statuses/8314410f8a17429289e0146ce4277dadba14507f",
"title": "Subscription deduplication, active subscriptions registry and limits",
"updated_at": "2026-04-04T20:59:16Z",
"url": "https://api.github.com/repos/graphql-hive/router/pulls/895",
"user": {
"avatar_url": "https://avatars.githubusercontent.com/u/11807600?v=4",
"events_url": "https://api.github.com/users/enisdenjo/events{/privacy}",
"followers_url": "https://api.github.com/users/enisdenjo/followers",
"following_url": "https://api.github.com/users/enisdenjo/following{/other_user}",
"gists_url": "https://api.github.com/users/enisdenjo/gists{/gist_id}",
"gravatar_id": "",
"html_url": "https://github.com/enisdenjo",
"id": 11807600,
"login": "enisdenjo",
"node_id": "MDQ6VXNlcjExODA3NjAw",
"organizations_url": "https://api.github.com/users/enisdenjo/orgs",
"received_events_url": "https://api.github.com/users/enisdenjo/received_events",
"repos_url": "https://api.github.com/users/enisdenjo/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/enisdenjo/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/enisdenjo/subscriptions",
"type": "User",
"url": "https://api.github.com/users/enisdenjo",
"user_view_type": "public"
}
},
"repository": {
"allow_forking": true,
"archive_url": "https://api.github.com/repos/graphql-hive/router/{archive_format}{/ref}",
"archived": false,
"assignees_url": "https://api.github.com/repos/graphql-hive/router/assignees{/user}",
"blobs_url": "https://api.github.com/repos/graphql-hive/router/git/blobs{/sha}",
"branches_url": "https://api.github.com/repos/graphql-hive/router/branches{/branch}",
"clone_url": "https://github.com/graphql-hive/router.git",
"collaborators_url": "https://api.github.com/repos/graphql-hive/router/collaborators{/collaborator}",
"comments_url": "https://api.github.com/repos/graphql-hive/router/comments{/number}",
"commits_url": "https://api.github.com/repos/graphql-hive/router/commits{/sha}",
"compare_url": "https://api.github.com/repos/graphql-hive/router/compare/{base}...{head}",
"contents_url": "https://api.github.com/repos/graphql-hive/router/contents/{+path}",
"contributors_url": "https://api.github.com/repos/graphql-hive/router/contributors",
"created_at": "2024-11-20T16:16:12Z",
"custom_properties": {
"vanta_production_branch_name": "main"
},
"default_branch": "main",
"deployments_url": "https://api.github.com/repos/graphql-hive/router/deployments",
"description": "Open-source (MIT) GraphQL Federation Router. Built with Rust for maximum performance and robustness.",
"disabled": false,
"downloads_url": "https://api.github.com/repos/graphql-hive/router/downloads",
"events_url": "https://api.github.com/repos/graphql-hive/router/events",
"fork": false,
"forks": 9,
"forks_count": 9,
"forks_url": "https://api.github.com/repos/graphql-hive/router/forks",
"full_name": "graphql-hive/router",
"git_commits_url": "https://api.github.com/repos/graphql-hive/router/git/commits{/sha}",
"git_refs_url": "https://api.github.com/repos/graphql-hive/router/git/refs{/sha}",
"git_tags_url": "https://api.github.com/repos/graphql-hive/router/git/tags{/sha}",
"git_url": "git://github.com/graphql-hive/router.git",
"has_discussions": false,
"has_downloads": true,
"has_issues": true,
"has_pages": false,
"has_projects": false,
"has_pull_requests": true,
"has_wiki": false,
"homepage": "https://the-guild.dev/graphql/hive/docs/router",
"hooks_url": "https://api.github.com/repos/graphql-hive/router/hooks",
"html_url": "https://github.com/graphql-hive/router",
"id": 891604244,
"is_template": false,
"issue_comment_url": "https://api.github.com/repos/graphql-hive/router/issues/comments{/number}",
"issue_events_url": "https://api.github.com/repos/graphql-hive/router/issues/events{/number}",
"issues_url": "https://api.github.com/repos/graphql-hive/router/issues{/number}",
"keys_url": "https://api.github.com/repos/graphql-hive/router/keys{/key_id}",
"labels_url": "https://api.github.com/repos/graphql-hive/router/labels{/name}",
"language": "Rust",
"languages_url": "https://api.github.com/repos/graphql-hive/router/languages",
"license": {
"key": "mit",
"name": "MIT License",
"node_id": "MDc6TGljZW5zZTEz",
"spdx_id": "MIT",
"url": "https://api.github.com/licenses/mit"
},
"merges_url": "https://api.github.com/repos/graphql-hive/router/merges",
"milestones_url": "https://api.github.com/repos/graphql-hive/router/milestones{/number}",
"mirror_url": null,
"name": "router",
"node_id": "R_kgDONSTNFA",
"notifications_url": "https://api.github.com/repos/graphql-hive/router/notifications{?since,all,participating}",
"open_issues": 64,
"open_issues_count": 64,
"owner": {
"avatar_url": "https://avatars.githubusercontent.com/u/182742256?v=4",
"events_url": "https://api.github.com/users/graphql-hive/events{/privacy}",
"followers_url": "https://api.github.com/users/graphql-hive/followers",
"following_url": "https://api.github.com/users/graphql-hive/following{/other_user}",
"gists_url": "https://api.github.com/users/graphql-hive/gists{/gist_id}",
"gravatar_id": "",
"html_url": "https://github.com/graphql-hive",
"id": 182742256,
"login": "graphql-hive",
"node_id": "O_kgDOCuRs8A",
"organizations_url": "https://api.github.com/users/graphql-hive/orgs",
"received_events_url": "https://api.github.com/users/graphql-hive/received_events",
"repos_url": "https://api.github.com/users/graphql-hive/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/graphql-hive/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/graphql-hive/subscriptions",
"type": "Organization",
"url": "https://api.github.com/users/graphql-hive",
"user_view_type": "public"
},
"private": false,
"pull_request_creation_policy": "all",
"pulls_url": "https://api.github.com/repos/graphql-hive/router/pulls{/number}",
"pushed_at": "2026-04-04T20:59:14Z",
"releases_url": "https://api.github.com/repos/graphql-hive/router/releases{/id}",
"size": 6185,
"ssh_url": "git@github.com:graphql-hive/router.git",
"stargazers_count": 81,
"stargazers_url": "https://api.github.com/repos/graphql-hive/router/stargazers",
"statuses_url": "https://api.github.com/repos/graphql-hive/router/statuses/{sha}",
"subscribers_url": "https://api.github.com/repos/graphql-hive/router/subscribers",
"subscription_url": "https://api.github.com/repos/graphql-hive/router/subscription",
"svn_url": "https://github.com/graphql-hive/router",
"tags_url": "https://api.github.com/repos/graphql-hive/router/tags",
"teams_url": "https://api.github.com/repos/graphql-hive/router/teams",
"topics": [
"apollo-federation",
"federation",
"federation-gateway",
"graphql",
"graphql-federation",
"router"
],
"trees_url": "https://api.github.com/repos/graphql-hive/router/git/trees{/sha}",
"updated_at": "2026-04-03T14:14:41Z",
"url": "https://api.github.com/repos/graphql-hive/router",
"visibility": "public",
"watchers": 81,
"watchers_count": 81,
"web_commit_signoff_required": false
},
"sender": {
"avatar_url": "https://avatars.githubusercontent.com/u/11807600?v=4",
"events_url": "https://api.github.com/users/enisdenjo/events{/privacy}",
"followers_url": "https://api.github.com/users/enisdenjo/followers",
"following_url": "https://api.github.com/users/enisdenjo/following{/other_user}",
"gists_url": "https://api.github.com/users/enisdenjo/gists{/gist_id}",
"gravatar_id": "",
"html_url": "https://github.com/enisdenjo",
"id": 11807600,
"login": "enisdenjo",
"node_id": "MDQ6VXNlcjExODA3NjAw",
"organizations_url": "https://api.github.com/users/enisdenjo/orgs",
"received_events_url": "https://api.github.com/users/enisdenjo/received_events",
"repos_url": "https://api.github.com/users/enisdenjo/repos",
"site_admin": false,
"starred_url": "https://api.github.com/users/enisdenjo/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/enisdenjo/subscriptions",
"type": "User",
"url": "https://api.github.com/users/enisdenjo",
"user_view_type": "public"
}
},
"github_job": "docker",
"github_ref": "refs/pull/895/merge",
"github_ref_name": "895/merge",
"github_ref_protected": "false",
"github_ref_type": "branch",
"github_repository": "graphql-hive/router",
"github_repository_id": "891604244",
"github_repository_owner": "graphql-hive",
"github_repository_owner_id": "182742256",
"github_run_attempt": "1",
"github_run_id": "23987464339",
"github_run_number": "2206",
"github_runner_arch": "X64",
"github_runner_environment": "github-hosted",
"github_runner_image_os": "ubuntu24",
"github_runner_image_version": "20260329.72.1",
"github_runner_name": "GitHub Actions 1000718830",
"github_runner_os": "Linux",
"github_runner_tracking_id": "github_eb84de7f-bc43-4bd7-b2a1-2ef75656989f",
"github_server_url": "https://github.com",
"github_triggering_actor": "enisdenjo",
"github_workflow": "build-router",
"github_workflow_ref": "graphql-hive/router/.github/workflows/build-router.yaml@refs/pull/895/merge",
"github_workflow_sha": "250e6da9cda80b809f12c473f838b657d6e817dd",
"platform": "linux/amd64"
}
}
},
"buildx.build.ref": "builder-f1218bd5-b6f5-4568-8446-b12b27ae0126/builder-f1218bd5-b6f5-4568-8446-b12b27ae01260/y5fawymevtpqz4a9wwrq0voqz",
"containerimage.descriptor": {
"mediaType": "application/vnd.oci.image.index.v1+json",
"digest": "sha256:ef5fdc52290c2ed3e9563cfa1edd888b60a33c396a862a40082ec55c1fd1ada1",
"size": 1609
},
"containerimage.digest": "sha256:ef5fdc52290c2ed3e9563cfa1edd888b60a33c396a862a40082ec55c1fd1ada1",
"image.name": "ghcr.io/graphql-hive/router:pr-895,ghcr.io/graphql-hive/router:sha-250e6da"
} |
This is a stacked PR based on #620.
This PR adds subscription deduplication at the router level - the same mechanism that already existed for queries now also works for subscriptions (both HTTP streaming and WebSocket). It also introduces a global limit on concurrent long-lived clients (WebSocket connections and HTTP streaming responses), and cleans up naming throughout the codebase to better distinguish the two separate kinds of subscription tracking that now coexist.
Two kinds of subscriptions
It is worth being explicit about this because the codebase now has two distinct subscription concepts and they should not be confused.
Inbound subscriptions (
ActiveSubscriptions) are subscriptions from clients to the router. Each one represents a client that is currently connected and receiving events. This is the new registry introduced in this PR.Outbound subscriptions (
CallbackSubscriptions,WsSubgraphExecutor) are subscriptions the router opens toward subgraphs in the background, either over HTTP callback protocol or WebSocket. These are invisible to the client - they are the upstream data sources that feed events into the router. Previously these were also called "active subscriptions" which made things ambiguous. They are now consistently named with thecallback_prefix or thews_prefix to make clear they are subgraph-facing, not client-facing.What changed
Subscriptions are now deduplicated
Subscriptions use the same fingerprint key as queries (method + path + selected headers + schema checksum + normalized operation hash + variables + extensions) and share the same in-flight map. The dedup mechanism is fundamentally different though because subscriptions are long-lived streams, not one-shot responses.
The first client (the leader) starts an upstream subscription and its events are pumped into a
tokio::broadcastchannel registered in theActiveSubscriptionsregistry. Every subsequent client that arrives with the same fingerprint while that subscription is still active subscribes to the same broadcast channel instead of opening a new upstream connection. When the upstream finishes (or all listeners drop), theProducerHandledrops, which removes the entry from both the registry and the in-flight map. The next client then starts a fresh upstream.This means N clients subscribing to the same query with the same variables against the same schema version result in exactly one upstream subgraph connection.
Transport-agnostic subscriptions deduplication
Subscriptions deduplication is completely transport-agnostic. HTTP streams and WebSocket requests share the same fingerprint space and the same in-flight map. This works for both queries and subscriptions.
The key enabler is that WebSocket subscribe messages are processed through a synthetic request - when a subscribe WebSocket message arrives, a synthetic
POSTis assembled using the WebSocket path and the connection's headers (filtered by the dedupe header policy). This synthetic request goes through the exact sameexecute_planned_requestpath as a real HTTP request, producing the same fingerprint. As a result:No special casing is needed for transport anywhere in the dedup logic.
One important nuance:
acceptand other transport-specific headers (connection: upgradefor WebSockets) are part of the fingerprint when theheadersdedupe config is set toall(the default). Since HTTP streaming clients sendaccept: text/event-streamoraccept: multipart/mixedwhile WebSocket clients do not send anacceptheader at all, those requests will produce different fingerprints and will not deduplicate against each other by default. To get true cross-transport deduplication - where a WebSocket subscription and an SSE subscription with the same operation share one upstream connection and the events fan out to both - configureheaders: noneor explicitly exclude the transport-specific headers from the key:or with an explicit include list that omits transport-specific headers:
Single
execute_planned_requestfor all transportsBefore this PR, the WebSocket handler had its own copy of the entire execution pipeline - JWT validation, variable coercion, client request details assembly, plan execution, usage reporting - all duplicated from the HTTP handler. This was the main reason WebSocket subscriptions could not participate in deduplication.
Now both HTTP and WebSocket handlers call a single
execute_planned_requestfunction. The WebSocket handler constructs a syntheticMethod,Uri, andHeaderMapfrom the WebSocket connection context and passes those in. The function has no knowledge of the transport - it just sees a method, URL, and headers, same as if it came from HTTP. This eliminates the duplication and is what makes transport-agnostic dedup possible.The caller-owned
InFlightCleanupGuardInflight map gains a new method
get_or_try_init_with_guardwhere the guard is handed to the init closure as an argument. It is used only for subscriptions, and there - it is moved into the upstream pump task and lives as long as the stream, so joiners can find the entry and connect to the broadcast channel.Why not change
get_or_try_init?Passing the guard into init also for queries and dropping it when init resolves introduced a 2x performance regression. I just couldn't figure out why. If you know, please share (browse code | see 2x regression).
No clones in
InFlightMapThe inflight map's
get_or_try_initpreviously clonedkeyandmapunconditionally on every call (both leader and joiner paths) so they could be captured by the closure. By destructuring self upfront into its three fields, all three can be moved independently -celldirectly into theget_or_try_initcall,keyandmapdirectly into the guard inside the closure - eliminating all clones on this path.Active subscriptions registry
A new
ActiveSubscriptionsregistry sits in shared router state. Every active upstream subscription is registered there. The registry holds the broadcast sender for each subscription identified by a ULID.ProducerHandleis a RAII wrapper: when it drops, it removes the entry from the registry and drops the in-flight cleanup guard.The registry is also the mechanism for graceful shutdown on schema reload: when a new supergraph is loaded, all active subscriptions are closed with a
SUBSCRIPTION_SCHEMA_RELOADerror before the schema is swapped in. Clients receive this as a final error event and are expected to reconnect. This is the same error code Apollo Router uses.SharedRouterResponseis now an enumBefore this PR
SharedRouterResponseonly represented a single buffered response body. Streaming responses bypassed it entirely and were returned as a separatePlannedResponse::Directvariant (from #620) that could not participate in the dedup path.Now
SharedRouterResponseis an enum with two variants:Single- a buffered body with status code and headersStream- a broadcast sender, the pre-subscribed leader receiver, the stream content type, and headersThis unification means the whole pipeline collapses to a single
execute_planned_requestcall for all transports and both operation types. ThePlannedResponseenum is gone. Error count, usage reporting, and metric writing all work through the enum regardless of response type.WebSocket subscription loop is now spawned
Previously the WebSocket frame handler drove the subscription event loop inline using "select" to race the upstream
BoxStreamagainst a cancel channel. This worked because the frame handler owned the stream directly - it could poll it and still break out on cancel.Now the upstream is no longer held by the frame handler.
execute_planned_requestspawns a pump task that drains the upstream into a broadcast channel before returning. By the time the frame handler gets back aSharedRouterResponse::Streamit only has a broadcast receiver, not the stream itself. If the subscription loop then runs inline, the frame handler blocks onreceiver.recv()and never reads another frame from the client - so aClientMessage::Completesent by the client sits unread in the WebSocket buffer forever and cancellation is broken.The fix is to spawn the subscription loop as a separate task. The frame handler returns immediately and remains free to process incoming frames. When it eventually reads a
Complete, it sends on the cancel channel which the spawned loop is selecting on.ServerMessage::completeis sent from inside the spawned task once the loop exits.Long-lived client limit
A new middleware counts active WebSocket and HTTP streaming connections and rejects new ones with
503 Service Unavailable+Retry-After: 5once the limit is reached. It is entirely branch-free on the hot path for non-streaming requests: theenabledflag is resolved once at app construction, and the streaming check uses cheap header lookups with a fast substring pre-filter before falling back to full Accept header parsing.The limit defaults to 128 and only activates when at least one of WebSocket or Subscriptions is enabled and the limit is greater than zero. Configurable via
traffic_shaping.router.max_long_lived_clients:Naming cleanup
The HTTP callback subscription infrastructure was renamed throughout to include the word
callbackso it is clearly distinct from the new inbound active subscriptions registry:ActiveSubscription->CallbackSubscriptionActiveSubscriptionsMap->CallbackSubscriptionsMapactive_callback_subscriptionsfield ->callback_subscriptionsHeartbeatEnforcerTask->CallbackHeartbeatEnforcerTaskID generation switched to ULID
Subscription IDs in both the active subscriptions registry and the HTTP callback executor now use ULIDs instead of UUID v4. The HTTP callback protocol does not mandate any particular ID format - the spec only requires that the subscription ID and verifier are strings agreed upon between the router and the subgraph, so we are free to use whatever we want. ULID was chosen because it is extremely fast to generate.
Broadcast capacity is configurable
The broadcast channel buffer size is configurable via
subscriptions.broadcast_capacity(default 32). When a consumer falls behind and the buffer fills up, it skips missed messages and continues from the latest available event - there is no error, just a trace log. This is intentional: the alternative (blocking the pump) would stall all other consumers, or dropping the client would be destructive (maybe it recovers).The internal mpsc buffers between the subgraph executor and the pump task are reduced from 256 to 16 now that back-pressure is handled by the broadcast channel's own buffer.
Open questions
Do we actually want cross-transport deduplication? The mechanism supports it, but it means a WebSocket client and an SSE client end up sharing the same broadcast channel and receiving the same raw event bytes. The bytes are valid for both because subscription events are just JSON, but it is worth deciding whether this is intentional behavior or an accidental side effect of the unified pipeline.
Should single responses deduplicate across transports too? The body is always JSON so it fits either transport. The awkward part is the status code and the
content-typeheaders. For example, if one client acceptsapplication/jsonand anotherapplication/graphql-response+json- they must get matching content types respectivelly. This might be an issue when the user excludesacceptfrom deduplication - but its rare to have many parallel different transports - so its up for debate.Gotchas
Late joiners do not get replayed events. A client that joins an already-running subscription receives events from the moment it subscribes onward. There is no replay of events already delivered to earlier clients. The promotion e2e test explicitly asserts this behavior.
The leader receiver is pre-subscribed before the pump spawns. The pump is spawned after the broadcast channel is created, but the leader's receiver is taken before the spawn. This closes the window where the channel could have zero receivers between spawn and the first consumer subscribing, which would cause events to be silently dropped by tokio's broadcast channel.
No outbound subscriptions deduplication. Often enough users emit an initial event on subscribe allowing clients to sync their state on subscribe. If we were to deduplicate outbound subscriptions (those going to subgraphs), clients would miss the initial events and their state could go out of sync. Maybe we can have an option? No, because subgraph teams are separate to gateway teams, and tracking down why an initial event is not propagated is time wasted.
TODOs