diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap index 3616c90414..0dc3c12eab 100644 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap @@ -1701,6 +1701,11 @@ snapshot_kind: text "description": "Enable or disable the entity caching feature", "type": "boolean" }, + "expose_keys_in_context": { + "default": false, + "description": "Expose cache keys in context", + "type": "boolean" + }, "invalidation": { "$ref": "#/definitions/InvalidationEndpointConfig", "description": "#/definitions/InvalidationEndpointConfig", diff --git a/apollo-router/src/plugins/cache/cache_control.rs b/apollo-router/src/plugins/cache/cache_control.rs index dd7d1a598e..4aa300e077 100644 --- a/apollo-router/src/plugins/cache/cache_control.rs +++ b/apollo-router/src/plugins/cache/cache_control.rs @@ -157,7 +157,24 @@ impl CacheControl { Ok(result) } + /// Fill the header map with cache-control header and age header pub(crate) fn to_headers(&self, headers: &mut HeaderMap) -> Result<(), BoxError> { + headers.insert( + CACHE_CONTROL, + HeaderValue::from_str(&self.to_cache_control_header()?)?, + ); + + if let Some(age) = self.age { + if age != 0 { + headers.insert(AGE, age.into()); + } + } + + Ok(()) + } + + /// Only for cache control header and not age + pub(crate) fn to_cache_control_header(&self) -> Result { let mut s = String::new(); let mut prev = false; let now = now_epoch_seconds(); @@ -228,15 +245,8 @@ impl CacheControl { if self.stale_if_error { write!(&mut s, "{}stale-if-error", if prev { "," } else { "" },)?; } - headers.insert(CACHE_CONTROL, HeaderValue::from_str(&s)?); - - if let Some(age) = self.age { - if age != 0 { - headers.insert(AGE, age.into()); - } - } - Ok(()) + Ok(s) } pub(super) fn no_store() -> Self { diff --git a/apollo-router/src/plugins/cache/entity.rs b/apollo-router/src/plugins/cache/entity.rs index b0abd0178e..d50531dde2 100644 --- a/apollo-router/src/plugins/cache/entity.rs +++ b/apollo-router/src/plugins/cache/entity.rs @@ -51,6 +51,7 @@ use crate::plugins::authorization::CacheKeyMetadata; use crate::query_planner::fetch::QueryHash; use crate::query_planner::OperationKind; use crate::services::subgraph; +use crate::services::subgraph::SubgraphRequestId; use crate::services::supergraph; use crate::spec::TYPENAME; use crate::Context; @@ -62,6 +63,8 @@ pub(crate) const ENTITY_CACHE_VERSION: &str = "1.0"; pub(crate) const ENTITIES: &str = "_entities"; pub(crate) const REPRESENTATIONS: &str = "representations"; pub(crate) const CONTEXT_CACHE_KEY: &str = "apollo_entity_cache::key"; +/// Context key to enable support of surrogate cache key +pub(crate) const CONTEXT_CACHE_KEYS: &str = "apollo::entity_cache::cached_keys_status"; register_plugin!("apollo", "preview_entity_cache", EntityCache); @@ -73,6 +76,7 @@ pub(crate) struct EntityCache { entity_type: Option, enabled: bool, metrics: Metrics, + expose_keys_in_context: bool, private_queries: Arc>>, pub(crate) invalidation: Invalidation, } @@ -96,6 +100,10 @@ pub(crate) struct Config { #[serde(default)] enabled: bool, + #[serde(default)] + /// Expose cache keys in context + expose_keys_in_context: bool, + /// Configure invalidation per subgraph subgraph: SubgraphConfiguration, @@ -297,6 +305,7 @@ impl Plugin for EntityCache { storage, entity_type, enabled: init.config.enabled, + expose_keys_in_context: init.config.expose_keys_in_context, endpoint_config: init.config.invalidation.clone().map(Arc::new), subgraphs: Arc::new(init.config.subgraph), metrics: init.config.metrics, @@ -390,6 +399,7 @@ impl Plugin for EntityCache { private_queries, private_id, invalidation: self.invalidation.clone(), + expose_keys_in_context: self.expose_keys_in_context, }))); tower::util::BoxService::new(inner) } else { @@ -467,6 +477,7 @@ impl EntityCache { storage, entity_type: None, enabled: true, + expose_keys_in_context: true, subgraphs: Arc::new(SubgraphConfiguration { all: Subgraph::default(), subgraphs, @@ -496,6 +507,7 @@ struct InnerCacheService { subgraph_ttl: Option, private_queries: Arc>>, private_id: Option, + expose_keys_in_context: bool, invalidation: Invalidation, } @@ -567,6 +579,7 @@ impl InnerCacheService { self.storage.clone(), is_known_private, private_id.as_deref(), + self.expose_keys_in_context, request, ) .instrument(tracing::info_span!("cache.entity.lookup")) @@ -614,6 +627,7 @@ impl InnerCacheService { if private_id.is_none() { // the response has a private scope but we don't have a way to differentiate users, so we do not store the response in cache + // We don't need to fill the context with this cache key as it will never be cached return Ok(response); } } @@ -638,6 +652,7 @@ impl InnerCacheService { &response, cache_control, root_cache_key, + self.expose_keys_in_context, ) .await?; } @@ -663,12 +678,14 @@ impl InnerCacheService { Ok(response) } } else { + let request_id = request.id.clone(); match cache_lookup_entities( self.name.clone(), self.storage.clone(), is_known_private, private_id.as_deref(), request, + self.expose_keys_in_context, ) .instrument(tracing::info_span!("cache.entity.lookup")) .await? @@ -701,6 +718,20 @@ impl InnerCacheService { &[graphql_error], &mut cache_result.0, ); + if self.expose_keys_in_context { + // Update cache keys needed for surrogate cache key because new data has not been fetched + context.upsert::<_, CacheKeysContext>( + CONTEXT_CACHE_KEYS, + |mut value| { + if let Some(cache_keys) = value.get_mut(&request_id) { + cache_keys.retain(|cache_key| { + matches!(cache_key.status, CacheKeyStatus::Cached) + }); + } + value + }, + )?; + } let mut data = Object::default(); data.insert(ENTITIES, new_entities.into()); @@ -727,6 +758,25 @@ impl InnerCacheService { if let Some(control_from_cached) = cache_result.1 { cache_control = cache_control.merge(&control_from_cached); } + if self.expose_keys_in_context { + // Update cache keys needed for surrogate cache key when it's new data and not data from the cache + let response_id = response.id.clone(); + let cache_control_str = cache_control.to_cache_control_header()?; + response.context.upsert::<_, CacheKeysContext>( + CONTEXT_CACHE_KEYS, + |mut value| { + if let Some(cache_keys) = value.get_mut(&response_id) { + for cache_key in cache_keys + .iter_mut() + .filter(|c| matches!(c.status, CacheKeyStatus::New)) + { + cache_key.cache_control = cache_control_str.clone(); + } + } + value + }, + )?; + } if !is_known_private && cache_control.private() { self.private_queries.write().await.insert(query.to_string()); @@ -797,6 +847,7 @@ async fn cache_lookup_root( cache: RedisCacheStorage, is_known_private: bool, private_id: Option<&str>, + expose_keys_in_context: bool, mut request: subgraph::Request, ) -> Result, BoxError> { let body = request.subgraph_request.body_mut(); @@ -822,6 +873,36 @@ async fn cache_lookup_root( .context .extensions() .with_lock(|mut lock| lock.insert(control)); + if expose_keys_in_context { + let request_id = request.id.clone(); + let cache_control_header = value.0.control.to_cache_control_header()?; + request.context.upsert::<_, CacheKeysContext>( + CONTEXT_CACHE_KEYS, + |mut val| { + match val.get_mut(&request_id) { + Some(v) => { + v.push(CacheKeyContext { + key: key.clone(), + status: CacheKeyStatus::Cached, + cache_control: cache_control_header, + }); + } + None => { + val.insert( + request_id, + vec![CacheKeyContext { + key: key.clone(), + status: CacheKeyStatus::Cached, + cache_control: cache_control_header, + }], + ); + } + } + + val + }, + )?; + } let mut response = subgraph::Response::builder() .data(value.0.data) @@ -851,6 +932,7 @@ async fn cache_lookup_entities( is_known_private: bool, private_id: Option<&str>, mut request: subgraph::Request, + expose_keys_in_context: bool, ) -> Result, BoxError> { let body = request.subgraph_request.body_mut(); @@ -893,6 +975,46 @@ async fn cache_lookup_entities( let (new_representations, cache_result, cache_control) = filter_representations(&name, representations, keys, cache_result, &request.context)?; + if expose_keys_in_context { + let mut cache_entries = Vec::with_capacity(cache_result.len()); + for intermediate_result in &cache_result { + match &intermediate_result.cache_entry { + Some(cache_entry) => { + cache_entries.push(CacheKeyContext { + key: intermediate_result.key.clone(), + status: CacheKeyStatus::Cached, + cache_control: cache_entry.control.to_cache_control_header()?, + }); + } + None => { + cache_entries.push(CacheKeyContext { + key: intermediate_result.key.clone(), + status: CacheKeyStatus::New, + cache_control: match &cache_control { + Some(cc) => cc.to_cache_control_header()?, + None => CacheControl::default().to_cache_control_header()?, + }, + }); + } + } + } + let request_id = request.id.clone(); + request + .context + .upsert::<_, CacheKeysContext>(CONTEXT_CACHE_KEYS, |mut v| { + match v.get_mut(&request_id) { + Some(cache_keys) => { + cache_keys.append(&mut cache_entries); + } + None => { + v.insert(request_id, cache_entries); + } + } + + v + })?; + } + if !new_representations.is_empty() { body.variables .insert(REPRESENTATIONS, new_representations.into()); @@ -954,6 +1076,7 @@ async fn cache_store_root_from_response( response: &subgraph::Response, cache_control: CacheControl, cache_key: String, + expose_keys_in_context: bool, ) -> Result<(), BoxError> { if let Some(data) = response.response.body().data.as_ref() { let ttl: Option = cache_control @@ -964,6 +1087,37 @@ async fn cache_store_root_from_response( if response.response.body().errors.is_empty() && cache_control.should_store() { let span = tracing::info_span!("cache.entity.store"); let data = data.clone(); + if expose_keys_in_context { + let response_id = response.id.clone(); + let cache_control_header = cache_control.to_cache_control_header()?; + + response + .context + .upsert::<_, CacheKeysContext>(CONTEXT_CACHE_KEYS, |mut val| { + match val.get_mut(&response_id) { + Some(v) => { + v.push(CacheKeyContext { + key: cache_key.clone(), + status: CacheKeyStatus::New, + cache_control: cache_control_header, + }); + } + None => { + val.insert( + response_id, + vec![CacheKeyContext { + key: cache_key.clone(), + status: CacheKeyStatus::New, + cache_control: cache_control_header, + }], + ); + } + } + + val + })?; + } + tokio::spawn(async move { cache .insert( @@ -1419,3 +1573,42 @@ fn assemble_response_from_errors( } (new_entities, new_errors) } + +pub(crate) type CacheKeysContext = HashMap>; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(test, derive(PartialEq, Eq, Hash, PartialOrd, Ord))] +pub(crate) struct CacheKeyContext { + key: String, + status: CacheKeyStatus, + cache_control: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(test, derive(PartialEq, Eq, Hash))] +#[serde(rename_all = "snake_case")] +pub(crate) enum CacheKeyStatus { + /// New cache key inserted in the cache + New, + /// Key that was already in the cache + Cached, +} + +#[cfg(test)] +impl PartialOrd for CacheKeyStatus { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[cfg(test)] +impl Ord for CacheKeyStatus { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + match (self, other) { + (CacheKeyStatus::New, CacheKeyStatus::New) => std::cmp::Ordering::Equal, + (CacheKeyStatus::New, CacheKeyStatus::Cached) => std::cmp::Ordering::Greater, + (CacheKeyStatus::Cached, CacheKeyStatus::New) => std::cmp::Ordering::Less, + (CacheKeyStatus::Cached, CacheKeyStatus::Cached) => std::cmp::Ordering::Equal, + } + } +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-2.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-2.snap index e3d6799c33..7d3bb156a2 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-2.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-2.snap @@ -1,17 +1,7 @@ --- source: apollo-router/src/plugins/cache/tests.rs -expression: response +expression: response.response.headers().get(CACHE_CONTROL) --- -{ - "data": { - "currentUser": { - "activeOrganization": { - "id": "1", - "creatorUser": { - "__typename": "User", - "id": 2 - } - } - } - } -} +Some( + "public", +) diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-3.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-3.snap index 7d3bb156a2..e3d6799c33 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-3.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-3.snap @@ -1,7 +1,17 @@ --- source: apollo-router/src/plugins/cache/tests.rs -expression: response.response.headers().get(CACHE_CONTROL) +expression: response --- -Some( - "public", -) +{ + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-4.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-4.snap index e3d6799c33..7d3bb156a2 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-4.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-4.snap @@ -1,17 +1,7 @@ --- source: apollo-router/src/plugins/cache/tests.rs -expression: response +expression: response.response.headers().get(CACHE_CONTROL) --- -{ - "data": { - "currentUser": { - "activeOrganization": { - "id": "1", - "creatorUser": { - "__typename": "User", - "id": 2 - } - } - } - } -} +Some( + "public", +) diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-5.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-5.snap new file mode 100644 index 0000000000..dd1ee738c2 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-5.snap @@ -0,0 +1,16 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:5811967f540d300d249ab30ae681359a7815fdb5d3dc71a94be1d491006a6b27:hash:d0b09a1a50750b5e95f73a196acf6ef5a8d60bf19599854b0dbee5dec6ee7ed6:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "cached", + "cache_control": "public" + }, + { + "key": "version:1.0:subgraph:user:type:Query:hash:a3b7f56680be04e3ae646cf8a025aed165e8dd0f6c3dc7c95d745f8cb1348083:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "cached", + "cache_control": "public" + } +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-6.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-6.snap new file mode 100644 index 0000000000..e3d6799c33 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert-6.snap @@ -0,0 +1,17 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response +--- +{ + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert.snap index 7d3bb156a2..1375808b78 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__insert.snap @@ -1,7 +1,16 @@ --- source: apollo-router/src/plugins/cache/tests.rs -expression: response.response.headers().get(CACHE_CONTROL) +expression: cache_keys --- -Some( - "public", -) +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:5811967f540d300d249ab30ae681359a7815fdb5d3dc71a94be1d491006a6b27:hash:d0b09a1a50750b5e95f73a196acf6ef5a8d60bf19599854b0dbee5dec6ee7ed6:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "public" + }, + { + "key": "version:1.0:subgraph:user:type:Query:hash:a3b7f56680be04e3ae646cf8a025aed165e8dd0f6c3dc7c95d745f8cb1348083:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "public" + } +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-2.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-2.snap index 6e58a2d437..b9832aaeaa 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-2.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-2.snap @@ -10,30 +10,11 @@ expression: response "id": "1", "name": "Organization 1" }, - { - "id": "2", - "name": null - }, { "id": "3", "name": "Organization 3" } ] } - }, - "errors": [ - { - "message": "HTTP fetch failed from 'orga': orga not found", - "path": [ - "currentUser", - "allOrganizations", - 1 - ], - "extensions": { - "code": "SUBREQUEST_HTTP_ERROR", - "service": "orga", - "reason": "orga not found" - } - } - ] + } } diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-3.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-3.snap new file mode 100644 index 0000000000..1322b59275 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-3.snap @@ -0,0 +1,16 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:5221ff42b311b757445c096c023cee4fefab5de49735e421c494f1119326317b:hash:cffb47a84aff0aea6a447e33caf3b275bdc7f71689d75f56647242b3b9f5e13b:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "cached", + "cache_control": "[REDACTED]" + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:5811967f540d300d249ab30ae681359a7815fdb5d3dc71a94be1d491006a6b27:hash:cffb47a84aff0aea6a447e33caf3b275bdc7f71689d75f56647242b3b9f5e13b:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "cached", + "cache_control": "[REDACTED]" + } +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-4.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-4.snap new file mode 100644 index 0000000000..6e58a2d437 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data-4.snap @@ -0,0 +1,39 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response +--- +{ + "data": { + "currentUser": { + "allOrganizations": [ + { + "id": "1", + "name": "Organization 1" + }, + { + "id": "2", + "name": null + }, + { + "id": "3", + "name": "Organization 3" + } + ] + } + }, + "errors": [ + { + "message": "HTTP fetch failed from 'orga': orga not found", + "path": [ + "currentUser", + "allOrganizations", + 1 + ], + "extensions": { + "code": "SUBREQUEST_HTTP_ERROR", + "service": "orga", + "reason": "orga not found" + } + } + ] +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data.snap index b9832aaeaa..87c750131f 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__no_data.snap @@ -1,20 +1,16 @@ --- source: apollo-router/src/plugins/cache/tests.rs -expression: response +expression: cache_keys --- -{ - "data": { - "currentUser": { - "allOrganizations": [ - { - "id": "1", - "name": "Organization 1" - }, - { - "id": "3", - "name": "Organization 3" - } - ] - } +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:5221ff42b311b757445c096c023cee4fefab5de49735e421c494f1119326317b:hash:cffb47a84aff0aea6a447e33caf3b275bdc7f71689d75f56647242b3b9f5e13b:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "[REDACTED]" + }, + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:5811967f540d300d249ab30ae681359a7815fdb5d3dc71a94be1d491006a6b27:hash:cffb47a84aff0aea6a447e33caf3b275bdc7f71689d75f56647242b3b9f5e13b:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "[REDACTED]" } -} +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-3.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-3.snap index 3c363a2b4d..c9839a8823 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-3.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-3.snap @@ -1,9 +1,16 @@ --- source: apollo-router/src/plugins/cache/tests.rs -expression: response +expression: cache_keys --- -{ - "data": { - "currentUser": null +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:5811967f540d300d249ab30ae681359a7815fdb5d3dc71a94be1d491006a6b27:hash:d0b09a1a50750b5e95f73a196acf6ef5a8d60bf19599854b0dbee5dec6ee7ed6:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "status": "cached", + "cache_control": "private" + }, + { + "key": "version:1.0:subgraph:user:type:Query:hash:a3b7f56680be04e3ae646cf8a025aed165e8dd0f6c3dc7c95d745f8cb1348083:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "status": "cached", + "cache_control": "private" } -} +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-4.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-4.snap new file mode 100644 index 0000000000..e3d6799c33 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-4.snap @@ -0,0 +1,17 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response +--- +{ + "data": { + "currentUser": { + "activeOrganization": { + "id": "1", + "creatorUser": { + "__typename": "User", + "id": 2 + } + } + } + } +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-5.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-5.snap new file mode 100644 index 0000000000..c9839a8823 --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-5.snap @@ -0,0 +1,16 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: cache_keys +--- +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:5811967f540d300d249ab30ae681359a7815fdb5d3dc71a94be1d491006a6b27:hash:d0b09a1a50750b5e95f73a196acf6ef5a8d60bf19599854b0dbee5dec6ee7ed6:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "status": "cached", + "cache_control": "private" + }, + { + "key": "version:1.0:subgraph:user:type:Query:hash:a3b7f56680be04e3ae646cf8a025aed165e8dd0f6c3dc7c95d745f8cb1348083:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "status": "cached", + "cache_control": "private" + } +] diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-6.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-6.snap new file mode 100644 index 0000000000..3c363a2b4d --- /dev/null +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private-6.snap @@ -0,0 +1,9 @@ +--- +source: apollo-router/src/plugins/cache/tests.rs +expression: response +--- +{ + "data": { + "currentUser": null + } +} diff --git a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private.snap b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private.snap index e3d6799c33..69027e4644 100644 --- a/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private.snap +++ b/apollo-router/src/plugins/cache/snapshots/apollo_router__plugins__cache__tests__private.snap @@ -1,17 +1,16 @@ --- source: apollo-router/src/plugins/cache/tests.rs -expression: response +expression: cache_keys --- -{ - "data": { - "currentUser": { - "activeOrganization": { - "id": "1", - "creatorUser": { - "__typename": "User", - "id": 2 - } - } - } +[ + { + "key": "version:1.0:subgraph:orga:type:Organization:entity:5811967f540d300d249ab30ae681359a7815fdb5d3dc71a94be1d491006a6b27:hash:d0b09a1a50750b5e95f73a196acf6ef5a8d60bf19599854b0dbee5dec6ee7ed6:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "private" + }, + { + "key": "version:1.0:subgraph:user:type:Query:hash:a3b7f56680be04e3ae646cf8a025aed165e8dd0f6c3dc7c95d745f8cb1348083:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c:03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4", + "status": "new", + "cache_control": "private" } -} +] diff --git a/apollo-router/src/plugins/cache/tests.rs b/apollo-router/src/plugins/cache/tests.rs index 24dc71d3ab..3633a21bcc 100644 --- a/apollo-router/src/plugins/cache/tests.rs +++ b/apollo-router/src/plugins/cache/tests.rs @@ -16,7 +16,10 @@ use super::entity::EntityCache; use crate::cache::redis::RedisCacheStorage; use crate::plugin::test::MockSubgraph; use crate::plugin::test::MockSubgraphService; +use crate::plugins::cache::entity::CacheKeyContext; +use crate::plugins::cache::entity::CacheKeysContext; use crate::plugins::cache::entity::Subgraph; +use crate::plugins::cache::entity::CONTEXT_CACHE_KEYS; use crate::services::subgraph; use crate::services::supergraph; use crate::Context; @@ -227,6 +230,10 @@ async fn insert() { .build() .unwrap(); let mut response = service.oneshot(request).await.unwrap(); + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + insta::assert_json_snapshot!(cache_keys); insta::assert_debug_snapshot!(response.response.headers().get(CACHE_CONTROL)); let response = response.next_response().await.unwrap(); @@ -255,6 +262,11 @@ async fn insert() { let mut response = service.oneshot(request).await.unwrap(); insta::assert_debug_snapshot!(response.response.headers().get(CACHE_CONTROL)); + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + insta::assert_json_snapshot!(cache_keys); + let response = response.next_response().await.unwrap(); insta::assert_json_snapshot!(response); @@ -434,14 +446,13 @@ async fn private() { .context(context) .build() .unwrap(); - let response = service - .oneshot(request) - .await - .unwrap() - .next_response() - .await - .unwrap(); + let mut response = service.clone().oneshot(request).await.unwrap(); + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + insta::assert_json_snapshot!(cache_keys); + let response = response.next_response().await.unwrap(); insta::assert_json_snapshot!(response); println!("\nNOW WITHOUT SUBGRAPHS\n"); @@ -463,14 +474,13 @@ async fn private() { .context(context) .build() .unwrap(); - let response = service - .clone() - .oneshot(request) - .await - .unwrap() - .next_response() - .await - .unwrap(); + let mut response = service.clone().oneshot(request).await.unwrap(); + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + insta::assert_json_snapshot!(cache_keys); + + let response = response.next_response().await.unwrap(); insta::assert_json_snapshot!(response); @@ -483,15 +493,16 @@ async fn private() { .context(context) .build() .unwrap(); - let response = service - .clone() - .oneshot(request) - .await - .unwrap() - .next_response() - .await - .unwrap(); + let mut response = service.clone().oneshot(request).await.unwrap(); + assert!(response + .context + .get::<_, CacheKeysContext>(CONTEXT_CACHE_KEYS) + .ok() + .flatten() + .is_none()); + insta::assert_json_snapshot!(cache_keys); + let response = response.next_response().await.unwrap(); insta::assert_json_snapshot!(response); } @@ -589,6 +600,18 @@ async fn no_data() { .unwrap(); let mut response = service.oneshot(request).await.unwrap(); + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + insta::assert_json_snapshot!(cache_keys, { + "[].cache_control" => insta::dynamic_redaction(|value, _path| { + let cache_control = value.as_str().unwrap().to_string(); + assert!(cache_control.contains("max-age=")); + assert!(cache_control.contains("public")); + "[REDACTED]" + }) + }); + let response = response.next_response().await.unwrap(); insta::assert_json_snapshot!(response); @@ -652,6 +675,18 @@ async fn no_data() { .build() .unwrap(); let mut response = service.oneshot(request).await.unwrap(); + + let cache_keys: CacheKeysContext = response.context.get(CONTEXT_CACHE_KEYS).unwrap().unwrap(); + let mut cache_keys: Vec = cache_keys.into_values().flatten().collect(); + cache_keys.sort(); + insta::assert_json_snapshot!(cache_keys, { + "[].cache_control" => insta::dynamic_redaction(|value, _path| { + let cache_control = value.as_str().unwrap().to_string(); + assert!(cache_control.contains("max-age=")); + assert!(cache_control.contains("public")); + "[REDACTED]" + }) + }); let response = response.next_response().await.unwrap(); insta::assert_json_snapshot!(response); diff --git a/docs/source/routing/performance/caching/entity.mdx b/docs/source/routing/performance/caching/entity.mdx index 76e9a616b1..64618ca2d5 100644 --- a/docs/source/routing/performance/caching/entity.mdx +++ b/docs/source/routing/performance/caching/entity.mdx @@ -162,6 +162,7 @@ For example: # Enable entity caching globally preview_entity_cache: enabled: true + expose_keys_in_context: true # Optional, it will expose cache keys in the context in order to use it in coprocessors or Rhai subgraph: all: enabled: true diff --git a/examples/coprocessor-surrogate-cache-key/README.md b/examples/coprocessor-surrogate-cache-key/README.md new file mode 100644 index 0000000000..059c6466f5 --- /dev/null +++ b/examples/coprocessor-surrogate-cache-key/README.md @@ -0,0 +1,124 @@ +## Context + +Existing caching systems often support a concept of surrogate keys, where a key can be linked to a specific piece of cached data, independently of the actual cache key. + +As an example, a news website might want to invalidate all cached articles linked to a specific company or person following an event. To that end, when returning the article, the service can add a surrogate key to the article response, and the cache would keep a map from surrogate keys to cache keys. + +## Surrogate keys and the router’s entity cache + +To support a surrogate key system with the entity caching in the router, we make the following assumptions: + +- The subgraph returns surrogate keys with the response. The router will not manipulate those surrogate keys directly. Instead, it leaves that task to a coprocessor +- The coprocessor tasked with managing surrogate keys will store the mapping from surrogate keys to cache keys. It will be useful to invalidate all cache keys related to a surrogate cache key in Redis. +- The router will expose a way to gather the cache keys used in a subgraph request + +### Router side support + +The router has two features to support surrogate cache key: + +- An id field for subgraph requests and responses. This is a random, unique id per subgraph call that can be used to keep state between the request and response side, and keep data from the various subgraph calls separately for the entire client request. You have to enable it in configuration (`subgraph_request_id`): + +```yaml title=router.yaml +coprocessor: + url: http://127.0.0.1:3000 # mandatory URL which is the address of the coprocessor + supergraph: + response: + context: true + subgraph: + all: + response: + subgraph_request_id: true + context: true +``` + +- The entity cache has an option to store in the request context, at the key `apollo::entity_cache::cached_keys_status`, a map `subgraph request id => cache keys` only when it's enabled in the configuration (`expose_keys_in_context`)): + +```yaml title=router.yaml +preview_entity_cache: + enabled: true + expose_keys_in_context: true + metrics: + enabled: true + invalidation: + listen: 0.0.0.0:4000 + path: /invalidation + # Configure entity caching per subgraph + subgraph: + all: + enabled: true + # Configure Redis + redis: + urls: ["redis://localhost:6379"] + ttl: 24h # Optional, by default no expiration +``` + +The coprocessor will then work at two stages: + +- Subgraph response: + - Extract the subgraph request id + - Extract the list of surrogate keys from the response +- Supergraph stage: + - Extract the map `subgraph request id => cache keys` + - Match it with the surrogate cache keys obtained at the subgraph response stage + +The coprocessor then has a map of `surrogate keys => cache keys` that it can use to invalidate cached data directly from Redis. + +### Example workflow + +- The router receives a client request +- The router starts a subgraph request: + - The entity cache plugin checks if the request has a corresponding cached entry: + - If the entire response can be obtained from cache, we return a response here + - If it cannot be obtained, or only partially (\_entities query), a request is transmitted to the subgraph + - The subgraph responds to the request. The response can contain a list of surrogate keys in a header: `Surrogate-Keys: homepage, feed` + - The subgraph response stage coprocessor extracts the surrogate keys from headers, and stores it in the request context, associated with the subgraph request id `0e67db40-e98d-4ad7-bb60-2012fb5db504`: + +```json +{ + "​0ee3bf47-5e8d-47e3-8e7e-b05ae877d9c7": ["homepage", "feed"] +} +``` + +- The entity cache processes the subgraph response: + - It generates a new subgraph response by interspersing data it got from cache with data from the original response + - It stores the list of keys in the context. `new` indicates newly cached data coming from the subgraph, linked to the surrogate keys, while `cached` is data obtained from the cache. These are the keys directly used in Redis: + +```json +{ + "apollo::entity_cache::cached_keys_status": { + "0ee3bf47-5e8d-47e3-8e7e-b05ae877d9c7": [ + { + "key": "version:1.0:subgraph:products:type:Query:hash:af9febfacdc8244afc233a857e3c4b85a749355707763dc523a6d9e8964e9c8d:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "max-age=60,public" + } + ] + } +} +``` + +- The supergraph response stage loads data from the context and creates the mapping: + +```json +{ + "homepage": [ + { + "key": "version:1.0:subgraph:products:type:Query:hash:af9febfacdc8244afc233a857e3c4b85a749355707763dc523a6d9e8964e9c8d:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "max-age=60,public" + } + ], + "feed": [ + { + "key": "version:1.0:subgraph:products:type:Query:hash:af9febfacdc8244afc233a857e3c4b85a749355707763dc523a6d9e8964e9c8d:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c", + "status": "new", + "cache_control": "max-age=60,public" + } + ] +} +``` + +- When a surrogate key must be used to invalidate data, that mapping is used to obtained the related cache keys + + +In this example we provide a very simple implementation using in memory data in NodeJs. It just prints the mapping at the supergraph response level to show you how you can create that mapping. diff --git a/examples/coprocessor-surrogate-cache-key/nodejs/.gitignore b/examples/coprocessor-surrogate-cache-key/nodejs/.gitignore new file mode 100644 index 0000000000..d5f19d89b3 --- /dev/null +++ b/examples/coprocessor-surrogate-cache-key/nodejs/.gitignore @@ -0,0 +1,2 @@ +node_modules +package-lock.json diff --git a/examples/coprocessor-surrogate-cache-key/nodejs/README.md b/examples/coprocessor-surrogate-cache-key/nodejs/README.md new file mode 100644 index 0000000000..e65978e36b --- /dev/null +++ b/examples/coprocessor-surrogate-cache-key/nodejs/README.md @@ -0,0 +1,16 @@ +# External Subgraph nodejs example + +This is an example that involves a nodejs coprocessor alongside a router. + +## Usage + +- Start the coprocessor: + +```bash +$ npm ci && npm run start +``` + +- Start the router +``` +$ APOLLO_KEY="YOUR_APOLLO_KEY" APOLLO_GRAPH_REF="YOUR_APOLLO_GRAPH_REF" cargo run -- --configuration router.yaml +``` diff --git a/examples/coprocessor-surrogate-cache-key/nodejs/package.json b/examples/coprocessor-surrogate-cache-key/nodejs/package.json new file mode 100644 index 0000000000..1c3ef66300 --- /dev/null +++ b/examples/coprocessor-surrogate-cache-key/nodejs/package.json @@ -0,0 +1,15 @@ +{ + "name": "coprocessor", + "version": "1.0.0", + "description": "A coprocessor example for the router", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "express": "^4.18.2" + } +} \ No newline at end of file diff --git a/examples/coprocessor-surrogate-cache-key/nodejs/router.yaml b/examples/coprocessor-surrogate-cache-key/nodejs/router.yaml new file mode 100644 index 0000000000..d3b36e3966 --- /dev/null +++ b/examples/coprocessor-surrogate-cache-key/nodejs/router.yaml @@ -0,0 +1,36 @@ +supergraph: + listen: 127.0.0.1:4000 + introspection: true +sandbox: + enabled: true +homepage: + enabled: false +include_subgraph_errors: + all: true # Propagate errors from all subraphs + +coprocessor: + url: http://127.0.0.1:3000 # mandatory URL which is the address of the coprocessor + supergraph: + response: + context: true + subgraph: + all: + response: + subgraph_request_id: true + context: true +preview_entity_cache: + enabled: true + expose_keys_in_context: true + metrics: + enabled: true + invalidation: + listen: 0.0.0.0:4000 + path: /invalidation + # Configure entity caching per subgraph + subgraph: + all: + enabled: true + # Configure Redis + redis: + urls: ["redis://localhost:6379"] + ttl: 24h # Optional, by default no expiration \ No newline at end of file diff --git a/examples/coprocessor-surrogate-cache-key/nodejs/src/index.js b/examples/coprocessor-surrogate-cache-key/nodejs/src/index.js new file mode 100644 index 0000000000..daadb50740 --- /dev/null +++ b/examples/coprocessor-surrogate-cache-key/nodejs/src/index.js @@ -0,0 +1,110 @@ +const express = require("express"); +const app = express(); +const port = 3000; + +app.use(express.json()); + +// This is for demo purpose and will keep growing over the time +// It saves the value of surrogate cache keys returned by a subgraph request +let surrogateKeys = new Map(); +// Example: +// { +// "​​0e67db40-e98d-4ad7-bb60-2012fb5db504": [ +// "elections", +// "sp500" +// ], +// "​​0d77db40-e98d-4ad7-bb60-2012fb5db555": [ +// "homepage" +// ] +// } +// -------------- +// For every surrogate cache key we know the related cache keys +// Example: +// { +// "elections": [ +// "version:1.0:subgraph:reviews:type:Product:entity:4e48855987eae27208b466b941ecda5fb9b88abc03301afef6e4099a981889e9:hash:1de543dab57fde0f00247922ccc4f76d4c916ae26a89dd83cd1a62300d0cda20:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" +// ], +// "sp500": [ +// "version:1.0:subgraph:reviews:type:Product:entity:4e48855987eae27208b466b941ecda5fb9b88abc03301afef6e4099a981889e9:hash:1de543dab57fde0f00247922ccc4f76d4c916ae26a89dd83cd1a62300d0cda20:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" +// ] +// } + +app.post("/", (req, res) => { + const request = req.body; + console.log("✉️ Got payload:"); + console.log(JSON.stringify(request, null, 2)); + switch (request.stage) { + case "SubgraphResponse": + request.headers["surrogate-keys"] = ["homepage, feed"]; // To simulate + // Fetch the surrogate keys returned by the subgraph to create a mapping between subgraph request id and surrogate keys, to create the final mapping later + // Example: + // { + // "​​0e67db40-e98d-4ad7-bb60-2012fb5db504": [ + // "elections", + // "sp500" + // ] + // } + if (request.headers["surrogate-keys"] && request.subgraphRequestId) { + let keys = request.headers["surrogate-keys"] + .join(",") + .split(",") + .map((k) => k.trim()); + + surrogateKeys.set(request.subgraphRequestId, keys); + console.log("surrogateKeys", surrogateKeys); + } + break; + case "SupergraphResponse": + if ( + request.context && + request.context.entries && + request.context.entries["apollo::entity_cache::cached_keys_status"] + ) { + let contextEntry = + request.context.entries["apollo::entity_cache::cached_keys_status"]; + let mapping = {}; + Object.keys(contextEntry).forEach((request_id) => { + let cache_keys = contextEntry[`${request_id}`]; + let surrogateCachekeys = surrogateKeys.get(request_id); + if (surrogateCachekeys) { + // Create the mapping between surrogate cache keys and effective cache keys + // Example: + // { + // "elections": [ + // "version:1.0:subgraph:reviews:type:Product:entity:4e48855987eae27208b466b941ecda5fb9b88abc03301afef6e4099a981889e9:hash:1de543dab57fde0f00247922ccc4f76d4c916ae26a89dd83cd1a62300d0cda20:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" + // ], + // "sp500": [ + // "version:1.0:subgraph:reviews:type:Product:entity:4e48855987eae27208b466b941ecda5fb9b88abc03301afef6e4099a981889e9:hash:1de543dab57fde0f00247922ccc4f76d4c916ae26a89dd83cd1a62300d0cda20:data:d9d84a3c7ffc27b0190a671212f3740e5b8478e84e23825830e97822e25cf05c" + // ] + // } + + surrogateCachekeys.reduce((acc, current) => { + if (acc[`${current}`]) { + acc[`${current}`] = acc[`${current}`].concat(cache_keys); + } else { + acc[`${current}`] = cache_keys; + } + + return acc; + }, mapping); + } + }); + + console.log(mapping); + } + break; + default: + return res.json(request); + } + res.json(request); +}); + +app.listen(port, () => { + console.log(`🚀 Coprocessor running on port ${port}`); + console.log( + `Run a router with the provided router.yaml configuration to test the example:` + ); + console.log( + `APOLLO_KEY="YOUR_APOLLO_KEY" APOLLO_GRAPH_REF="YOUR_APOLLO_GRAPH_REF" cargo run -- --configuration router.yaml` + ); +});