diff --git a/apollo-router/src/plugins/response_cache/plugin.rs b/apollo-router/src/plugins/response_cache/plugin.rs index aa73cb80a5..4c7c9f8a03 100644 --- a/apollo-router/src/plugins/response_cache/plugin.rs +++ b/apollo-router/src/plugins/response_cache/plugin.rs @@ -1763,7 +1763,21 @@ async fn cache_store_entities_from_response( None }; - // Support cache tags coming from subgraph extensions + // cache tags coming from the subgraph response extension allow for granular invalidation + // by being very specific about _which_ entities we're invalidating. The nested arrays look + // like this: + // + // "data": {"_entities": [{"id": 1, ...}, {"id": 2, ...}]} + // "extensions": {"apolloEntityCacheTags": [["tag-1"], ["tag-2"]]} + // + // where entity with id=1 corresponds to ["tag-1"] and entity with id=2 to ["tag-2"] + // + // this nested array design was in response to how things were previously: a single flat + // array with all the invalidation cache tags, which would invalidate _all_ entities when + // any tag was matched. With nested arrays, each array positionally corresponds to a + // specific entity and its tags are treated individually to that entity rather than + // applying to all entities + let per_entity_surrogate_keys = response .response .body() @@ -2453,6 +2467,8 @@ async fn insert_entities_in_result( let mut to_insert: Vec<_> = Vec::new(); let mut debug_ctx_entries = Vec::new(); let mut entities_it = entities.drain(..).enumerate(); + // iterate through per-entity cache tags in parallel with entities; tags are matched + // positionally, meaning the first tag array applies to the first entity, etc let mut per_entity_surrogate_keys_it = per_entity_surrogate_keys.iter(); // insert requested entities and cached entities in the same order as @@ -2509,6 +2525,8 @@ async fn insert_entities_in_result( has_errors = true; } + // apply per-entity cache tags from the subgraph's apolloEntityCacheTags extension; these tags + // enable targeted cache invalidation for this specific entity if let Some(Value::Array(keys)) = specific_surrogate_keys { invalidation_keys .extend(keys.iter().filter_map(|v| v.as_str()).map(|s| s.to_owned())); diff --git a/apollo-router/tests/integration/response_cache.rs b/apollo-router/tests/integration/response_cache.rs index 623aa1140c..a686e778f5 100644 --- a/apollo-router/tests/integration/response_cache.rs +++ b/apollo-router/tests/integration/response_cache.rs @@ -705,6 +705,30 @@ async fn invalidate_with_endpoint_by_type() { "); } +/// Tests cache invalidation by entity cache tags set via the `apolloEntityCacheTags` extension. +/// +/// The `experimental_mock_subgraphs` plugin generates `apolloEntityCacheTags` from `__cacheTags` +/// fields in the mock entity data. The extension is an array of arrays where each inner array +/// corresponds **positionally** to entities in the `_entities` response array. +/// +/// For example, with this mock configuration in `base_subgraphs()`: +/// ```json +/// "entities": [ +/// {"__cacheTags": ["product-1"], "__typename": "Product", "upc": "1", ...}, +/// {"__cacheTags": ["product-2"], "__typename": "Product", "upc": "2", ...}, +/// ] +/// ``` +/// +/// The subgraph response will include: +/// ```json +/// "extensions": {"apolloEntityCacheTags": [["product-1"], ["product-2"]]} +/// ``` +/// +/// The router associates cache tags positionally: the first entity (upc "1") gets tags +/// `["product-1"]`, the second entity (upc "2") gets tags `["product-2"]`. +/// +/// This test verifies that invalidating by cache tag `"product-1"` only invalidates the +/// first entity's cache, requiring a new fetch from the reviews subgraph. #[tokio::test(flavor = "multi_thread")] async fn invalidate_with_endpoint_by_entity_cache_tag() { if !graph_os_enabled() { diff --git a/docs/source/routing/performance/caching/response-caching/invalidation.mdx b/docs/source/routing/performance/caching/response-caching/invalidation.mdx index a26e07f2d3..9ccd6e9846 100644 --- a/docs/source/routing/performance/caching/response-caching/invalidation.mdx +++ b/docs/source/routing/performance/caching/response-caching/invalidation.mdx @@ -423,34 +423,70 @@ type Country { If you need to set cache tags programmatically (for example, if the tag depends on neither root field arguments nor entity keys), create the cache tags in your subgraph and set them in the response extensions. -For cache tags on _entities_, set `apolloEntityCacheTags` in `extensions`. The following example shows a response payload that sets cache tags for entities returned by a subgraph: +The router uses two different extensions because entities and root fields are cached differently: +- **Entities** are cached individually—each entity in an `_entities` response gets its own cache entry. Use `apolloEntityCacheTags` with an array of arrays to assign different tags to different entities. +- **Root fields** are cached as a single unit—the entire subgraph response is one cache entry. Use `apolloCacheTags` with a flat array of tags that apply to the whole response. + +#### Entity cache tags + +For cache tags on _entities_, set `apolloEntityCacheTags` in `extensions`. This field must be an array of arrays, where: +- The outer array corresponds **positionally** to the entities in the `_entities` array +- Each inner array contains string cache tags for that specific entity + +The following example shows a response payload that sets cache tags for entities returned by a subgraph: ```json { "data": {"_entities": [ - {"__typename": "User", "id": 42, ...}, - {"__typename": "User", "id": 1023, ...}, - {"__typename": "User", "id": 7, ...}, + {"__typename": "User", "id": 42, "name": "Alice"}, + {"__typename": "User", "id": 1023, "name": "Bob"}, + {"__typename": "User", "id": 7, "name": "Charlie"} ]}, "extensions": {"apolloEntityCacheTags": [ - ["products", "product-42"], - ["products", "product-1023"], - ["products", "product-7"] + ["users", "user-42"], + ["users", "user-1023"], + ["users", "user-7"] ]} } ``` -For cache tags on _root fields_, set `apolloCacheTags` in `extensions`. The following example shows a response payload that sets cache tags for root fields returned by a subgraph: +In this example: +- The first entity (User with id 42) is tagged with `["users", "user-42"]` +- The second entity (User with id 1023) is tagged with `["users", "user-1023"]` +- The third entity (User with id 7) is tagged with `["users", "user-7"]` + +Because each entity is cached separately with its own tags, you can invalidate individual entities. For example, invalidating by tag `user-42` only removes User 42 from the cache, while invalidating by tag `users` removes all three users. + +To invalidate using these programmatically-set tags, send a request to the invalidation endpoint: + +```json +[{ + "kind": "cache_tag", + "subgraphs": ["your-subgraph-name"], + "cache_tag": "user-42" +}] +``` + +#### Root field cache tags + +For cache tags on _root fields_, set `apolloCacheTags` in `extensions`. This field is a flat array of strings because the entire root field response is cached as a single entry—all tags apply to that one cache entry. + +The following example shows a response payload that sets cache tags for a `homepage` query: ```json { "data": { - "someField": {...} + "homepage": { + "featuredProducts": [...], + "currentUser": {"id": 9001, "name": "Jane"} + } }, "extensions": {"apolloCacheTags": ["homepage", "user-9001-homepage"]} } ``` +In this example, both tags (`homepage` and `user-9001-homepage`) are applied to the cached response. Later, you can invalidate this cached response by targeting either tag. + ### Invalidation HTTP endpoint The invalidation endpoint exposed by the router expects to receive an array of invalidation requests and processes them in sequence. For authorization, you must provide a shared key in the request header. For example, with the previous configuration, send the following request: