diff --git a/apollo-router/src/plugins/cache/entity.rs b/apollo-router/src/plugins/cache/entity.rs index b566121e9f..2375d4fde4 100644 --- a/apollo-router/src/plugins/cache/entity.rs +++ b/apollo-router/src/plugins/cache/entity.rs @@ -963,10 +963,7 @@ fn extract_cache_keys( let typename = opt_type.as_str().unwrap_or("-"); - // We have to hash the representation because it can contains PII - let mut digest = Sha256::new(); - digest.update(serde_json::to_string(&representation).unwrap().as_bytes()); - let hashed_entity_key = hex::encode(digest.finalize().as_slice()); + let hashed_entity_key = hash_entity_key(representation); // the cache key is written to easily find keys matching a prefix for deletion: // - entity cache version: current version of the hash @@ -976,7 +973,7 @@ fn extract_cache_keys( // - query hash: invalidate the entry for a specific query and operation name // - additional data: separate cache entries depending on info like authorization status let mut key = String::new(); - let _ = write!(&mut key, "version:{ENTITY_CACHE_VERSION}:subgraph:{subgraph_name}:{typename}:{hashed_entity_key}:{query_hash}:{additional_data_hash}"); + let _ = write!(&mut key, "version:{ENTITY_CACHE_VERSION}:subgraph:{subgraph_name}:type:{typename}:entity:{hashed_entity_key}:hash:{query_hash}:data:{additional_data_hash}"); if is_known_private { if let Some(id) = private_id { let _ = write!(&mut key, ":{id}"); @@ -991,6 +988,13 @@ fn extract_cache_keys( Ok(res) } +pub(crate) fn hash_entity_key(representation: &Value) -> String { + // We have to hash the representation because it can contains PII + let mut digest = Sha256::new(); + digest.update(serde_json::to_string(&representation).unwrap().as_bytes()); + hex::encode(digest.finalize().as_slice()) +} + /// represents the result of a cache lookup for an entity type and key struct IntermediateResult { key: String, diff --git a/apollo-router/src/plugins/cache/invalidation.rs b/apollo-router/src/plugins/cache/invalidation.rs index b5f0f6d061..96c863e437 100644 --- a/apollo-router/src/plugins/cache/invalidation.rs +++ b/apollo-router/src/plugins/cache/invalidation.rs @@ -13,6 +13,8 @@ use crate::cache::redis::RedisCacheStorage; use crate::cache::redis::RedisKey; use crate::notification::Handle; use crate::notification::HandleStream; +use crate::plugins::cache::entity::hash_entity_key; +use crate::plugins::cache::entity::ENTITY_CACHE_VERSION; use crate::Notify; #[derive(Clone)] @@ -179,13 +181,18 @@ impl InvalidationRequest { fn key_prefix(&self) -> String { match self { InvalidationRequest::Subgraph { subgraph } => { - format!("subgraph:{subgraph}*",) + format!("version:{ENTITY_CACHE_VERSION}:subgraph:{subgraph}*",) } InvalidationRequest::Type { subgraph, r#type } => { - format!("subgraph:{subgraph}:type:{type}*",) + format!("version:{ENTITY_CACHE_VERSION}:subgraph:{subgraph}:type:{type}*",) } - _ => { - todo!() + InvalidationRequest::Entity { + subgraph, + r#type, + key, + } => { + let entity_key = hash_entity_key(key); + format!("version:{ENTITY_CACHE_VERSION}:subgraph:{subgraph}:type:{type}:entity:{entity_key}*") } } } diff --git a/apollo-router/tests/integration/redis.rs b/apollo-router/tests/integration/redis.rs index 9cf5912c60..b7bfe2ea52 100644 --- a/apollo-router/tests/integration/redis.rs +++ b/apollo-router/tests/integration/redis.rs @@ -413,7 +413,7 @@ async fn entity_cache() -> Result<(), BoxError> { let v: Value = serde_json::from_str(&s).unwrap(); insta::assert_json_snapshot!(v.as_object().unwrap().get("data").unwrap()); - let s: String = client.get("version:1.0:subgraph:reviews:Product:4911f7a9dbad8a47b8900d65547503a2f3c0359f65c0bc5652ad9b9843281f66:1de543dab57fde0f00247922ccc4f76d4c916ae26a89dd83cd1a62300d0cda20:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c").await.unwrap(); + let s: String = client.get("version:1.0:subgraph:reviews:type:Product:entity:4911f7a9dbad8a47b8900d65547503a2f3c0359f65c0bc5652ad9b9843281f66:hash:1de543dab57fde0f00247922ccc4f76d4c916ae26a89dd83cd1a62300d0cda20:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c").await.unwrap(); let v: Value = serde_json::from_str(&s).unwrap(); insta::assert_json_snapshot!(v.as_object().unwrap().get("data").unwrap()); @@ -517,7 +517,7 @@ async fn entity_cache() -> Result<(), BoxError> { insta::assert_json_snapshot!(response); let s:String = client - .get("version:1.0:subgraph:reviews:Product:d9a4cd73308dd13ca136390c10340823f94c335b9da198d2339c886c738abf0d:1de543dab57fde0f00247922ccc4f76d4c916ae26a89dd83cd1a62300d0cda20:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c") + .get("version:1.0:subgraph:reviews:type:Product:entity:d9a4cd73308dd13ca136390c10340823f94c335b9da198d2339c886c738abf0d:hash:1de543dab57fde0f00247922ccc4f76d4c916ae26a89dd83cd1a62300d0cda20:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c") .await .unwrap(); let v: Value = serde_json::from_str(&s).unwrap(); @@ -755,7 +755,7 @@ async fn entity_cache_authorization() -> Result<(), BoxError> { ); let s: String = client - .get("version:1.0:subgraph:reviews:Product:4911f7a9dbad8a47b8900d65547503a2f3c0359f65c0bc5652ad9b9843281f66:1de543dab57fde0f00247922ccc4f76d4c916ae26a89dd83cd1a62300d0cda20:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c") + .get("version:1.0:subgraph:reviews:type:Product:entity:4911f7a9dbad8a47b8900d65547503a2f3c0359f65c0bc5652ad9b9843281f66:hash:1de543dab57fde0f00247922ccc4f76d4c916ae26a89dd83cd1a62300d0cda20:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c") .await .unwrap(); let v: Value = serde_json::from_str(&s).unwrap(); @@ -799,7 +799,7 @@ async fn entity_cache_authorization() -> Result<(), BoxError> { insta::assert_json_snapshot!(response); let s:String = client - .get("version:1.0:subgraph:reviews:Product:4911f7a9dbad8a47b8900d65547503a2f3c0359f65c0bc5652ad9b9843281f66:3b6ef3c8fd34c469d59f513942c5f4c8f91135e828712de2024e2cd4613c50ae:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c") + .get("version:1.0:subgraph:reviews:type:Product:entity:4911f7a9dbad8a47b8900d65547503a2f3c0359f65c0bc5652ad9b9843281f66:hash:3b6ef3c8fd34c469d59f513942c5f4c8f91135e828712de2024e2cd4613c50ae:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c") .await .unwrap(); let v: Value = serde_json::from_str(&s).unwrap(); diff --git a/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/README.md b/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/README.md new file mode 100644 index 0000000000..15f004693c --- /dev/null +++ b/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/README.md @@ -0,0 +1,6 @@ +# Entity cache invalidation + +This tests entity cache invalidation based on entity keys. This is the expected process: +- a query is sent to the router, for which multiple entities will be requested +- we reload the subgraph with a mock mutation where the response has an extension to invalidate one of the entities +- we do the same query, we should see an `_entities` query that only requests that specific entity \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/configuration.yaml b/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/configuration.yaml new file mode 100644 index 0000000000..b297fee443 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/configuration.yaml @@ -0,0 +1,17 @@ +override_subgraph_url: + products: http://localhost:4005 +include_subgraph_errors: + all: true + +preview_entity_cache: + enabled: true + redis: + urls: + ["redis://localhost:6379",] + subgraph: + all: + enabled: true + subgraphs: + reviews: + ttl: 120s + enabled: true \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/skipped.json b/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/skipped.json new file mode 100644 index 0000000000..b505259570 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/skipped.json @@ -0,0 +1,239 @@ +{ + "enterprise": true, + "redis": true, + "actions": [ + { + "type": "Start", + "schema_path": "./supergraph.graphql", + "configuration_path": "./configuration.yaml", + "subgraphs": { + "products": { + "requests": [ + { + "request": { + "body": {"query":"{topProducts{__typename upc}}"} + }, + "response": { + "headers": { + "Cache-Control": "public, max-age=10", + "Content-Type": "application/json" + }, + "body": {"data": { "topProducts": [{ "__typename": "Product", "upc": "0" }, { "__typename": "Product", "upc": "1"} ] } } + } + } + ] + }, + "reviews": { + "requests": [ + { + "request": { + "body": { + "query":"query($representations:[_Any!]!){_entities(representations:$representations){...on Product{reviews{body}}}}", + "variables":{"representations":[{"upc":"0","__typename":"Product"},{"upc":"1","__typename":"Product"}]} + } + }, + "response": { + "headers": { + "Cache-Control": "public, max-age=10", + "Content-Type": "application/json" + }, + "body": {"data": { "_entities": [ + { + "reviews": [ + { "body": "A"}, + { "body": "B"} + ] + }, + { + "reviews": [ + { "body": "C"} + ] + }] + }} + } + } + ] + } + } + }, + { + "type": "Request", + "request": { + "query": "{ topProducts { reviews { body } } }" + }, + "expected_response": { + "data":{ + "topProducts": [{ + "reviews": [{ + "body": "A" + },{ + "body": "B" + }] + }, + { + "reviews": [{ + "body": "C" + }] + }] + } + } + }, + { + "type": "ReloadSubgraphs", + "subgraphs": { + "reviews": { + "requests": [ + { + "request": { + "body": {"query":"mutation{invalidateProductReview}"} + }, + "response": { + "headers": { + "Content-Type": "application/json" + }, + "body": { + "data": { "invalidateProductReview": 1 }, + "extensions": { + "invalidation": [{ + "kind": "entity", + "subgraph": "reviews", + "type": "Product", + "key": { + "upc": "1" + } + }] + } + } + } + }, + { + "request": { + "body": { + "query":"query($representations:[_Any!]!){_entities(representations:$representations){...on Product{reviews{body}}}}", + "variables":{"representations":[{"upc":"1","__typename":"Product"}]} + } + }, + "response": { + "status": 500, + "headers": { + "Cache-Control": "public, max-age=10", + "Content-Type": "application/json" + }, + "body": {} + } + } + ] + } + } + }, + { + "type": "Request", + "request": { + "query": "{ topProducts { reviews { body } } }" + }, + "expected_response": { + "data":{ + "topProducts": [{ + "reviews": [{ + "body": "A" + },{ + "body": "B" + }] + }, + { + "reviews": [{ + "body": "C" + }] + }] + } + } + }, + { + "type": "Request", + "request": { + "query": "mutation { invalidateProductReview }" + }, + "expected_response": { + "data":{ + "invalidateProductReview": 1 + } + } + }, + { + "type": "Request", + "request": { + "query": "{ topProducts { reviews { body } } }" + }, + "expected_response":{ + "data":{ + "topProducts":[{"reviews":null},{"reviews":null}] + }, + "errors":[ + { + "message":"HTTP fetch failed from 'reviews': 500: Internal Server Error", + "extensions":{"code":"SUBREQUEST_HTTP_ERROR","service":"reviews","reason":"500: Internal Server Error","http":{"status":500}} + }, + { + "message":"service 'reviews' response was malformed: {}", + "extensions":{"service":"reviews","reason":"{}","code":"SUBREQUEST_MALFORMED_RESPONSE"} + } + ] + } + }, + { + "type": "ReloadSubgraphs", + "subgraphs": { + "reviews": { + "requests": [ + { + "request": { + "body": { + "query":"query($representations:[_Any!]!){_entities(representations:$representations){...on Product{reviews{body}}}}", + "variables":{"representations":[{"upc":"1","__typename":"Product"}]} + } + }, + "response": { + "headers": { + "Cache-Control": "public, max-age=10", + "Content-Type": "application/json" + }, + "body": {"data": { "_entities": [ + { + "reviews": [ + { "body": "C"} + ] + }] + }} + } + } + ] + } + } + }, + { + "type": "Request", + "request": { + "query": "{ topProducts { reviews { body } } }" + }, + "expected_response": { + "data":{ + "topProducts": [{ + "reviews": [{ + "body": "A" + },{ + "body": "B" + }] + }, + { + "reviews": [{ + "body": "C" + }] + }] + } + } + }, + { + "type": "Stop" + } + ] +} \ No newline at end of file diff --git a/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/supergraph.graphql b/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/supergraph.graphql new file mode 100644 index 0000000000..8f4b1aa05b --- /dev/null +++ b/apollo-router/tests/samples/enterprise/entity-cache/invalidation-entity-key/supergraph.graphql @@ -0,0 +1,90 @@ + +schema + @core(feature: "https://specs.apollo.dev/core/v0.2"), + @core(feature: "https://specs.apollo.dev/join/v0.1", for: EXECUTION) + @core(feature: "https://specs.apollo.dev/inaccessible/v0.1", for: SECURITY) +{ + query: Query + mutation: Mutation +} + +directive @core(as: String, feature: String!, for: core__Purpose) repeatable on SCHEMA + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet) on FIELD_DEFINITION + +directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on OBJECT | INTERFACE + +directive @join__owner(graph: join__Graph!) on OBJECT | INTERFACE + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION + +directive @inaccessible on OBJECT | FIELD_DEFINITION | INTERFACE | UNION + +enum core__Purpose { + """ + `EXECUTION` features provide metadata necessary to for operation execution. + """ + EXECUTION + + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY +} + +scalar join__FieldSet + +enum join__Graph { + ACCOUNTS @join__graph(name: "accounts", url: "https://accounts.demo.starstuff.dev") + INVENTORY @join__graph(name: "inventory", url: "https://inventory.demo.starstuff.dev") + PRODUCTS @join__graph(name: "products", url: "https://products.demo.starstuff.dev") + REVIEWS @join__graph(name: "reviews", url: "https://reviews.demo.starstuff.dev") +} +type Mutation { + updateMyAccount: User @join__field(graph: ACCOUNTS) + invalidateProductReview: Int @join__field(graph: REVIEWS) +} + +type Product + @join__owner(graph: PRODUCTS) + @join__type(graph: PRODUCTS, key: "upc") + @join__type(graph: INVENTORY, key: "upc") + @join__type(graph: REVIEWS, key: "upc") +{ + inStock: Boolean @join__field(graph: INVENTORY) @tag(name: "private") @inaccessible + name: String @join__field(graph: PRODUCTS) + price: Int @join__field(graph: PRODUCTS) + reviews: [Review] @join__field(graph: REVIEWS) + reviewsForAuthor(authorID: ID!): [Review] @join__field(graph: REVIEWS) + shippingEstimate: Int @join__field(graph: INVENTORY, requires: "price weight") + upc: String! @join__field(graph: PRODUCTS) + weight: Int @join__field(graph: PRODUCTS) +} + +type Query { + me: User @join__field(graph: ACCOUNTS) + topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCTS) +} + +type Review + @join__owner(graph: REVIEWS) + @join__type(graph: REVIEWS, key: "id") +{ + author: User @join__field(graph: REVIEWS, provides: "username") + body: String @join__field(graph: REVIEWS) + id: ID! @join__field(graph: REVIEWS) + product: Product @join__field(graph: REVIEWS) +} + +type User + @join__owner(graph: ACCOUNTS) + @join__type(graph: ACCOUNTS, key: "id") + @join__type(graph: REVIEWS, key: "id") +{ + id: ID! @join__field(graph: ACCOUNTS) + name: String @join__field(graph: ACCOUNTS) + reviews: [Review] @join__field(graph: REVIEWS) + username: String @join__field(graph: ACCOUNTS) +}