Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions apollo-router/src/plugins/cache/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}");
Expand All @@ -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,
Expand Down
15 changes: 11 additions & 4 deletions apollo-router/src/plugins/cache/invalidation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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}*")
}
}
}
Expand Down
8 changes: 4 additions & 4 deletions apollo-router/tests/integration/redis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Loading