Is your feature request related to a problem? Please describe.
Operators running Apollo Router behind a CDN want to propagate the router's response cache tags (apolloCacheTags for root fields, apolloEntityCacheTags for entities, and the resolved output of the @cacheTag directive) to the CDN as Cache-Tag or Surrogate-Key headers. This lets the CDN perform tag-based purging in coordination with the router's own cache.
Today this is not possible across the full request lifecycle. The relevant mechanics in response_cache:
- The plugin reads
apolloCacheTags and apolloEntityCacheTags from subgraph response extensions and writes them into Redis invalidation indexes (apollo-router/src/plugins/response_cache/plugin.rs:996, :1753). The plugin does not strip them from the response body.
- On cache miss, a coprocessor at the SubgraphResponse stage sees the extensions intact.
- On cache hit, the plugin constructs a fresh
subgraph::Response with extensions(Object::new()) and only Cache-Control replayed on the headers (root-field hit path at plugin.rs:1320, all-entities-from-cache short-circuit at plugin.rs:1625). The original subgraph extensions and headers are not retained on the stored entry, so they cannot be replayed.
- Partial entity merges return tags only for the freshly fetched entities; cached entities are returned without any tag context, since the cache entries do not carry their tag set.
A coprocessor at the subgraph stage also cannot reconstruct the full tag set across a single request. Consider the following partial-entity-merge scenario:
Request A fetches entities 1, 2, and 3 and stores their cache tags in Redis. Request B resolves to entities 2, 3, and 4. Entities 2 and 3 are served from cache, so the subgraph is only called for entity 4 and the subgraph response carries only the tag for entity 4. A coprocessor at the SubgraphResponse stage has no path to recover the tags from request A, because the cache entries for entities 2 and 3 do not carry them.
So coprocessor and rhai approaches address only the cache-miss path. The cache-hit and partial-hit paths are the cases that matter most for CDN integration, since CDN tagging is most valuable precisely when the router cache is warm.
Describe the solution you'd like
A three-part change to response_cache. Concrete proposal below, intentionally specific so it is easy to push back on. None of the names or shapes are load-bearing; they are starting points.
1. Persist the tag set on the cache entry.
Today the cached payload is effectively data + cache_control. Extend the cache entry to also store the union of apolloCacheTags, apolloEntityCacheTags, and resolved @cacheTag values applicable to that entry. The cache-hit response paths (plugin.rs:1320, plugin.rs:1625) then attach the stored tag set to the rebuilt response, so a hit response is indistinguishable in tag content from a miss response.
2. Accumulate tags across the supergraph response.
The router walks many subgraph calls per supergraph response, some hit and some miss, with per-entity hits and misses inside _entities calls. Accumulate the union of tags as a Context extension while walking the resolution tree. Each subgraph response (cache hit or miss) contributes its tag set to a single growing collection scoped to the request context.
3. Expose the aggregated tag set on the supergraph response.
Proposed default: a single configurable response header. Strawman config:
response_cache:
enabled: true
propagate_cache_tags:
enabled: false # opt-in; default off to keep existing behavior
header: "Cache-Tag" # configurable; common alternates: "Surrogate-Key" (Fastly), "Cache-Tags"
separator: "," # how to join multiple tags
max_bytes: 16384 # cap per CDN limits; truncate or drop with a warning
on_overflow: "truncate" # "truncate" | "drop" | "header_per_tag"
deduplicate: true # default true; same tag from multiple subgraphs counted once
The accumulated context entry should also be exposed to coprocessor and rhai (read-only) under a stable key, so customers with custom propagation needs (multiple headers, conditional emission, transformation) can opt out of the built-in header and consume the raw set themselves. Suggested key: apollo::response_cache::aggregated_cache_tags (Rust-style namespace mirrors existing Context keys in the codebase).
Default behavior choices in the proposal:
- Off by default. Adding a response header to every supergraph response is a behavior change that should be opt-in.
Cache-Tag as the default header name. It is the Cloudflare convention and shows up most often in customer integrations. Easy to swap.
- Comma separator, deduplication on by default. Aligns with how CDNs typically parse the header.
- 16 KB cap matching Cloudflare's documented limit. Other CDNs are similar.
- Empty aggregated set: suppress the header rather than emit it empty.
Describe alternatives you've considered
Coprocessor or rhai header propagation at the subgraph stage. Works on cache miss only. Cache entries do not carry tags, the rebuilt response only replays Cache-Control, and a coprocessor cannot reconstruct the cross-call tag union without reimplementing request hashing and federation logic. Not viable as a general solution.
Subgraph emits an HTTP Cache-Tag or Surrogate-Key header. Same limitation. Original subgraph headers are not preserved on the cached entry and are not replayed on hit.
Schema-resolved @cacheTag only. The directive resolves at request time from arguments and entity keys, but the resolved values are not exposed to coprocessor, rhai, or response headers today. This is a separate axis from programmatic tags returned via subgraph response extensions, so it does not address use cases whose tags depend on response data.
Debug-mode apolloCacheDebugging extension. With debug: true, response_cache injects an apolloCacheDebugging extension into the supergraph response body containing human-readable invalidationKeys. A coprocessor could in principle lift these onto a header. Not viable in production:
- Cache entries only carry these tags when the entry was written under debug mode, so the cache would need to be warmed and continuously refreshed under debug mode.
- The debug payload includes full subgraph requests, raw cached data, and warnings on every response. Significant overhead, not appropriate to run hot.
- The shape is intended for debugging, not for consumption by downstream systems.
Surface noted here for completeness; not a recommended path.
Additional context
Source references (verified against main at commit ae852ae6b, v2.14.1):
- Extension key constants:
apollo-router/src/plugins/response_cache/plugin.rs:100-101
- Cache-write reads of the tag extensions:
plugin.rs:996, plugin.rs:1753
- Root-field cache-hit response construction with empty extensions:
plugin.rs:1320
- All-entities-from-cache short-circuit with empty extensions:
plugin.rs:1625
- Errors-only assembly with empty extensions:
plugin.rs:1167
- Partial entity merge passes per-entity tags through but only for the fetched slice:
plugin.rs:1749-1783 (uses insert_entities_in_result at plugin.rs:2423)
- Plugin chain order showing coprocessor outside
response_cache for the subgraph stage: apollo-router/src/services/subgraph_service.rs:1116, with registration order in apollo-router/src/router_factory.rs:897-898
- Coprocessor body filter does not strip cache-tag extensions:
apollo-router/src/plugins/coprocessor/mod.rs:1708
Related issues:
Open design questions, with my current lean:
- Surface shape: I am proposing header-by-default with context-entry-also-available. Push back if you would prefer extensions-on-the-supergraph-response (cleaner from a GraphQL purist angle, but pollutes the response payload for clients), context-only (most flexible but pushes propagation work to every customer), or header-only (simplest, least flexible).
- Default header name:
Cache-Tag proposed. Surrogate-Key is the next most common. Open to whatever the router team thinks integrates best with the broader Apollo cache configuration vocabulary.
- Empty-set behavior: suppress the header rather than emit it empty. Alternative is emit-empty for client signal that the feature is on but produced no tags this request.
- Overflow policy: truncate with a structured log warning by default; alternates are drop-the-header or split-into-multiple-headers. Truncation is the lowest-surprise option.
- Interaction with
@cacheTag directive resolution: should resolved directive values flow into the same aggregated set, or be exposed separately? My proposal merges them, since downstream consumers do not need to distinguish the source.
- Naming of the context key:
apollo::response_cache::aggregated_cache_tags proposed; open to whatever fits existing conventions.
Implementation intent: I plan to follow this issue up with a PR once the design direction is settled.
Is your feature request related to a problem? Please describe.
Operators running Apollo Router behind a CDN want to propagate the router's response cache tags (
apolloCacheTagsfor root fields,apolloEntityCacheTagsfor entities, and the resolved output of the@cacheTagdirective) to the CDN asCache-TagorSurrogate-Keyheaders. This lets the CDN perform tag-based purging in coordination with the router's own cache.Today this is not possible across the full request lifecycle. The relevant mechanics in
response_cache:apolloCacheTagsandapolloEntityCacheTagsfrom subgraph response extensions and writes them into Redis invalidation indexes (apollo-router/src/plugins/response_cache/plugin.rs:996,:1753). The plugin does not strip them from the response body.subgraph::Responsewithextensions(Object::new())and onlyCache-Controlreplayed on the headers (root-field hit path atplugin.rs:1320, all-entities-from-cache short-circuit atplugin.rs:1625). The original subgraph extensions and headers are not retained on the stored entry, so they cannot be replayed.A coprocessor at the subgraph stage also cannot reconstruct the full tag set across a single request. Consider the following partial-entity-merge scenario:
So coprocessor and rhai approaches address only the cache-miss path. The cache-hit and partial-hit paths are the cases that matter most for CDN integration, since CDN tagging is most valuable precisely when the router cache is warm.
Describe the solution you'd like
A three-part change to
response_cache. Concrete proposal below, intentionally specific so it is easy to push back on. None of the names or shapes are load-bearing; they are starting points.1. Persist the tag set on the cache entry.
Today the cached payload is effectively
data + cache_control. Extend the cache entry to also store the union ofapolloCacheTags,apolloEntityCacheTags, and resolved@cacheTagvalues applicable to that entry. The cache-hit response paths (plugin.rs:1320,plugin.rs:1625) then attach the stored tag set to the rebuilt response, so a hit response is indistinguishable in tag content from a miss response.2. Accumulate tags across the supergraph response.
The router walks many subgraph calls per supergraph response, some hit and some miss, with per-entity hits and misses inside
_entitiescalls. Accumulate the union of tags as aContextextension while walking the resolution tree. Each subgraph response (cache hit or miss) contributes its tag set to a single growing collection scoped to the request context.3. Expose the aggregated tag set on the supergraph response.
Proposed default: a single configurable response header. Strawman config:
The accumulated context entry should also be exposed to coprocessor and rhai (read-only) under a stable key, so customers with custom propagation needs (multiple headers, conditional emission, transformation) can opt out of the built-in header and consume the raw set themselves. Suggested key:
apollo::response_cache::aggregated_cache_tags(Rust-style namespace mirrors existingContextkeys in the codebase).Default behavior choices in the proposal:
Cache-Tagas the default header name. It is the Cloudflare convention and shows up most often in customer integrations. Easy to swap.Describe alternatives you've considered
Coprocessor or rhai header propagation at the subgraph stage. Works on cache miss only. Cache entries do not carry tags, the rebuilt response only replays
Cache-Control, and a coprocessor cannot reconstruct the cross-call tag union without reimplementing request hashing and federation logic. Not viable as a general solution.Subgraph emits an HTTP
Cache-TagorSurrogate-Keyheader. Same limitation. Original subgraph headers are not preserved on the cached entry and are not replayed on hit.Schema-resolved
@cacheTagonly. The directive resolves at request time from arguments and entity keys, but the resolved values are not exposed to coprocessor, rhai, or response headers today. This is a separate axis from programmatic tags returned via subgraph response extensions, so it does not address use cases whose tags depend on response data.Debug-mode
apolloCacheDebuggingextension. Withdebug: true,response_cacheinjects anapolloCacheDebuggingextension into the supergraph response body containing human-readableinvalidationKeys. A coprocessor could in principle lift these onto a header. Not viable in production:Surface noted here for completeness; not a recommended path.
Additional context
Source references (verified against
mainat commitae852ae6b, v2.14.1):apollo-router/src/plugins/response_cache/plugin.rs:100-101plugin.rs:996,plugin.rs:1753plugin.rs:1320plugin.rs:1625plugin.rs:1167plugin.rs:1749-1783(usesinsert_entities_in_resultatplugin.rs:2423)response_cachefor the subgraph stage:apollo-router/src/services/subgraph_service.rs:1116, with registration order inapollo-router/src/router_factory.rs:897-898apollo-router/src/plugins/coprocessor/mod.rs:1708Related issues:
response_cache). Adjacent surface area; both touch the cache-tag write/read paths. Worth coordinating implementation.Open design questions, with my current lean:
Cache-Tagproposed.Surrogate-Keyis the next most common. Open to whatever the router team thinks integrates best with the broader Apollo cache configuration vocabulary.@cacheTagdirective resolution: should resolved directive values flow into the same aggregated set, or be exposed separately? My proposal merges them, since downstream consumers do not need to distinguish the source.apollo::response_cache::aggregated_cache_tagsproposed; open to whatever fits existing conventions.Implementation intent: I plan to follow this issue up with a PR once the design direction is settled.