diff --git a/.gitleaks.toml b/.gitleaks.toml index 1a8bc65a01..01a4b975c9 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -23,6 +23,8 @@ "77700b93798ce98eaf75c3b02b70198e7b730ddb", # not a key: https://github.com/apollographql/router/pull/8326#issuecomment-3325655427 "58bca0271d5a2046dfa7705a5418781bc456c787", + # Cache keys, not secret keys + "165156910be987f1d28b0c0013fe45ce2ccd23c6" ] paths = [ @@ -129,6 +131,7 @@ [ rules.allowlist ] paths = [ '''^docs/source/routing/performance/caching/response-caching/invalidation.mdx$''', + '''^docs/source/routing/performance/caching/response-caching/customization.mdx$''', ] [[ rules ]] diff --git a/apollo-federation/src/connectors/expand/tests/mod.rs b/apollo-federation/src/connectors/expand/tests/mod.rs index 862872ead5..d355e87943 100644 --- a/apollo-federation/src/connectors/expand/tests/mod.rs +++ b/apollo-federation/src/connectors/expand/tests/mod.rs @@ -7,6 +7,8 @@ use insta::glob; use crate::ApiSchemaOptions; use crate::connectors::expand::ExpansionResult; use crate::connectors::expand::expand_connectors; +use crate::schema::FederationSchema; +use crate::supergraph::extract_subgraphs_from_supergraph; #[test] fn it_expand_supergraph() { @@ -29,6 +31,32 @@ fn it_expand_supergraph() { }); } +/// @cacheTag: The expanded supergraph's @join__directive `graphs` +/// list includes all synthetic connector subgraphs, but only one owns the +/// field — `extract_subgraphs_from_supergraph` must tolerate this. +#[test] +fn cache_tag_on_connector_field_does_not_crash_extraction() { + let to_expand = read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/connectors/expand/tests/schemas/expand/cache_tag_on_connector.graphql" + )) + .unwrap(); + + let ExpansionResult::Expanded { raw_sdl, .. } = + expand_connectors(&to_expand, &ApiSchemaOptions::default()).unwrap() + else { + panic!("expected expansion"); + }; + + let schema = apollo_compiler::Schema::parse_and_validate(&raw_sdl, "expanded.graphql") + .expect("expanded supergraph should be valid GraphQL"); + let fed_schema = + FederationSchema::new(schema.into_inner()).expect("should create FederationSchema"); + + extract_subgraphs_from_supergraph(&fed_schema, Some(true)) + .expect("extract_subgraphs_from_supergraph should succeed"); +} + #[test] fn it_ignores_supergraph() { insta::with_settings!({prepend_module_to_snapshot => false}, { diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/cache_tag_on_connector.graphql b/apollo-federation/src/connectors/expand/tests/schemas/expand/cache_tag_on_connector.graphql new file mode 100644 index 0000000000..bfee9f5f6a --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/cache_tag_on_connector.graphql @@ -0,0 +1,78 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @link(url: "https://specs.apollo.dev/cacheTag/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "example", http: {baseURL: "http://example"}}) +{ + query: Query +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar link__Import + +enum link__Purpose { + """ + `SECURITY` features provide metadata necessary to securely resolve fields. + """ + SECURITY + + """ + `EXECUTION` features provide metadata necessary for operation execution. + """ + EXECUTION +} + +type Product + @join__type(graph: CONNECTORS, key: "id") +{ + id: ID! + title: String @join__field(graph: CONNECTORS) +} + +type Query + @join__type(graph: CONNECTORS) +{ + users: [User] @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "federation__cacheTag", args: {format: "users"}) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "example", http: {GET: "/users"}, selection: "id name"}) + products: [Product] @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "example", http: {GET: "/products"}, selection: "id title"}) +} + +type User + @join__type(graph: CONNECTORS, key: "id") +{ + id: ID! + name: String @join__field(graph: CONNECTORS) +} diff --git a/apollo-federation/src/connectors/expand/tests/schemas/expand/cache_tag_on_connector.yaml b/apollo-federation/src/connectors/expand/tests/schemas/expand/cache_tag_on_connector.yaml new file mode 100644 index 0000000000..0d8fa49b20 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/schemas/expand/cache_tag_on_connector.yaml @@ -0,0 +1,27 @@ +subgraphs: + connectors: + routing_url: none + schema: + sdl: | + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.12" + import: ["@key", "@cacheTag"] + ) + @link(url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]) + @source(name: "example", http: { baseURL: "http://example" }) + + type Query { + users: [User] @cacheTag(format: "users") @connect(source: "example", http: { GET: "/users" }, selection: "id name") + products: [Product] @connect(source: "example", http: { GET: "/products" }, selection: "id title") + } + + type User @key(fields: "id") { + id: ID! + name: String + } + + type Product @key(fields: "id") { + id: ID! + title: String + } diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/api@cache_tag_on_connector.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/api@cache_tag_on_connector.graphql.snap new file mode 100644 index 0000000000..58db3f9a82 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/api@cache_tag_on_connector.graphql.snap @@ -0,0 +1,21 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: api_schema +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/cache_tag_on_connector.graphql +--- +directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +type Product { + id: ID! + title: String +} + +type Query { + users: [User] + products: [Product] +} + +type User { + id: ID! + name: String +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/connectors@cache_tag_on_connector.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@cache_tag_on_connector.graphql.snap new file mode 100644 index 0000000000..4dcef9e1a3 --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/connectors@cache_tag_on_connector.graphql.snap @@ -0,0 +1,277 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: connectors.by_service_name +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/cache_tag_on_connector.graphql +--- +{ + "connectors_Query_users_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "example", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.users), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example", + location: 0..14, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/users", + location: 0..6, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "name", + ), + range: Some( + 3..7, + ), + }, + WithRange { + node: Empty, + range: Some( + 7..7, + ), + }, + ), + range: Some( + 3..7, + ), + }, + }, + }, + ], + range: Some( + 0..7, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + schema_subtypes_map: { + "_Entity": { + "Product", + "User", + }, + }, + request_headers: {}, + response_headers: {}, + request_variable_keys: {}, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.example http: GET /users", + ), + }, + "connectors_Query_products_0": Connector { + id: ConnectId { + subgraph_name: "connectors", + source_name: Some( + "example", + ), + named: None, + directive: Field( + ObjectOrInterfaceFieldDirectivePosition { + field: Object(Query.products), + directive_name: "connect", + directive_index: 0, + }, + ), + }, + transport: HttpJsonTransport { + source_template: Some( + StringTemplate { + parts: [ + Constant( + Constant { + value: "http://example", + location: 0..14, + }, + ), + ], + }, + ), + connect_template: StringTemplate { + parts: [ + Constant( + Constant { + value: "/products", + location: 0..9, + }, + ), + ], + }, + method: Get, + headers: [], + body: None, + source_path: None, + source_query_params: None, + connect_path: None, + connect_query_params: None, + }, + selection: JSONSelection { + inner: Named( + SubSelection { + selections: [ + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "id", + ), + range: Some( + 0..2, + ), + }, + WithRange { + node: Empty, + range: Some( + 2..2, + ), + }, + ), + range: Some( + 0..2, + ), + }, + }, + }, + NamedSelection { + prefix: None, + path: PathSelection { + path: WithRange { + node: Key( + WithRange { + node: Field( + "title", + ), + range: Some( + 3..8, + ), + }, + WithRange { + node: Empty, + range: Some( + 8..8, + ), + }, + ), + range: Some( + 3..8, + ), + }, + }, + }, + ], + range: Some( + 0..8, + ), + }, + ), + spec: V0_1, + }, + config: None, + max_requests: None, + entity_resolver: None, + spec: V0_1, + schema_subtypes_map: { + "_Entity": { + "Product", + "User", + }, + }, + request_headers: {}, + response_headers: {}, + request_variable_keys: {}, + response_variable_keys: {}, + batch_settings: None, + error_settings: ConnectorErrorsSettings { + message: None, + source_extensions: None, + connect_extensions: None, + connect_is_success: None, + }, + label: Label( + "connectors.example http: GET /products", + ), + }, +} diff --git a/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@cache_tag_on_connector.graphql.snap b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@cache_tag_on_connector.graphql.snap new file mode 100644 index 0000000000..2f0affd4cd --- /dev/null +++ b/apollo-federation/src/connectors/expand/tests/snapshots/supergraph@cache_tag_on_connector.graphql.snap @@ -0,0 +1,68 @@ +--- +source: apollo-federation/src/connectors/expand/tests/mod.rs +expression: raw_sdl +input_file: apollo-federation/src/connectors/expand/tests/schemas/expand/cache_tag_on_connector.graphql +--- +schema @link(url: "https://specs.apollo.dev/link/v1.0") @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) @join__directive(graphs: [CONNECTORS_QUERY_USERS_0, CONNECTORS_QUERY_PRODUCTS_0], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) { + query: Query +} + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on ENUM | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, overrideLabel: String, usedOverridden: Boolean, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on INTERFACE | OBJECT + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments!) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +enum link__Purpose { + """ + SECURITY features provide metadata necessary to securely resolve fields. + """ + SECURITY + """EXECUTION features provide metadata necessary for operation execution.""" + EXECUTION +} + +scalar link__Import + +scalar join__FieldSet + +scalar join__FieldValue + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +enum join__Graph { + CONNECTORS_QUERY_PRODUCTS_0 @join__graph(name: "connectors_Query_products_0", url: "none") + CONNECTORS_QUERY_USERS_0 @join__graph(name: "connectors_Query_users_0", url: "none") +} + +type Product @join__type(graph: CONNECTORS_QUERY_PRODUCTS_0) { + id: ID! @join__field(graph: CONNECTORS_QUERY_PRODUCTS_0, type: "ID!") + title: String @join__field(graph: CONNECTORS_QUERY_PRODUCTS_0, type: "String") +} + +type Query @join__type(graph: CONNECTORS_QUERY_PRODUCTS_0) @join__type(graph: CONNECTORS_QUERY_USERS_0) { + products: [Product] @join__field(graph: CONNECTORS_QUERY_PRODUCTS_0, type: "[Product]") + users: [User] @join__field(graph: CONNECTORS_QUERY_USERS_0, type: "[User]") @join__directive(graphs: [CONNECTORS_QUERY_USERS_0, CONNECTORS_QUERY_PRODUCTS_0], name: "federation__cacheTag", args: {format: "users"}) +} + +type User @join__type(graph: CONNECTORS_QUERY_USERS_0) { + id: ID! @join__field(graph: CONNECTORS_QUERY_USERS_0, type: "ID!") + name: String @join__field(graph: CONNECTORS_QUERY_USERS_0, type: "String") +} diff --git a/apollo-federation/src/connectors/mod.rs b/apollo-federation/src/connectors/mod.rs index 527590811b..e28c6f717e 100644 --- a/apollo-federation/src/connectors/mod.rs +++ b/apollo-federation/src/connectors/mod.rs @@ -87,7 +87,7 @@ impl ConnectId { /// Until we have a source-aware query planner, we'll need to split up connectors into /// their own subgraphs when doing planning. Each subgraph will need a name, so we /// synthesize one using metadata present on the directive. - pub(crate) fn synthetic_name(&self) -> String { + pub fn synthetic_name(&self) -> String { format!("{}_{}", self.subgraph_name, self.directive.synthetic_name()) } diff --git a/apollo-federation/src/connectors/runtime/errors.rs b/apollo-federation/src/connectors/runtime/errors.rs index 74cfa106e9..9b6924ae49 100644 --- a/apollo-federation/src/connectors/runtime/errors.rs +++ b/apollo-federation/src/connectors/runtime/errors.rs @@ -86,6 +86,9 @@ pub enum Error { #[error("Connector error: {0}")] TransportFailure(String), + + #[error("Invalid cache-control header: {0}")] + InvalidCacheControl(String), } impl Error { @@ -110,6 +113,7 @@ impl Error { Self::RateLimited => "REQUEST_RATE_LIMITED", Self::GatewayTimeout => "GATEWAY_TIMEOUT", Self::TransportFailure(_) => "HTTP_CLIENT_ERROR", + Self::InvalidCacheControl(_) => "INVALID_CACHE_CONTROL_HEADER", } } } diff --git a/apollo-federation/src/connectors/runtime/http_json_transport.rs b/apollo-federation/src/connectors/runtime/http_json_transport.rs index 602fc98546..8dc1018a74 100644 --- a/apollo-federation/src/connectors/runtime/http_json_transport.rs +++ b/apollo-federation/src/connectors/runtime/http_json_transport.rs @@ -53,6 +53,8 @@ pub enum TransportRequest { pub enum TransportResponse { /// A response from an HTTP transport Http(HttpResponse), + /// A response served from cache (no HTTP transport involved) + CacheHit, } impl From for TransportRequest { diff --git a/apollo-federation/src/supergraph/join_directive.rs b/apollo-federation/src/supergraph/join_directive.rs index c28c6cb4b9..d4a410ea02 100644 --- a/apollo-federation/src/supergraph/join_directive.rs +++ b/apollo-federation/src/supergraph/join_directive.rs @@ -115,6 +115,13 @@ pub(super) fn extract( &subgraph_enum_value, )?; + // Skip if the field doesn't exist in this subgraph (e.g. expanded + // connector subgraphs where graphs: includes all synthetic subgraphs + // but only one owns the field) + if object_field_pos.try_get(subgraph.schema.schema()).is_none() { + continue; + } + object_field_pos .insert_directive(&mut subgraph.schema, Node::new(directive.clone()))?; } @@ -195,6 +202,10 @@ pub(super) fn extract( .map(|t| matches!(t, TypeDefinitionPosition::Interface(_))) .unwrap_or_default() { + // Skip if the field doesn't exist in this subgraph + if intf_field_pos.try_get(subgraph.schema.schema()).is_none() { + continue; + } intf_field_pos .insert_directive(&mut subgraph.schema, Node::new(directive.clone()))?; } else { @@ -203,6 +214,10 @@ pub(super) fn extract( type_name: intf_field_pos.type_name.clone(), field_name: intf_field_pos.field_name.clone(), }; + // Skip if the field doesn't exist in this subgraph + if object_field_pos.try_get(subgraph.schema.schema()).is_none() { + continue; + } object_field_pos .insert_directive(&mut subgraph.schema, Node::new(directive.clone()))?; } 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 7997775aca..6e6f9bf948 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 @@ -2033,6 +2033,14 @@ expression: "&schema" "additionalProperties": false, "description": "Configuration for response caching", "properties": { + "connector": { + "allOf": [ + { + "$ref": "#/definitions/ConnectorCacheConfiguration" + } + ], + "description": "Configure response caching per connector source" + }, "debug": { "default": false, "description": "Enable debug mode for the debugger", @@ -2067,12 +2075,19 @@ expression: "&schema" "$ref": "#/definitions/SubgraphSubgraphConfiguration2" } ], + "default": { + "all": { + "enabled": true, + "invalidation": null, + "private_id": null, + "redis": null, + "ttl": null + }, + "subgraphs": {} + }, "description": "Configure invalidation per subgraph" } }, - "required": [ - "subgraph" - ], "type": "object" }, "Config9": { @@ -2193,6 +2208,95 @@ expression: "&schema" ], "type": "object" }, + "ConnectorCacheConfiguration": { + "additionalProperties": false, + "description": "Per connector source configuration for response caching", + "properties": { + "all": { + "allOf": [ + { + "$ref": "#/definitions/ConnectorCacheSource" + } + ], + "default": { + "enabled": null, + "invalidation": null, + "private_id": null, + "redis": null, + "ttl": null + }, + "description": "Options applying to all connector sources" + }, + "sources": { + "additionalProperties": { + "$ref": "#/definitions/ConnectorCacheSource" + }, + "default": {}, + "description": "Map of subgraph_name.connector_source_name to configuration", + "type": "object" + } + }, + "type": "object" + }, + "ConnectorCacheSource": { + "additionalProperties": false, + "description": "Per connector source configuration for response caching", + "properties": { + "enabled": { + "default": null, + "description": "Activates caching for this connector source, overrides the global configuration", + "type": [ + "boolean", + "null" + ] + }, + "invalidation": { + "anyOf": [ + { + "$ref": "#/definitions/SubgraphInvalidationConfig2" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Invalidation configuration" + }, + "private_id": { + "default": null, + "description": "Context key used to separate cache sections per user", + "type": [ + "string", + "null" + ] + }, + "redis": { + "anyOf": [ + { + "$ref": "#/definitions/Config9" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Redis configuration" + }, + "ttl": { + "anyOf": [ + { + "$ref": "#/definitions/Ttl2" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Expiration for all keys for this connector source, unless overridden by the `Cache-Control` header in connector responses" + } + }, + "type": "object" + }, "ConnectorConfiguration": { "properties": { "all": { diff --git a/apollo-router/src/plugin/mod.rs b/apollo-router/src/plugin/mod.rs index bab3a4985d..f5911fc2a9 100644 --- a/apollo-router/src/plugin/mod.rs +++ b/apollo-router/src/plugin/mod.rs @@ -630,6 +630,14 @@ pub(crate) trait PluginPrivate: Send + Sync + 'static { service } + /// This service handles connector execution (wrapping individual connector requests) + fn connector_service( + &self, + service: crate::services::connect::BoxService, + ) -> crate::services::connect::BoxService { + service + } + /// Return the name of the plugin. fn name(&self) -> &'static str where @@ -747,6 +755,12 @@ pub(crate) trait DynPlugin: Send + Sync + 'static { source_name: String, ) -> crate::services::connector::request_service::BoxService; + /// This service handles connector execution (wrapping individual connector requests) + fn connector_service( + &self, + service: crate::services::connect::BoxService, + ) -> crate::services::connect::BoxService; + /// Return the name of the plugin. fn name(&self) -> &'static str; @@ -804,6 +818,13 @@ where self.connector_request_service(service, source_name) } + fn connector_service( + &self, + service: crate::services::connect::BoxService, + ) -> crate::services::connect::BoxService { + self.connector_service(service) + } + fn name(&self) -> &'static str { self.name() } diff --git a/apollo-router/src/plugins/coprocessor/connector.rs b/apollo-router/src/plugins/coprocessor/connector.rs index 7650bef6ff..8242cf559a 100644 --- a/apollo-router/src/plugins/coprocessor/connector.rs +++ b/apollo-router/src/plugins/coprocessor/connector.rs @@ -428,7 +428,7 @@ where .then(|| http_response.inner.status.as_u16()); (headers, status) } - Err(_) => (None, None), + Ok(TransportResponse::CacheHit) | Err(_) => (None, None), }; // Extract body from mapped response diff --git a/apollo-router/src/plugins/response_cache/cache_control.rs b/apollo-router/src/plugins/response_cache/cache_control.rs index 79197dc0a5..2d06e5ff7a 100644 --- a/apollo-router/src/plugins/response_cache/cache_control.rs +++ b/apollo-router/src/plugins/response_cache/cache_control.rs @@ -145,7 +145,10 @@ impl CacheControl { } } - if !found { + // Only mark as no_store when there's no Cache-Control header AND no default_ttl. + // When a default_ttl is configured, it's already set as max_age above, so we + // should allow caching with that TTL even without an explicit Cache-Control header. + if !found && default_ttl.is_none() { result.no_store = true; } diff --git a/apollo-router/src/plugins/response_cache/cache_key.rs b/apollo-router/src/plugins/response_cache/cache_key.rs index e2e92abe58..70b55eecb8 100644 --- a/apollo-router/src/plugins/response_cache/cache_key.rs +++ b/apollo-router/src/plugins/response_cache/cache_key.rs @@ -106,6 +106,121 @@ impl<'a> PrimaryCacheKeyEntity<'a> { } } +/// Cache key for connector root field +pub(super) struct ConnectorCacheKeyRoot<'a> { + pub(super) source_name: &'a str, + pub(super) graphql_type: &'a str, + pub(super) operation_hash: &'a str, + pub(super) additional_data_hash: &'a str, + pub(super) private_id: Option<&'a str>, +} + +impl<'a> ConnectorCacheKeyRoot<'a> { + pub(super) fn hash(&self) -> String { + let Self { + source_name, + graphql_type, + operation_hash, + additional_data_hash, + private_id, + } = self; + + let mut key = format!( + "version:{RESPONSE_CACHE_VERSION}:connector:{source_name}:type:{graphql_type}:hash:{operation_hash}:data:{additional_data_hash}" + ); + if let Some(private_id) = private_id { + let _ = write!(&mut key, ":{private_id}"); + } + + key + } +} + +/// Cache key for a connector entity +pub(super) struct ConnectorCacheKeyEntity<'a> { + pub(super) source_name: &'a str, + pub(super) entity_type: &'a str, + pub(super) representation: &'a Map, + pub(super) operation_hash: &'a str, + pub(super) additional_data_hash: &'a str, + pub(super) private_id: Option<&'a str>, +} + +impl<'a> ConnectorCacheKeyEntity<'a> { + pub(super) fn hash(&mut self) -> String { + let Self { + source_name, + entity_type, + operation_hash, + additional_data_hash, + private_id, + representation, + } = self; + + let hashed_representation = if representation.is_empty() { + String::new() + } else { + sort_and_hash_object(representation) + }; + + let mut key = format!( + "version:{RESPONSE_CACHE_VERSION}:connector:{source_name}:type:{entity_type}:representation:{hashed_representation}:hash:{operation_hash}:data:{additional_data_hash}" + ); + + if let Some(private_id) = private_id { + let _ = write!(&mut key, ":{private_id}"); + } + + key + } +} + +/// Hash an operation document for use as a connector query hash +pub(super) fn hash_operation(operation: &str) -> String { + let mut digest = blake3::Hasher::new(); + digest.update(operation.as_bytes()); + digest.update(&[0u8; 1][..]); + digest.finalize().to_hex().to_string() +} + +/// Hash additional data for connector cache keys. +/// Similar to `hash_additional_data` but works with connector `Variables` instead of `graphql::Request`. +pub(super) fn hash_connector_additional_data( + source_name: &str, + variables: &Object, + context: &Context, + cache_key: &CacheKeyMetadata, +) -> String { + let mut hasher = blake3::Hasher::new(); + + let repr_key = ByteString::from(REPRESENTATIONS); + hash( + &mut hasher, + variables.iter().filter(|(key, _value)| key != &&repr_key), + ); + + cache_key + .serialize(Blake3Serializer::new(&mut hasher)) + .expect("this serializer doesn't throw any errors; qed"); + + // Takes value specific for a connector source, if it doesn't exist take value for all + if let Ok(Some(cache_data)) = context.get::<&str, Object>(CONTEXT_CACHE_KEY) { + if let Some(v) = cache_data + .get("connectors") + .and_then(|s| s.as_object()) + .and_then(|connector_data| connector_data.get(source_name)) + { + v.serialize(Blake3Serializer::new(&mut hasher)) + .expect("this serializer doesn't throw any errors; qed"); + } else if let Some(v) = cache_data.get("all") { + v.serialize(Blake3Serializer::new(&mut hasher)) + .expect("this serializer doesn't throw any errors; qed"); + } + } + + hasher.finalize().to_hex().to_string() +} + /// Hash subgraph query pub(super) fn hash_query(query_hash: &QueryHash) -> String { let mut digest = blake3::Hasher::new(); @@ -293,4 +408,187 @@ mod tests { assert_snapshot!(value1); assert_snapshot!(value2); } + + #[test] + fn connector_root_cache_key_format() { + let key = ConnectorCacheKeyRoot { + source_name: "mysubgraph.my_api", + graphql_type: "Query", + operation_hash: "abc123", + additional_data_hash: "def456", + private_id: None, + }; + let hash = key.hash(); + assert!(hash.starts_with("version:")); + assert!(hash.contains(":connector:mysubgraph.my_api:")); + assert!(hash.contains(":type:Query:")); + assert!(hash.contains(":hash:abc123:")); + assert!(hash.contains(":data:def456")); + assert!(!hash.contains(":subgraph:")); + assert_snapshot!(hash); + } + + #[test] + fn connector_root_cache_key_with_private_id() { + let without_private = ConnectorCacheKeyRoot { + source_name: "mysubgraph.my_api", + graphql_type: "Query", + operation_hash: "abc123", + additional_data_hash: "def456", + private_id: None, + }; + let with_private = ConnectorCacheKeyRoot { + source_name: "mysubgraph.my_api", + graphql_type: "Query", + operation_hash: "abc123", + additional_data_hash: "def456", + private_id: Some("user_hash_xyz"), + }; + let hash_without = without_private.hash(); + let hash_with = with_private.hash(); + assert!(hash_with.ends_with(":user_hash_xyz")); + assert!(!hash_without.contains("user_hash_xyz")); + } + + #[test] + fn connector_entity_cache_key_format() { + let repr = serde_json_bytes::json!({"id": "1"}); + let mut key = ConnectorCacheKeyEntity { + source_name: "mysubgraph.my_api", + entity_type: "User", + representation: repr.as_object().unwrap(), + operation_hash: "abc123", + additional_data_hash: "def456", + private_id: None, + }; + let hash = key.hash(); + assert!(hash.contains(":connector:mysubgraph.my_api:")); + assert!(hash.contains(":type:User:")); + assert!(hash.contains(":representation:")); + assert!(!hash.contains(":subgraph:")); + assert_snapshot!(hash); + } + + #[test] + fn connector_entity_cache_key_empty_representation() { + let repr = serde_json_bytes::Map::new(); + let mut key = ConnectorCacheKeyEntity { + source_name: "mysubgraph.my_api", + entity_type: "User", + representation: &repr, + operation_hash: "abc123", + additional_data_hash: "def456", + private_id: None, + }; + let hash = key.hash(); + assert!(hash.contains(":representation::hash:")); + } + + #[test] + fn connector_entity_cache_key_representation_changes_hash() { + let repr1 = serde_json_bytes::json!({"id": "1"}); + let repr2 = serde_json_bytes::json!({"id": "2"}); + let mut key1 = ConnectorCacheKeyEntity { + source_name: "mysubgraph.my_api", + entity_type: "User", + representation: repr1.as_object().unwrap(), + operation_hash: "abc123", + additional_data_hash: "def456", + private_id: None, + }; + let mut key2 = ConnectorCacheKeyEntity { + source_name: "mysubgraph.my_api", + entity_type: "User", + representation: repr2.as_object().unwrap(), + operation_hash: "abc123", + additional_data_hash: "def456", + private_id: None, + }; + assert_ne!(key1.hash(), key2.hash()); + } + + #[test] + fn hash_operation_deterministic() { + let hash1 = hash_operation("query { users { id name } }"); + let hash2 = hash_operation("query { users { id name } }"); + assert_eq!(hash1, hash2); + assert_snapshot!(hash1); + } + + #[test] + fn hash_operation_different_input() { + let hash1 = hash_operation("query { users { id name } }"); + let hash2 = hash_operation("query { posts { id title } }"); + assert_ne!(hash1, hash2); + } + + #[test] + fn hash_connector_additional_data_source_specific() { + let context = Context::new(); + context.insert_json_value( + CONTEXT_CACHE_KEY, + serde_json_bytes::json!({ + "all": { "locale": "en" }, + "connectors": { + "source_a": { "foo": "bar" }, + "source_b": { "baz": "qux" } + } + }), + ); + let vars = serde_json_bytes::json!({"key": "value"}); + let hash_a = hash_connector_additional_data( + "source_a", + vars.as_object().unwrap(), + &context, + &Default::default(), + ); + let hash_b = hash_connector_additional_data( + "source_b", + vars.as_object().unwrap(), + &context, + &Default::default(), + ); + assert_ne!(hash_a, hash_b); + } + + #[test] + fn hash_connector_additional_data_fallback_to_all() { + let context = Context::new(); + context.insert_json_value( + CONTEXT_CACHE_KEY, + serde_json_bytes::json!({ + "all": { "locale": "en" }, + "connectors": { + "source_a": { "foo": "bar" } + } + }), + ); + let vars = serde_json_bytes::json!({"key": "value"}); + let hash_unknown_1 = hash_connector_additional_data( + "unknown_source", + vars.as_object().unwrap(), + &context, + &Default::default(), + ); + let hash_unknown_2 = hash_connector_additional_data( + "another_unknown", + vars.as_object().unwrap(), + &context, + &Default::default(), + ); + assert_eq!(hash_unknown_1, hash_unknown_2); + } + + #[test] + fn connector_key_uses_connector_prefix() { + let connector_key = ConnectorCacheKeyRoot { + source_name: "test_source", + graphql_type: "Query", + operation_hash: "hash", + additional_data_hash: "data", + private_id: None, + }; + assert!(connector_key.hash().contains(":connector:")); + assert!(!connector_key.hash().contains(":subgraph:")); + } } diff --git a/apollo-router/src/plugins/response_cache/connectors.rs b/apollo-router/src/plugins/response_cache/connectors.rs new file mode 100644 index 0000000000..bac17763f7 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/connectors.rs @@ -0,0 +1,1725 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use apollo_compiler::Schema; +use apollo_compiler::collections::IndexMap; +use apollo_compiler::validation::Valid; +use apollo_federation::connectors::StringTemplate; +use http::HeaderValue; +use http::header::CACHE_CONTROL; +use lru::LruCache; +use opentelemetry::Array; +use opentelemetry::Key; +use opentelemetry::StringValue; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json_bytes::ByteString; +use serde_json_bytes::Value; +use tokio::sync::RwLock; +use tower::BoxError; +use tower_service::Service; +use tracing::Instrument; +use tracing::Span; + +use super::cache_control::CacheControl; +use super::invalidation_endpoint::SubgraphInvalidationConfig; +use super::metrics::CacheMetricContextKey; +use super::metrics::record_fetch_error; +use super::plugin::CACHE_DEBUG_HEADER_NAME; +use super::plugin::CACHE_TAG_DIRECTIVE_NAME; +use super::plugin::CacheHitMiss; +use super::plugin::CacheSubgraph; +use super::plugin::ENTITIES; +use super::plugin::INTERNAL_CACHE_TAG_PREFIX; +use super::plugin::IntermediateResult; +use super::plugin::PrivateQueryKey; +use super::plugin::REPRESENTATIONS; +use super::plugin::RESPONSE_CACHE_VERSION; +use super::plugin::StorageInterface; +use super::plugin::Ttl; +use super::plugin::assemble_response_from_errors; +use super::plugin::external_invalidation_keys; +use super::plugin::get_invalidation_entity_keys_from_schema; +use super::plugin::hash_private_id; +use super::plugin::update_cache_control; +use super::storage; +use super::storage::CacheEntry; +use super::storage::CacheStorage; +use super::storage::Document; +use super::storage::redis::Storage; +use crate::Context; +use crate::context::OPERATION_KIND; +use crate::error::FetchError; +use crate::graphql; +use crate::json_ext::Object; +use crate::json_ext::Path; +use crate::json_ext::PathElement; +use crate::plugins::authorization::CacheKeyMetadata; +use crate::plugins::connectors::query_plans::get_connectors; +use crate::plugins::response_cache::cache_key::ConnectorCacheKeyEntity; +use crate::plugins::response_cache::cache_key::ConnectorCacheKeyRoot; +use crate::plugins::response_cache::cache_key::hash_connector_additional_data; +use crate::plugins::response_cache::cache_key::hash_operation; +use crate::plugins::response_cache::debugger::CacheEntryKind; +use crate::plugins::response_cache::debugger::CacheKeyContext; +use crate::plugins::response_cache::debugger::CacheKeySource; +use crate::plugins::response_cache::debugger::add_cache_key_to_context; +use crate::plugins::response_cache::debugger::add_cache_keys_to_context; +use crate::plugins::telemetry::LruSizeInstrument; +use crate::plugins::telemetry::dynamic_attribute::SpanDynAttribute; +use crate::plugins::telemetry::span_ext::SpanMarkError; +use crate::query_planner::OperationKind; +use crate::services::connect; +use crate::services::connector; +use crate::spec::TYPENAME; + +/// Per connector source configuration for response caching +#[derive(Clone, Debug, Default, JsonSchema, Deserialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields, default)] +pub(crate) struct ConnectorCacheConfiguration { + /// Options applying to all connector sources + pub(crate) all: ConnectorCacheSource, + + /// Map of subgraph_name.connector_source_name to configuration + #[serde(default)] + pub(crate) sources: HashMap, +} + +impl ConnectorCacheConfiguration { + /// Get the configuration for a specific connector source, falling back to `all`. + pub(crate) fn get(&self, source_name: &str) -> &ConnectorCacheSource { + self.sources.get(source_name).unwrap_or(&self.all) + } + + /// Returns whether caching is enabled for a specific connector source. + pub(super) fn is_source_enabled(&self, plugin_enabled: bool, source_name: &str) -> bool { + if !plugin_enabled { + return false; + } + match (self.all.enabled, self.get(source_name).enabled) { + (_, Some(x)) => x, // explicit per-source setting overrides the `all` default + (Some(true) | None, None) => true, + (Some(false), None) => false, + } + } +} + +/// Per connector source configuration for response caching +#[derive(Clone, Debug, Default, JsonSchema, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", deny_unknown_fields, default)] +pub(crate) struct ConnectorCacheSource { + /// Redis configuration + pub(crate) redis: Option, + + /// Expiration for all keys for this connector source, unless overridden by the `Cache-Control` header in connector responses + pub(crate) ttl: Option, + + /// Activates caching for this connector source, overrides the global configuration + pub(crate) enabled: Option, + + /// Context key used to separate cache sections per user + pub(crate) private_id: Option, + + /// Invalidation configuration + pub(crate) invalidation: Option, +} + +// --- Connector Cache Service --- + +/// Cached entity results stored in context extensions for merging back into the connector response. +#[derive(Default)] +struct ConnectorCachedEntities { + /// The intermediate results from the cache lookup, indexed by original position + results: Vec, + /// The cache control from cached entries + cache_control: Option, +} + +#[derive(Clone)] +#[allow(dead_code)] +pub(super) struct ConnectorCacheService { + pub(super) service: tower::util::BoxCloneService, + pub(super) storage: Arc, + pub(super) connectors_config: Arc, + pub(super) private_queries: Arc>>, + pub(super) debug: bool, + pub(super) supergraph_schema: Arc>, + pub(super) subgraph_enums: Arc>, + pub(super) lru_size_instrument: LruSizeInstrument, +} + +impl Service for ConnectorCacheService { + type Response = connect::Response; + type Error = BoxError; + type Future = >::Future; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.service.poll_ready(cx) + } + + fn call(&mut self, request: connect::Request) -> Self::Future { + let clone = self.clone(); + let inner = std::mem::replace(self, clone); + + Box::pin(inner.connector_call_inner(request)) + } +} + +impl ConnectorCacheService { + async fn connector_call_inner( + mut self, + request: connect::Request, + ) -> Result { + // Look up the connector to get the source_config_key + let connectors = get_connectors(&request.context); + let connector = connectors + .as_ref() + .and_then(|c| c.get(&request.service_name)); + + let source_name = connector.map(|c| c.source_config_key()).unwrap_or_default(); + let connector_synthetic_name = connector.map(|c| c.id.synthetic_name()).unwrap_or_default(); + + // Check if caching is enabled for this connector source + let connector_config = self.connectors_config.get(&source_name); + if !self.connectors_config.is_source_enabled(true, &source_name) { + return self.service.call(request).await; + } + + // Skip cache entirely for non-Query operations (mutations, subscriptions) + if let Ok(Some(operation_kind)) = request.context.get::<_, OperationKind>(OPERATION_KIND) + && operation_kind != OperationKind::Query + { + return self.service.call(request).await; + } + + // Check if the request is part of a batch. If it is, completely bypass response caching + // since it will break any request batches which this request is part of. + // This check is what enables Batching and response caching to work together, so be very + // careful before making any changes to it. + if request.is_part_of_batch() { + return self.service.call(request).await; + } + + // Gate debug mode on the per-request header, matching the subgraph path + self.debug = self.debug + && (request + .supergraph_request + .headers() + .get(CACHE_DEBUG_HEADER_NAME) + == Some(&HeaderValue::from_static("true"))); + + let storage = match self.storage.get_connector(&source_name) { + Some(storage) => storage.clone(), + None => { + record_fetch_error(&storage::Error::NoStorage, &source_name); + return self.service.call(request).await; + } + }; + + let connector_ttl = connector_config + .ttl + .clone() + .map(|t| t.0) + .or_else(|| self.connectors_config.all.ttl.clone().map(|t| t.0)) + .unwrap_or_else(|| Duration::from_secs(60 * 60 * 24)); + + let private_id_key = connector_config + .private_id + .clone() + .or_else(|| self.connectors_config.all.private_id.clone()); + + let private_id = private_id_key + .as_ref() + .and_then(|key| hash_private_id(&request.context, key)); + + // Build private query key for LRU tracking + let operation_str = request.operation.serialize().no_indent().to_string(); + let private_query_key = PrivateQueryKey { + query_hash: hash_operation(&operation_str), + has_private_id: private_id.is_some(), + }; + + let is_known_private = { + self.private_queries + .read() + .await + .contains(&private_query_key) + }; + + // [RFC 9111](https://datatracker.ietf.org/doc/html/rfc9111): + // * no-store: allows serving response from cache, but prohibits storing response in cache + // * no-cache: prohibits serving response from cache, but allows storing response in cache + // + // NB: no-cache actually prohibits serving response from cache _without revalidation_, but + // in the router this is the same thing + let request_cache_control = if request + .supergraph_request + .headers() + .contains_key(&CACHE_CONTROL) + { + let cache_control = match CacheControl::new(request.supergraph_request.headers(), None) + { + Ok(cache_control) => cache_control, + Err(err) => { + return Ok(connect::Response { + response: http::Response::builder() + .body( + graphql::Response::builder() + .error( + graphql::Error::builder() + .message(format!( + "cannot get cache-control header: {err}" + )) + .extension_code("INVALID_CACHE_CONTROL_HEADER") + .build(), + ) + .build(), + ) + .unwrap(), + }); + } + }; + + // Don't use cache at all if both no-store and no-cache are set + if cache_control.is_no_cache() && cache_control.is_no_store() { + return self.service.call(request).await; + } + Some(cache_control) + } else { + None + }; + + // Check if this is an entity query (has representations) — needed before private bypass + // to determine debug entry kind + let is_entity = request.variables.variables.contains_key(REPRESENTATIONS); + + // The response will have a private scope but we don't have a way to differentiate users, + // so we know we will not get or store anything in the cache + if is_known_private && private_id.is_none() { + let debug_request = if self.debug { + Some( + graphql::Request::builder() + .query(operation_str.clone()) + .variables(request.variables.variables.clone().into_iter().collect()) + .build(), + ) + } else { + None + }; + + let context = request.context.clone(); + let resp = self.service.call(request).await?; + + if self.debug { + // Use no_store cache control — this is a known-private query without private_id, + // so we won't be storing anything regardless of what the upstream returns + let cache_control = CacheControl::no_store(); + let kind = if is_entity { + CacheEntryKind::Entity { + typename: "".to_string(), + entity_key: Default::default(), + } + } else { + CacheEntryKind::RootFields { + root_fields: Vec::new(), + } + }; + + let cache_key_context = CacheKeyContext { + key: "-".to_string(), + invalidation_keys: vec![], + kind, + hashed_private_id: None, + subgraph_name: source_name.to_string(), + subgraph_request: debug_request.unwrap_or_default(), + source: CacheKeySource::Connector, + cache_control, + data: serde_json_bytes::to_value(resp.response.body().clone()) + .unwrap_or_default(), + warnings: Vec::new(), + should_store: false, + } + .update_metadata(); + add_cache_key_to_context(&context, cache_key_context)?; + } + + return Ok(resp); + } + + if is_entity { + let source_name_span = source_name.clone(); + let private_id_exists = private_id.is_some(); + let is_debug = self.debug; + self.handle_entity_query( + request, + storage, + source_name, + connector_synthetic_name, + connector_ttl, + private_id, + request_cache_control, + is_known_private, + private_query_key, + ) + .instrument(tracing::info_span!( + "response_cache.lookup", + kind = "entity", + "connector.source" = source_name_span.as_str(), + debug = is_debug, + private = is_known_private, + contains_private_id = private_id_exists, + )) + .await + } else { + // Root field queries are handled at the connector_request_service level + self.service.call(request).await + } + } + + #[allow(clippy::too_many_arguments)] + async fn handle_entity_query( + mut self, + mut request: connect::Request, + storage: Storage, + source_name: String, + connector_synthetic_name: String, + connector_ttl: Duration, + private_id: Option, + request_cache_control: Option, + is_known_private: bool, + private_query_key: PrivateQueryKey, + ) -> Result { + // Get auth metadata from context + let auth_metadata = request + .context + .extensions() + .with_lock(|lock| lock.get::().cloned()) + .unwrap_or_default(); + + // Hash the operation for use in cache keys + let operation_str = request.operation.serialize().no_indent().to_string(); + let operation_hash = hash_operation(&operation_str); + + // Hash additional data (variables minus representations + auth metadata) + let additional_data_hash = hash_connector_additional_data( + &source_name, + &request.variables.variables, + &request.context, + &auth_metadata, + ); + + // Build debug request before representations are mutably borrowed + let debug_request = if self.debug { + Some( + graphql::Request::builder() + .query(operation_str.clone()) + .variables(request.variables.variables.clone().into_iter().collect()) + .build(), + ) + } else { + None + }; + + let representations = request + .variables + .variables + .get_mut(REPRESENTATIONS) + .and_then(|value| value.as_array_mut()); + + let Some(representations) = representations else { + // No representations found, pass through + return self.service.call(request).await; + }; + + // Build cache keys for each representation + let mut cache_keys = Vec::with_capacity(representations.len()); + for representation in representations.iter_mut() { + let representation_obj = + representation + .as_object_mut() + .ok_or_else(|| FetchError::MalformedRequest { + reason: "representation variable should be an array of objects".to_string(), + })?; + + let typename_value = representation_obj + .get(TYPENAME) + .ok_or_else(|| FetchError::MalformedRequest { + reason: "missing __typename in representation".to_string(), + })? + .clone(); + + let typename = typename_value + .as_str() + .ok_or_else(|| FetchError::MalformedRequest { + reason: "__typename in representation is not a string".to_string(), + })?; + + // Temporarily remove __typename for hashing (same as subgraph flow) + representation_obj.remove(TYPENAME); + + // Get the entity key from `representation`, only needed in debug for the cache debugger. + // Connectors don't use @key directives — their key fields are derived from variable + // references ($args, $this, $batch) and stored on ConnectRequest.keys as a FieldSet. + // We extract key field values directly rather than using + // get_entity_key_from_selection_set. + let representation_entity_key = if self.debug { + request.keys.as_ref().map(|keys| { + let default_document = Default::default(); + let mut entity_key = serde_json_bytes::Map::new(); + for field in keys.selection_set.root_fields(&default_document) { + let key = field.name.as_str(); + if let Some(val) = representation_obj.get(key) { + entity_key.insert(ByteString::from(key), val.clone()); + } + } + entity_key + }) + } else { + None + }; + + let cache_key = ConnectorCacheKeyEntity { + source_name: &source_name, + entity_type: typename, + representation: representation_obj, + operation_hash: &operation_hash, + additional_data_hash: &additional_data_hash, + private_id: if is_known_private { + private_id.as_deref() + } else { + None + }, + } + .hash(); + + // Build invalidation keys + let mut invalidation_keys = vec![format!( + "{INTERNAL_CACHE_TAG_PREFIX}version:{RESPONSE_CACHE_VERSION}:connector:{source_name}:type:{typename}" + )]; + // Extract @cacheTag invalidation keys from the supergraph schema. + if let Ok(cache_tag_keys) = get_invalidation_entity_keys_from_schema( + &self.supergraph_schema, + &connector_synthetic_name, + &self.subgraph_enums, + typename, + representation_obj, + ) { + invalidation_keys.extend(cache_tag_keys); + } + + // Restore __typename + representation_obj.insert(TYPENAME, typename_value); + + cache_keys.push(CacheMetadata { + cache_key, + invalidation_keys, + entity_key: representation_entity_key, + }); + } + + let entities = cache_keys.len() as u64; + u64_histogram_with_unit!( + "apollo.router.operations.response_cache.fetch.entity", + "Number of entities per subgraph fetch node", + "{entity}", + entities, + "connector.source" = source_name.to_string() + ); + + // Record cache keys on the lookup span + Span::current().set_span_dyn_attribute( + "cache.keys".into(), + opentelemetry::Value::Array(Array::String( + cache_keys + .iter() + .map(|k| StringValue::from(k.cache_key.clone())) + .collect(), + )), + ); + + // Batch fetch from cache + // Skip cache lookup if request had no-cache — we have no means of revalidating entries + // without just performing the query, so there's no benefit to hitting the cache + let cache_result: Vec> = if request_cache_control + .as_ref() + .is_some_and(|c| c.is_no_cache()) + { + std::iter::repeat_n(None, cache_keys.len()).collect() + } else { + let keys_for_fetch: Vec<&str> = + cache_keys.iter().map(|k| k.cache_key.as_str()).collect(); + let cache_result = storage.fetch_multiple(&keys_for_fetch, &source_name).await; + + match cache_result { + Ok(res) => res + .into_iter() + .map(|v| match v { + Some(v) if v.control.can_use() => Some(v), + _ => None, + }) + .collect(), + Err(err) => { + if !err.is_row_not_found() { + Span::current().mark_as_error(format!("cannot get cache entry: {err}")); + tracing::warn!(error = %err, "cannot get connector cache entries"); + } + std::iter::repeat_n(None, cache_keys.len()).collect() + } + } + }; + + // Filter representations: remove cached ones + let mut new_representations: Vec = Vec::new(); + let mut intermediate_results: Vec = Vec::new(); + let mut cache_control: Option = None; + let mut cache_hit_miss: HashMap = HashMap::new(); + + let representations = request + .variables + .variables + .get_mut(REPRESENTATIONS) + .and_then(|value| value.as_array_mut()) + .expect("representations should exist"); + + // Record graphql.types on the lookup span (deduplicated typenames) + let typenames_for_span: HashSet = representations + .iter() + .filter_map(|r| { + r.as_object() + .and_then(|o| o.get(TYPENAME)) + .and_then(|v| v.as_str()) + .map(String::from) + }) + .collect(); + Span::current().set_span_dyn_attribute( + Key::from_static_str("graphql.types"), + opentelemetry::Value::Array( + typenames_for_span + .into_iter() + .map(StringValue::from) + .collect::>() + .into(), + ), + ); + + for ((representation, metadata), entry) in + representations.drain(..).zip(cache_keys).zip(cache_result) + { + let typename = representation + .as_object() + .and_then(|o| o.get(TYPENAME)) + .and_then(|v| v.as_str()) + .unwrap_or("-") + .to_string(); + + match &entry { + Some(cache_entry) => { + // Cache hit - merge cache control + cache_hit_miss.entry(typename.clone()).or_default().hit += 1; + match cache_control.as_mut() { + None => cache_control = Some(cache_entry.control.clone()), + Some(c) => *c = c.merge(&cache_entry.control), + } + } + None => { + // Cache miss - keep for downstream + cache_hit_miss.entry(typename.clone()).or_default().miss += 1; + new_representations.push(representation); + } + } + + intermediate_results.push(IntermediateResult { + key: metadata.cache_key, + invalidation_keys: metadata.invalidation_keys, + typename, + entity_key: metadata.entity_key, + cache_entry: entry, + }); + } + + // Record cache.status on the lookup span + let cache_status = if new_representations.is_empty() { + "hit" + } else if intermediate_results + .iter() + .any(|ir| ir.cache_entry.is_some()) + { + "partial_hit" + } else { + "miss" + }; + Span::current().set_span_dyn_attribute( + opentelemetry::Key::new("cache.status"), + opentelemetry::Value::String(cache_status.into()), + ); + + // Store cache hit/miss metrics in context for telemetry + let _ = request.context.insert( + CacheMetricContextKey::new(source_name.clone()), + CacheSubgraph(cache_hit_miss), + ); + + // Add debug entries for cache hits + if self.debug + && let Some(ref debug_req) = debug_request + { + let debug_cache_keys_ctx = intermediate_results.iter().filter_map(|ir| { + ir.cache_entry.as_ref().map(|cache_entry| { + CacheKeyContext { + key: ir.key.clone(), + hashed_private_id: private_id.clone(), + invalidation_keys: external_invalidation_keys(ir.invalidation_keys.clone()), + kind: CacheEntryKind::Entity { + typename: ir.typename.clone(), + entity_key: ir.entity_key.clone().unwrap_or_default(), + }, + subgraph_name: source_name.clone(), + subgraph_request: debug_req.clone(), + source: CacheKeySource::Cache, + cache_control: cache_entry.control.clone(), + data: serde_json_bytes::json!({ + "data": cache_entry.data.clone() + }), + warnings: Vec::new(), + should_store: false, + } + .update_metadata() + }) + }); + add_cache_keys_to_context(&request.context, debug_cache_keys_ctx)?; + } + + if !new_representations.is_empty() { + // Partial or full miss - update representations and continue + request + .variables + .variables + .insert(REPRESENTATIONS, new_representations.into()); + + // Store cached results for merging on response + let mut cached_entities = ConnectorCachedEntities { + results: intermediate_results, + cache_control, + }; + + let debug = self.debug; + let context = request.context.clone(); + let mut response = match self.service.call(request).await { + Ok(response) => response, + Err(e) => { + let e = match e.downcast::() { + Ok(inner) => match *inner { + FetchError::SubrequestHttpError { .. } => *inner, + _ => FetchError::SubrequestHttpError { + status_code: None, + service: source_name.clone(), + reason: inner.to_string(), + }, + }, + Err(e) => FetchError::SubrequestHttpError { + status_code: None, + service: source_name.clone(), + reason: e.to_string(), + }, + }; + + let graphql_error = e.to_graphql_error(None); + + let (new_entities, new_errors) = assemble_response_from_errors( + &[graphql_error], + &mut cached_entities.results, + ); + + let mut data = Object::default(); + data.insert(ENTITIES, new_entities.into()); + + let response = connect::Response { + response: http::Response::builder() + .body( + graphql::Response::builder() + .data(data) + .errors(new_errors) + .build(), + ) + .unwrap(), + }; + + update_cache_control(&context, &CacheControl::no_store()); + + return Ok(response); + } + }; + + // Merge cached entities back into the response + Self::merge_cached_entities( + &mut response, + &context, + cached_entities, + &storage, + &source_name, + connector_ttl, + debug, + debug_request, + private_id, + request_cache_control, + is_known_private, + private_query_key, + &self.private_queries, + &self.lru_size_instrument, + ) + .await?; + + Ok(response) + } else { + // All entities cached - build response directly + let entities: Vec = intermediate_results + .iter() + .filter_map(|r| r.cache_entry.as_ref()) + .map(|entry| entry.data.clone()) + .collect(); + + let mut data = Object::default(); + data.insert(ENTITIES, entities.into()); + + let response = connect::Response { + response: http::Response::builder() + .body(graphql::Response::builder().data(data).build()) + .unwrap(), + }; + + if let Some(cc) = cache_control { + update_cache_control(&request.context, &cc); + } + + Ok(response) + } + } + + #[allow(clippy::too_many_arguments)] + async fn merge_cached_entities( + response: &mut connect::Response, + context: &Context, + cached: ConnectorCachedEntities, + storage: &Storage, + source_name: &str, + connector_ttl: Duration, + debug: bool, + debug_request: Option, + private_id: Option, + request_cache_control: Option, + is_known_private: bool, + private_query_key: PrivateQueryKey, + private_queries: &Arc>>, + lru_size_instrument: &LruSizeInstrument, + ) -> Result<(), BoxError> { + let ConnectorCachedEntities { + mut results, + cache_control: cached_cache_control, + } = cached; + + // Get the response cache control from context (set by connector_request_service) + let mut response_cache_control = context + .extensions() + .with_lock(|lock| lock.get::().cloned()) + .unwrap_or_else(CacheControl::no_store); + + // If the request had no-store, propagate that to the response cache control + if let Some(ref req_cc) = request_cache_control { + response_cache_control.no_store |= req_cc.no_store; + } + + // Track private queries in the LRU so future requests can short-circuit + if response_cache_control.private() && !is_known_private { + let size = { + let mut pq = private_queries.write().await; + pq.put(private_query_key, ()); + pq.len() + }; + lru_size_instrument.update(size as u64); + } + + // 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 + let unstorable_private_response = response_cache_control.private() && private_id.is_none(); + + // If the response is private but wasn't known-private before, we need to append + // the private_id to cache keys before storing (matching the subgraph pattern in + // insert_entities_in_result). This ensures the stored keys include the private_id + // suffix that will be used in subsequent lookups (when is_known_private is true). + let update_key_private = if !is_known_private && response_cache_control.private() { + private_id.clone() + } else { + None + }; + + // Merge the cached and response cache controls + let merged_cache_control = match cached_cache_control { + Some(cached_cc) => cached_cc.merge(&response_cache_control), + None => response_cache_control.clone(), + }; + + // Take the response data out to avoid borrow issues + let mut response_data = response.response.body_mut().data.take(); + + let entities = response_data + .as_mut() + .and_then(|v| v.as_object_mut()) + .and_then(|o| o.remove(ENTITIES)) + .and_then(|v| match v { + Value::Array(arr) => Some(arr), + _ => None, + }); + + let Some(mut entities) = entities else { + // No _entities in response (e.g., connector returned HTTP error). + // Build a partial response with cached entities + null/errors for misses, + // matching the subgraph path in cache_store_entities_from_response. + let (new_entities, new_errors) = + assemble_response_from_errors(&response.response.body().errors, &mut results); + + let mut data = Object::default(); + data.insert(ENTITIES, new_entities.into()); + + response.response.body_mut().data = Some(Value::Object(data)); + response.response.body_mut().errors = new_errors; + + update_cache_control(context, &CacheControl::no_store()); + + return Ok(()); + }; + + let ttl = response_cache_control + .ttl() + .map(Duration::from_secs) + .unwrap_or(connector_ttl); + + // Merge: iterate through results, inserting cached entities at correct positions + let errors = response.response.body().errors.clone(); + let mut new_entities = Vec::new(); + let mut new_errors = Vec::new(); + let mut to_insert: Vec = Vec::new(); + let mut debug_ctx_entries: Vec = Vec::new(); + let mut entities_iter = entities.drain(..).enumerate(); + + for ( + new_entity_idx, + IntermediateResult { + key, + invalidation_keys, + typename, + entity_key, + cache_entry, + }, + ) in results.drain(..).enumerate() + { + match cache_entry { + Some(entry) => { + // Was cached - use cached value + new_entities.push(entry.data); + } + None => { + // Was not cached - take from response and store + if let Some((entity_idx, value)) = entities_iter.next() { + // Check for per-entity errors (matching the subgraph pattern in + // insert_entities_in_result). Entities with errors should not be cached + // to avoid persisting error data until TTL expires. + let mut has_errors = false; + for error in errors.iter().filter(|e| { + e.path + .as_ref() + .map(|path| { + path.starts_with(&Path(vec![ + PathElement::Key(ENTITIES.to_string(), None), + PathElement::Index(entity_idx), + ])) + }) + .unwrap_or(false) + }) { + // Update the entity index to match the merged position + let mut e = error.clone(); + if let Some(path) = e.path.as_mut() { + path.0[1] = PathElement::Index(new_entity_idx); + } + new_errors.push(e); + has_errors = true; + } + + // Append private_id to cache key if response was discovered + // to be private mid-flight (matching subgraph pattern) + let key = if let Some(ref id) = update_key_private { + format!("{key}:{id}") + } else { + key + }; + + if !has_errors + && !unstorable_private_response + && response_cache_control.should_store() + { + to_insert.push(Document { + control: response_cache_control.clone(), + data: value.clone(), + key: key.clone(), + invalidation_keys: invalidation_keys.clone(), + expire: ttl, + debug, + }); + } + + if debug && let Some(ref debug_req) = debug_request { + debug_ctx_entries.push( + CacheKeyContext { + key, + hashed_private_id: private_id.clone(), + invalidation_keys: external_invalidation_keys( + invalidation_keys, + ), + kind: CacheEntryKind::Entity { + typename, + entity_key: entity_key.unwrap_or_default(), + }, + subgraph_name: source_name.to_string(), + subgraph_request: debug_req.clone(), + source: CacheKeySource::Connector, + cache_control: response_cache_control.clone(), + data: serde_json_bytes::json!({"data": value.clone()}), + warnings: Vec::new(), + should_store: false, + } + .update_metadata(), + ); + } + + new_entities.push(value); + } + } + } + } + + if !debug_ctx_entries.is_empty() { + add_cache_keys_to_context(context, debug_ctx_entries.into_iter())?; + } + + // Put the merged entities back into the response data + if let Some(data_obj) = response_data.as_mut().and_then(|v| v.as_object_mut()) { + data_obj.insert(ENTITIES, new_entities.into()); + } + response.response.body_mut().data = response_data; + + // Update errors with reindexed paths (entity indices changed due to cache merge) + if !new_errors.is_empty() { + response.response.body_mut().errors = new_errors; + } + + // Store new entities in cache asynchronously + if !to_insert.is_empty() { + let cache = storage.clone(); + let source = source_name.to_string(); + let span = tracing::info_span!( + "response_cache.store", + "kind" = "entity", + "connector.source" = source_name, + "ttl" = ?ttl, + "batch.size" = to_insert.len() + ); + tokio::spawn(async move { + let _ = cache + .insert_in_batch(to_insert, &source) + .instrument(span) + .await; + }); + } + + update_cache_control(context, &merged_cache_control); + + Ok(()) + } +} + +// --- Connector Request Cache Service --- +// Handles root field cache lookup/store and Cache-Control extraction at the individual HTTP request level. + +pub(super) type ConnectorRequestBoxCloneService = tower::util::BoxCloneService< + connector::request_service::Request, + connector::request_service::Response, + BoxError, +>; + +#[derive(Clone)] +pub(super) struct ConnectorRequestCacheService { + pub(super) service: ConnectorRequestBoxCloneService, + pub(super) storage: Arc, + pub(super) source_name: String, + pub(super) connector_ttl: Duration, + pub(super) private_id_key: Option, + pub(super) debug: bool, + pub(super) supergraph_schema: Arc>, + pub(super) subgraph_enums: Arc>, + pub(super) private_queries: Arc>>, + pub(super) lru_size_instrument: LruSizeInstrument, +} + +impl Service for ConnectorRequestCacheService { + type Response = connector::request_service::Response; + type Error = BoxError; + type Future = >::Future; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.service.poll_ready(cx) + } + + fn call(&mut self, request: connector::request_service::Request) -> Self::Future { + let clone = self.clone(); + let inner = std::mem::replace(self, clone); + + Box::pin(inner.call_inner(request)) + } +} + +impl ConnectorRequestCacheService { + fn get_private_id(&self, context: &Context) -> Option { + hash_private_id(context, self.private_id_key.as_ref()?) + } + + async fn call_inner( + mut self, + request: connector::request_service::Request, + ) -> Result { + // Check if the request is part of a batch. If it is, completely bypass response caching + // since it will break any request batches which this request is part of. + // This check is what enables Batching and response caching to work together, so be very + // careful before making any changes to it. + if request.is_part_of_batch() { + return self.service.call(request).await; + } + + // Skip cache entirely for non-Query operations (mutations, subscriptions) + if let Ok(Some(operation_kind)) = request.context.get::<_, OperationKind>(OPERATION_KIND) + && operation_kind != OperationKind::Query + { + return self.service.call(request).await; + } + + // Gate debug mode on the per-request header, matching the subgraph path + self.debug = self.debug + && (request + .supergraph_request + .headers() + .get(CACHE_DEBUG_HEADER_NAME) + == Some(&HeaderValue::from_static("true"))); + + let is_root_field = matches!( + request.key, + apollo_federation::connectors::runtime::key::ResponseKey::RootField { .. } + ); + + if is_root_field { + self.handle_root_field(request).await + } else { + // Entity requests: just extract Cache-Control from the response + self.handle_with_cache_control_extraction(request).await + } + } + + /// Handle root field requests with full cache lookup/store + async fn handle_root_field( + self, + request: connector::request_service::Request, + ) -> Result { + let storage = match self.storage.get_connector(&self.source_name) { + Some(s) => s.clone(), + None => { + record_fetch_error(&storage::Error::NoStorage, &self.source_name); + return self.handle_with_cache_control_extraction(request).await; + } + }; + + // [RFC 9111](https://datatracker.ietf.org/doc/html/rfc9111): + // * no-store: allows serving response from cache, but prohibits storing response in cache + // * no-cache: prohibits serving response from cache, but allows storing response in cache + let request_cache_control = if request + .supergraph_request + .headers() + .contains_key(&CACHE_CONTROL) + { + let cache_control = match CacheControl::new(request.supergraph_request.headers(), None) + { + Ok(cache_control) => cache_control, + Err(err) => { + let message = format!("cannot get cache-control header: {err}"); + let runtime_error = + apollo_federation::connectors::runtime::errors::RuntimeError::new( + &message, + &request.key, + ) + .with_code("INVALID_CACHE_CONTROL_HEADER"); + return Ok(connector::request_service::Response { + context: request.context, + transport_result: Err( + apollo_federation::connectors::runtime::errors::Error::InvalidCacheControl(message), + ), + mapped_response: + apollo_federation::connectors::runtime::responses::MappedResponse::Error { + error: runtime_error, + key: request.key, + problems: Vec::new(), + }, + }); + } + }; + + // Don't use cache at all if both no-store and no-cache are set + if cache_control.is_no_cache() && cache_control.is_no_store() { + return self.handle_with_cache_control_extraction(request).await; + } + Some(cache_control) + } else { + None + }; + + let private_id = self.get_private_id(&request.context); + + // Build operation hash early — needed for both cache key and private query LRU key + let operation_hash = request + .operation + .as_ref() + .map(|op| hash_operation(&op.serialize().no_indent().to_string())) + .unwrap_or_default(); + + // Build private query key for LRU tracking + let private_query_key = PrivateQueryKey { + query_hash: operation_hash.clone(), + has_private_id: private_id.is_some(), + }; + + let is_known_private = { + self.private_queries + .read() + .await + .contains(&private_query_key) + }; + + // Capture root field name before the private query bypass — needed for debug entries + let root_field_name = match &request.key { + apollo_federation::connectors::runtime::key::ResponseKey::RootField { + name, .. + } => name.clone(), + _ => String::new(), + }; + + // The response will have a private scope but we don't have a way to differentiate users, + // so we know we will not get or store anything in the cache + if is_known_private && private_id.is_none() { + let debug_request = if self.debug { + let query_str = request + .operation + .as_ref() + .map(|op| op.serialize().no_indent().to_string()) + .unwrap_or_default(); + Some(graphql::Request::builder().query(query_str).build()) + } else { + None + }; + + let context = request.context.clone(); + let source_name = self.source_name.clone(); + let debug = self.debug; + let resp = self.handle_with_cache_control_extraction(request).await?; + + if debug { + let cache_key_context = CacheKeyContext { + key: "-".to_string(), + invalidation_keys: vec![], + kind: CacheEntryKind::RootFields { + root_fields: vec![root_field_name], + }, + hashed_private_id: None, + subgraph_name: source_name, + subgraph_request: debug_request.unwrap_or_default(), + source: CacheKeySource::Connector, + cache_control: CacheControl::no_store(), + data: serde_json_bytes::Value::Null, + warnings: Vec::new(), + should_store: false, + } + .update_metadata(); + add_cache_key_to_context(&context, cache_key_context)?; + } + + return Ok(resp); + } + + // Get auth metadata from context + let auth_metadata = request + .context + .extensions() + .with_lock(|lock| lock.get::().cloned()) + .unwrap_or_default(); + + // Capture connector info for cache tag extraction before request is consumed + let connector_synthetic_name = request.connector.id.synthetic_name(); + + // Build a variables object from the request inputs for hashing + let inputs = request.key.inputs(); + let cache_tag_args = inputs.args.clone(); + let mut variables = Object::default(); + for (k, v) in inputs.args.iter() { + variables.insert(k.clone(), v.clone()); + } + for (k, v) in inputs.this.iter() { + variables.insert(k.clone(), v.clone()); + } + let additional_data_hash = hash_connector_additional_data( + &self.source_name, + &variables, + &request.context, + &auth_metadata, + ); + + let mut cache_key = ConnectorCacheKeyRoot { + source_name: &self.source_name, + graphql_type: "Query", + operation_hash: &operation_hash, + additional_data_hash: &additional_data_hash, + private_id: if is_known_private { + private_id.as_deref() + } else { + None + }, + } + .hash(); + + // Build debug request and root fields list before request is consumed + let debug_request = if self.debug { + let query_str = request + .operation + .as_ref() + .map(|op| op.serialize().no_indent().to_string()) + .unwrap_or_default(); + Some( + graphql::Request::builder() + .query(query_str) + .variables(variables.into_iter().collect()) + .build(), + ) + } else { + None + }; + + // Try cache lookup + // Skip cache lookup if request had no-cache — we have no means of revalidating entries + // without just performing the query, so there's no benefit to hitting the cache + let skip_cache_lookup = request_cache_control + .as_ref() + .is_some_and(|c| c.is_no_cache()); + + let lookup_span = tracing::info_span!( + "response_cache.lookup", + kind = "root", + "connector.source" = self.source_name.as_str(), + debug = self.debug, + private = is_known_private, + contains_private_id = private_id.is_some(), + "cache.key" = cache_key.as_str(), + ); + + let fetch_result = storage + .fetch(&cache_key, &self.source_name) + .instrument(lookup_span.clone()) + .await; + + // Mark span as error for non-trivial fetch failures + if let Err(ref err) = fetch_result + && !err.is_row_not_found() + { + lookup_span.mark_as_error(format!("cannot get cache entry: {err}")); + } + + match fetch_result { + Ok(entry) if entry.control.can_use() && !skip_cache_lookup => { + // Cache hit - build a response from cached data + lookup_span.set_span_dyn_attribute( + opentelemetry::Key::new("cache.status"), + opentelemetry::Value::String("hit".into()), + ); + update_cache_control(&request.context, &entry.control); + + // Store cache hit metric in context for telemetry + let mut cache_hit = HashMap::new(); + cache_hit.insert("Query".to_string(), CacheHitMiss { hit: 1, miss: 0 }); + let _ = request.context.insert( + CacheMetricContextKey::new(self.source_name.clone()), + CacheSubgraph(cache_hit), + ); + + if self.debug + && let Some(debug_req) = debug_request + { + let cache_key_context = CacheKeyContext { + key: cache_key.clone(), + hashed_private_id: private_id, + invalidation_keys: entry + .cache_tags + .as_ref() + .map(|tags| external_invalidation_keys(tags.iter().cloned())) + .unwrap_or_default(), + kind: CacheEntryKind::RootFields { + root_fields: vec![root_field_name.clone()], + }, + subgraph_name: self.source_name.clone(), + subgraph_request: debug_req, + source: CacheKeySource::Cache, + cache_control: entry.control.clone(), + data: serde_json_bytes::json!({"data": entry.data.clone()}), + warnings: Vec::new(), + should_store: false, + } + .update_metadata(); + add_cache_key_to_context(&request.context, cache_key_context)?; + } + + let cached_response = connector::request_service::Response { + context: request.context, + transport_result: Ok( + apollo_federation::connectors::runtime::http_json_transport::TransportResponse::CacheHit, + ), + mapped_response: + apollo_federation::connectors::runtime::responses::MappedResponse::Data { + data: entry.data, + key: request.key, + problems: Vec::new(), + }, + }; + + Ok(cached_response) + } + _ => { + // Cache miss - call inner service and cache the response + lookup_span.set_span_dyn_attribute( + opentelemetry::Key::new("cache.status"), + opentelemetry::Value::String("miss".into()), + ); + let mut cache_miss = HashMap::new(); + cache_miss.insert("Query".to_string(), CacheHitMiss { hit: 0, miss: 1 }); + let _ = request.context.insert( + CacheMetricContextKey::new(self.source_name.clone()), + CacheSubgraph(cache_miss), + ); + + let debug = self.debug; + let context = request.context.clone(); + let source_name = self.source_name.clone(); + let connector_ttl = self.connector_ttl; + let supergraph_schema = self.supergraph_schema.clone(); + let subgraph_enums = self.subgraph_enums.clone(); + let private_queries = self.private_queries.clone(); + let lru_size_instrument = self.lru_size_instrument.clone(); + let response = self.handle_with_cache_control_extraction(request).await?; + + // Store in cache if appropriate + if let apollo_federation::connectors::runtime::responses::MappedResponse::Data { + ref data, + .. + } = response.mapped_response + { + let mut cache_control = context + .extensions() + .with_lock(|lock| lock.get::().cloned()) + .unwrap_or_else(CacheControl::no_store); + + // If the request had no-store, propagate that to the response cache control + if let Some(ref req_cc) = request_cache_control { + cache_control.no_store |= req_cc.no_store; + } + + // Track private queries in the LRU so future requests can short-circuit + if cache_control.private() && !is_known_private { + let size = { + let mut pq = private_queries.write().await; + pq.put(private_query_key, ()); + pq.len() + }; + lru_size_instrument.update(size as u64); + + // Update cache key with private_id suffix now that we know the + // response is private (matching subgraph pattern at line 1278) + if let Some(ref s) = private_id { + cache_key = format!("{cache_key}:{s}"); + } + } + + // 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 + let unstorable_private_response = + cache_control.private() && private_id.is_none(); + + if !unstorable_private_response && cache_control.should_store() { + let ttl = cache_control + .ttl() + .map(Duration::from_secs) + .unwrap_or(connector_ttl); + + let mut invalidation_keys = vec![format!( + "{INTERNAL_CACHE_TAG_PREFIX}version:{RESPONSE_CACHE_VERSION}:connector:{}:type:Query", + source_name + )]; + // Extract @cacheTag invalidation keys from the supergraph schema + if let Ok(cache_tag_keys) = get_connector_root_cache_tags( + &supergraph_schema, + &subgraph_enums, + &connector_synthetic_name, + &root_field_name, + &cache_tag_args, + ) { + invalidation_keys.extend(cache_tag_keys); + } + + if debug && let Some(debug_req) = debug_request { + let cache_key_context = CacheKeyContext { + key: cache_key.clone(), + hashed_private_id: private_id, + invalidation_keys: external_invalidation_keys( + invalidation_keys.clone(), + ), + kind: CacheEntryKind::RootFields { + root_fields: vec![root_field_name.clone()], + }, + subgraph_name: source_name.clone(), + subgraph_request: debug_req, + source: CacheKeySource::Connector, + cache_control: cache_control.clone(), + data: serde_json_bytes::json!({"data": data.clone()}), + warnings: Vec::new(), + should_store: false, + } + .update_metadata(); + add_cache_key_to_context(&context, cache_key_context)?; + } + + let document = Document { + key: cache_key, + data: data.clone(), + control: cache_control, + invalidation_keys, + expire: ttl, + debug, + }; + + let source = source_name; + let span = tracing::info_span!( + "response_cache.store", + "kind" = "root", + "connector.source" = source.as_str(), + "ttl" = ?ttl + ); + tokio::spawn(async move { + let _ = storage.insert(document, &source).instrument(span).await; + }); + } + } + + Ok(response) + } + } + } + + /// Pass through to inner service, extracting Cache-Control from the transport response + async fn handle_with_cache_control_extraction( + mut self, + request: connector::request_service::Request, + ) -> Result { + let context = request.context.clone(); + let response = self.service.call(request).await?; + + // Extract Cache-Control from the transport response headers + if let Ok( + apollo_federation::connectors::runtime::http_json_transport::TransportResponse::Http( + http_response, + ), + ) = &response.transport_result + { + let cache_control = + CacheControl::new(&http_response.inner.headers, self.connector_ttl.into()) + .ok() + .unwrap_or_else(CacheControl::no_store); + update_cache_control(&context, &cache_control); + } + + Ok(response) + } +} + +/// Extract `@cacheTag` invalidation keys for a connector root field from the supergraph schema. +/// +/// Looks for `@join__directive(name: "federation__cacheTag")` on the given root field, +/// filters by `graphs` matching the connector's synthetic subgraph name, and interpolates +/// the format template with the field's `$args`. +fn get_connector_root_cache_tags( + supergraph_schema: &Valid, + subgraph_enums: &HashMap, + connector_synthetic_name: &str, + field_name: &str, + args: &serde_json_bytes::Map, +) -> Result, anyhow::Error> { + let root_query_type = supergraph_schema + .root_operation(apollo_compiler::ast::OperationType::Query) + .ok_or_else(|| anyhow::anyhow!("no root query type in supergraph schema"))?; + let query_object_type = supergraph_schema + .get_object(root_query_type.as_str()) + .ok_or_else(|| anyhow::anyhow!("cannot get root query type from supergraph schema"))?; + let field_def = match query_object_type.fields.get(field_name) { + Some(f) => f, + None => return Ok(HashSet::new()), + }; + + let templates = field_def + .directives + .get_all("join__directive") + .filter_map(|dir| { + let name = dir.argument_by_name("name", supergraph_schema).ok()?; + if name.as_str()? != CACHE_TAG_DIRECTIVE_NAME { + return None; + } + let is_current_subgraph = dir + .argument_by_name("graphs", supergraph_schema) + .ok() + .and_then(|f| { + Some( + f.as_list()? + .iter() + .filter_map(|graph| graph.as_enum()) + .any(|g| { + subgraph_enums.get(g.as_str()).map(|s| s.as_str()) + == Some(connector_synthetic_name) + }), + ) + }) + .unwrap_or_default(); + if !is_current_subgraph { + return None; + } + let mut format = None; + for (field_name, value) in dir + .argument_by_name("args", supergraph_schema) + .ok()? + .as_object()? + { + if field_name.as_str() == "format" { + format = value + .as_str() + .and_then(|v| v.parse::().ok()) + } + } + format + }); + + let mut vars = IndexMap::default(); + vars.insert("$args".to_string(), Value::Object(args.clone())); + + let mut keys = HashSet::new(); + for template in templates { + match template.interpolate(&vars) { + Ok((key, _)) => { + keys.insert(key); + } + Err(e) => { + tracing::warn!(error = %e, "failed to interpolate @cacheTag format for connector root field"); + } + } + } + Ok(keys) +} + +struct CacheMetadata { + cache_key: String, + invalidation_keys: Vec, + // Only set when debug mode is enabled + entity_key: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config_with( + all_enabled: Option, + sources: Vec<(&str, Option)>, + ) -> ConnectorCacheConfiguration { + let mut source_map = HashMap::new(); + for (name, enabled) in sources { + source_map.insert( + name.to_string(), + ConnectorCacheSource { + enabled, + ..Default::default() + }, + ); + } + ConnectorCacheConfiguration { + all: ConnectorCacheSource { + enabled: all_enabled, + ..Default::default() + }, + sources: source_map, + } + } + + #[test] + fn plugin_disabled_returns_false() { + let config = config_with(None, vec![]); + assert!(!config.is_source_enabled(false, "any")); + } + + #[test] + fn default_enabled_when_plugin_enabled() { + let config = config_with(None, vec![]); + assert!(config.is_source_enabled(true, "any")); + } + + #[test] + fn all_explicitly_enabled() { + let config = config_with(Some(true), vec![]); + assert!(config.is_source_enabled(true, "any")); + } + + #[test] + fn all_explicitly_disabled() { + let config = config_with(Some(false), vec![]); + assert!(!config.is_source_enabled(true, "any")); + } + + #[test] + fn per_source_true_overrides_all_disabled() { + let config = config_with(Some(false), vec![("src", Some(true))]); + assert!(config.is_source_enabled(true, "src")); + } + + #[test] + fn per_source_false_overrides_all_enabled() { + let config = config_with(Some(true), vec![("src", Some(false))]); + assert!(!config.is_source_enabled(true, "src")); + } + + #[test] + fn plugin_disabled_overrides_per_source_true() { + let config = config_with(None, vec![("src", Some(true))]); + assert!(!config.is_source_enabled(false, "src")); + } + + #[test] + fn unknown_source_uses_all_defaults() { + let config = config_with(None, vec![("known", Some(false))]); + assert!(config.is_source_enabled(true, "unknown")); + } +} diff --git a/apollo-router/src/plugins/response_cache/debugger.rs b/apollo-router/src/plugins/response_cache/debugger.rs index af7e65856f..41ba1fab6e 100644 --- a/apollo-router/src/plugins/response_cache/debugger.rs +++ b/apollo-router/src/plugins/response_cache/debugger.rs @@ -65,6 +65,8 @@ pub(crate) enum CacheKeySource { Subgraph, /// Data fetched from cache Cache, + /// Data fetched from connector + Connector, } impl CacheKeyContext { diff --git a/apollo-router/src/plugins/response_cache/invalidation.rs b/apollo-router/src/plugins/response_cache/invalidation.rs index 9d320bba4c..6867fceb03 100644 --- a/apollo-router/src/plugins/response_cache/invalidation.rs +++ b/apollo-router/src/plugins/response_cache/invalidation.rs @@ -205,6 +205,66 @@ impl Invalidation { subgraphs.clone().into_iter().collect::>(), ) } + InvalidationRequest::ConnectorSource { source } => { + let count = storage + .invalidate_by_subgraph(source, request.kind()) + .await + .inspect_err(|err| { + u64_counter_with_unit!( + "apollo.router.operations.response_cache.invalidation.error", + "Errors when invalidating data in cache", + "{error}", + 1, + "code" = err.code(), + "kind" = "connector", + "connector.source" = source.clone() + ); + })?; + u64_counter_with_unit!( + "apollo.router.operations.response_cache.invalidation.entry", + "Response cache counter for invalidated entries", + "{entry}", + count, + "kind" = "connector", + "connector.source" = source.clone() + ); + (count, vec![source.clone()]) + } + InvalidationRequest::ConnectorType { + source, + r#type: graphql_type, + } => { + let source_counts = storage + .invalidate(vec![invalidation_key], vec![source.clone()], request.kind()) + .await + .inspect_err(|err| { + u64_counter_with_unit!( + "apollo.router.operations.response_cache.invalidation.error", + "Errors when invalidating data in cache", + "{error}", + 1, + "code" = err.code(), + "kind" = "type", + "connector.source" = source.clone(), + "graphql.type" = graphql_type.clone() + ); + })?; + let mut total_count = 0; + for (source_name, count) in source_counts { + total_count += count; + u64_counter_with_unit!( + "apollo.router.operations.response_cache.invalidation.entry", + "Response cache counter for invalidated entries", + "{entry}", + count, + "kind" = "type", + "connector.source" = source_name, + "graphql.type" = graphql_type.clone() + ); + } + + (total_count, vec![source.clone()]) + } }; for subgraph in subgraphs { @@ -236,8 +296,19 @@ impl Invalidation { }, InvalidationRequest::CacheTag { subgraphs, .. } => subgraphs .iter() - .filter_map(|subgraph| self.storage.get(subgraph)) + .filter_map(|subgraph| { + self.storage + .get(subgraph) + .or_else(|| self.storage.get_connector(subgraph)) + }) .collect(), + InvalidationRequest::ConnectorSource { source } + | InvalidationRequest::ConnectorType { source, .. } => { + match self.storage.get_connector(source) { + Some(s) => vec![s], + None => continue, + } + } }; for storage in storages { @@ -270,8 +341,8 @@ impl Invalidation { pub(super) type InvalidationKind = &'static str; -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] pub(crate) enum InvalidationRequest { Subgraph { subgraph: String, @@ -284,6 +355,140 @@ pub(crate) enum InvalidationRequest { subgraphs: HashSet, cache_tag: String, }, + /// Invalidate all cached entries for a connector source + #[serde(rename = "connector")] + ConnectorSource { + /// Connector source identifier in "subgraph_name.source_name" format + source: String, + }, + /// Invalidate all cached entries of a specific type for a connector source + ConnectorType { + /// Connector source identifier in "subgraph_name.source_name" format + source: String, + r#type: String, + }, +} + +/// Intermediate struct for custom deserialization of `InvalidationRequest`. +/// Allows `"kind": "type"` to dispatch to either `Type` or `ConnectorType` +/// based on whether `"subgraph"` or `"source"` is present. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawInvalidationRequest { + kind: String, + #[serde(default)] + subgraph: Option, + #[serde(default)] + subgraphs: Option>, + #[serde(default)] + source: Option, + #[serde(default)] + r#type: Option, + #[serde(default)] + cache_tag: Option, + #[serde(default)] + sources: Option>, +} + +impl<'de> Deserialize<'de> for InvalidationRequest { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + + let raw = RawInvalidationRequest::deserialize(deserializer)?; + + // Helper to reject unexpected fields + fn reject_field( + field: &str, + present: bool, + kind: &str, + ) -> Result<(), E> { + if present { + Err(E::custom(format!( + "unexpected field `{field}` for kind `{kind}`" + ))) + } else { + Ok(()) + } + } + + let kind = raw.kind.as_str(); + + match kind { + "subgraph" => { + let subgraph = raw + .subgraph + .ok_or_else(|| D::Error::missing_field("subgraph"))?; + reject_field::("source", raw.source.is_some(), kind)?; + reject_field::("type", raw.r#type.is_some(), kind)?; + reject_field::("subgraphs", raw.subgraphs.is_some(), kind)?; + reject_field::("cache_tag", raw.cache_tag.is_some(), kind)?; + reject_field::("sources", raw.sources.is_some(), kind)?; + Ok(InvalidationRequest::Subgraph { subgraph }) + } + "type" => { + let r#type = raw.r#type.ok_or_else(|| D::Error::missing_field("type"))?; + reject_field::("subgraphs", raw.subgraphs.is_some(), kind)?; + reject_field::("cache_tag", raw.cache_tag.is_some(), kind)?; + reject_field::("sources", raw.sources.is_some(), kind)?; + match (raw.subgraph, raw.source) { + (Some(subgraph), None) => Ok(InvalidationRequest::Type { subgraph, r#type }), + (None, Some(source)) => { + Ok(InvalidationRequest::ConnectorType { source, r#type }) + } + (Some(_), Some(_)) => Err(D::Error::custom( + "cannot specify both `subgraph` and `source` for kind `type`", + )), + (None, None) => Err(D::Error::custom( + "kind `type` requires either `subgraph` or `source` field", + )), + } + } + "connector" => { + let source = raw + .source + .ok_or_else(|| D::Error::missing_field("source"))?; + reject_field::("subgraph", raw.subgraph.is_some(), kind)?; + reject_field::("type", raw.r#type.is_some(), kind)?; + reject_field::("subgraphs", raw.subgraphs.is_some(), kind)?; + reject_field::("cache_tag", raw.cache_tag.is_some(), kind)?; + reject_field::("sources", raw.sources.is_some(), kind)?; + Ok(InvalidationRequest::ConnectorSource { source }) + } + "cache_tag" => { + let subgraphs = match (raw.subgraphs, raw.sources) { + (Some(subgraphs), None) => subgraphs, + (None, Some(sources)) => sources, + (Some(_), Some(_)) => { + return Err(D::Error::custom( + "cannot specify both `subgraphs` and `sources` for kind `cache_tag`", + )); + } + (None, None) => { + return Err(D::Error::custom( + "kind `cache_tag` requires either `subgraphs` or `sources` field", + )); + } + }; + let cache_tag = raw + .cache_tag + .ok_or_else(|| D::Error::missing_field("cache_tag"))?; + reject_field::("subgraph", raw.subgraph.is_some(), kind)?; + reject_field::("source", raw.source.is_some(), kind)?; + reject_field::("type", raw.r#type.is_some(), kind)?; + Ok(InvalidationRequest::CacheTag { + subgraphs, + cache_tag, + }) + } + other => Err(D::Error::unknown_variant( + other, + &["subgraph", "type", "connector", "cache_tag"], + )), + } + } } impl InvalidationRequest { @@ -294,8 +499,11 @@ impl InvalidationRequest { InvalidationRequest::CacheTag { subgraphs, .. } => { subgraphs.clone().into_iter().collect() } + InvalidationRequest::ConnectorSource { source } + | InvalidationRequest::ConnectorType { source, .. } => vec![source.clone()], } } + fn invalidation_key(&self) -> String { match self { InvalidationRequest::Subgraph { subgraph } => { @@ -307,14 +515,280 @@ impl InvalidationRequest { ) } InvalidationRequest::CacheTag { cache_tag, .. } => cache_tag.clone(), + InvalidationRequest::ConnectorSource { source } => { + format!("version:{RESPONSE_CACHE_VERSION}:connector:{source}") + } + InvalidationRequest::ConnectorType { source, r#type } => { + format!( + "{INTERNAL_CACHE_TAG_PREFIX}version:{RESPONSE_CACHE_VERSION}:connector:{source}:type:{type}", + ) + } } } + /// Returns whether this request targets connector storage (vs subgraph storage) + pub(super) fn is_connector(&self) -> bool { + matches!( + self, + InvalidationRequest::ConnectorSource { .. } | InvalidationRequest::ConnectorType { .. } + ) + } + pub(super) fn kind(&self) -> InvalidationKind { match self { InvalidationRequest::Subgraph { .. } => "subgraph", InvalidationRequest::Type { .. } => "type", InvalidationRequest::CacheTag { .. } => "cache_tag", + InvalidationRequest::ConnectorSource { .. } => "connector", + InvalidationRequest::ConnectorType { .. } => "type", } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn connector_source_invalidation_key_format() { + let req = InvalidationRequest::ConnectorSource { + source: "mysubgraph.my_api".to_string(), + }; + let key = req.invalidation_key(); + assert_eq!( + key, + format!("version:{RESPONSE_CACHE_VERSION}:connector:mysubgraph.my_api") + ); + } + + #[test] + fn connector_type_invalidation_key_format() { + let req = InvalidationRequest::ConnectorType { + source: "mysubgraph.my_api".to_string(), + r#type: "User".to_string(), + }; + let key = req.invalidation_key(); + assert_eq!( + key, + format!( + "{INTERNAL_CACHE_TAG_PREFIX}version:{RESPONSE_CACHE_VERSION}:connector:mysubgraph.my_api:type:User" + ) + ); + } + + #[test] + fn connector_source_is_connector() { + assert!( + InvalidationRequest::ConnectorSource { + source: "s".to_string() + } + .is_connector() + ); + assert!( + InvalidationRequest::ConnectorType { + source: "s".to_string(), + r#type: "T".to_string() + } + .is_connector() + ); + } + + #[test] + fn subgraph_requests_are_not_connector() { + assert!( + !InvalidationRequest::Subgraph { + subgraph: "s".to_string() + } + .is_connector() + ); + assert!( + !InvalidationRequest::Type { + subgraph: "s".to_string(), + r#type: "T".to_string() + } + .is_connector() + ); + assert!( + !InvalidationRequest::CacheTag { + subgraphs: HashSet::new(), + cache_tag: "tag".to_string() + } + .is_connector() + ); + } + + #[test] + fn connector_source_kind() { + assert_eq!( + InvalidationRequest::ConnectorSource { + source: "s".to_string() + } + .kind(), + "connector" + ); + } + + #[test] + fn connector_type_kind() { + assert_eq!( + InvalidationRequest::ConnectorType { + source: "s".to_string(), + r#type: "T".to_string() + } + .kind(), + "type" + ); + } + + #[test] + fn connector_source_subgraph_names() { + let req = InvalidationRequest::ConnectorSource { + source: "mysubgraph.my_api".to_string(), + }; + assert_eq!(req.subgraph_names(), vec!["mysubgraph.my_api"]); + } + + #[test] + fn connector_type_subgraph_names() { + let req = InvalidationRequest::ConnectorType { + source: "mysubgraph.my_api".to_string(), + r#type: "User".to_string(), + }; + assert_eq!(req.subgraph_names(), vec!["mysubgraph.my_api"]); + } + + #[test] + fn deserialize_type_with_source_gives_connector_type() { + let json = r#"{"kind":"type","source":"graph.api","type":"User"}"#; + let req: InvalidationRequest = serde_json::from_str(json).unwrap(); + assert_eq!( + req, + InvalidationRequest::ConnectorType { + source: "graph.api".to_string(), + r#type: "User".to_string() + } + ); + } + + #[test] + fn deserialize_type_with_subgraph_gives_type() { + let json = r#"{"kind":"type","subgraph":"products","type":"Product"}"#; + let req: InvalidationRequest = serde_json::from_str(json).unwrap(); + assert_eq!( + req, + InvalidationRequest::Type { + subgraph: "products".to_string(), + r#type: "Product".to_string() + } + ); + } + + #[test] + fn deserialize_connector_with_source_gives_connector_source() { + let json = r#"{"kind":"connector","source":"graph.api"}"#; + let req: InvalidationRequest = serde_json::from_str(json).unwrap(); + assert_eq!( + req, + InvalidationRequest::ConnectorSource { + source: "graph.api".to_string() + } + ); + } + + #[test] + fn deserialize_subgraph_gives_subgraph() { + let json = r#"{"kind":"subgraph","subgraph":"products"}"#; + let req: InvalidationRequest = serde_json::from_str(json).unwrap(); + assert_eq!( + req, + InvalidationRequest::Subgraph { + subgraph: "products".to_string() + } + ); + } + + #[test] + fn deserialize_type_with_both_subgraph_and_source_errors() { + let json = r#"{"kind":"type","subgraph":"x","source":"y","type":"T"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("cannot specify both") + ); + } + + #[test] + fn deserialize_type_without_subgraph_or_source_errors() { + let json = r#"{"kind":"type","type":"T"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("requires either")); + } + + #[test] + fn deserialize_unknown_field_rejected() { + let json = r#"{"kind":"type","subgraph":"x","type":"T","extra":"bad"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("unknown field")); + } + + #[test] + fn deserialize_unknown_kind_rejected() { + let json = r#"{"kind":"bogus","subgraph":"x"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("unknown variant")); + } + + #[test] + fn deserialize_cache_tag_with_sources_field() { + let json = r#"{"kind":"cache_tag","sources":["connector-graph.random_person_api"],"cache_tag":"test-1"}"#; + let req: InvalidationRequest = serde_json::from_str(json).unwrap(); + assert_eq!( + req, + InvalidationRequest::CacheTag { + subgraphs: HashSet::from(["connector-graph.random_person_api".to_string()]), + cache_tag: "test-1".to_string(), + } + ); + } + + #[test] + fn deserialize_cache_tag_with_subgraphs_field() { + let json = r#"{"kind":"cache_tag","subgraphs":["my-subgraph"],"cache_tag":"test-1"}"#; + let req: InvalidationRequest = serde_json::from_str(json).unwrap(); + assert_eq!( + req, + InvalidationRequest::CacheTag { + subgraphs: HashSet::from(["my-subgraph".to_string()]), + cache_tag: "test-1".to_string(), + } + ); + } + + #[test] + fn deserialize_cache_tag_with_both_subgraphs_and_sources_rejected() { + let json = + r#"{"kind":"cache_tag","subgraphs":["foo"],"sources":["bar"],"cache_tag":"test-1"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("cannot specify both") + ); + } + + #[test] + fn deserialize_cache_tag_with_neither_subgraphs_nor_sources_rejected() { + let json = r#"{"kind":"cache_tag","cache_tag":"test-1"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("requires either")); + } +} diff --git a/apollo-router/src/plugins/response_cache/invalidation_endpoint.rs b/apollo-router/src/plugins/response_cache/invalidation_endpoint.rs index 1729436071..d683ec43ee 100644 --- a/apollo-router/src/plugins/response_cache/invalidation_endpoint.rs +++ b/apollo-router/src/plugins/response_cache/invalidation_endpoint.rs @@ -17,6 +17,7 @@ use tower::Service; use tracing::Span; use tracing_futures::Instrument; +use super::connectors::ConnectorCacheConfiguration; use super::invalidation::Invalidation; use super::plugin::Subgraph; use crate::ListenAddr; @@ -51,16 +52,19 @@ pub(crate) struct InvalidationEndpointConfig { #[derive(Clone)] pub(crate) struct InvalidationService { config: Arc>, + connector_config: Arc, invalidation: Invalidation, } impl InvalidationService { pub(crate) fn new( config: Arc>, + connector_config: Arc, invalidation: Invalidation, ) -> Self { Self { config, + connector_config, invalidation, } } @@ -80,6 +84,7 @@ impl Service for InvalidationService { HeaderValue::from_static("application/json"); let invalidation = self.invalidation.clone(); let config = self.config.clone(); + let connector_config = self.connector_config.clone(); Box::pin( async move { let (parts, body) = req.router_request.into_parts(); @@ -130,12 +135,24 @@ impl Service for InvalidationService { .collect::>() .join(", "), ); - let shared_key_is_valid = body - .iter() - .flat_map(|b| b.subgraph_names()) - .all(|subgraph_name| { - validate_shared_key(&config, shared_key, &subgraph_name) - }); + let shared_key_is_valid = body.iter().all(|req| { + if req.is_connector() { + validate_connector_shared_key( + &connector_config, + shared_key, + req, + ) + } else { + req.subgraph_names().iter().all(|name| { + validate_shared_key(&config, shared_key, name) + || validate_connector_shared_key_by_source( + &connector_config, + shared_key, + name, + ) + }) + } + }); if !shared_key_is_valid { Span::current() .record(OTEL_STATUS_CODE, OTEL_STATUS_CODE_ERROR); @@ -234,6 +251,52 @@ impl Service for InvalidationService { } } +fn validate_connector_shared_key( + config: &ConnectorCacheConfiguration, + shared_key: &str, + request: &InvalidationRequest, +) -> bool { + let source_name = match request { + InvalidationRequest::ConnectorSource { source } + | InvalidationRequest::ConnectorType { source, .. } => source, + _ => return false, + }; + + config + .all + .invalidation + .as_ref() + .map(|i| i.shared_key == shared_key) + .unwrap_or_default() + || config + .sources + .get(source_name) + .and_then(|s| s.invalidation.as_ref()) + .map(|i| i.shared_key == shared_key) + .unwrap_or_default() +} + +/// Validate shared key for a connector source by name. +/// Used for CacheTag requests where the `subgraphs` field may contain connector source names. +fn validate_connector_shared_key_by_source( + config: &ConnectorCacheConfiguration, + shared_key: &str, + source_name: &str, +) -> bool { + config + .all + .invalidation + .as_ref() + .map(|i| i.shared_key == shared_key) + .unwrap_or_default() + || config + .sources + .get(source_name) + .and_then(|s| s.invalidation.as_ref()) + .map(|i| i.shared_key == shared_key) + .unwrap_or_default() +} + fn validate_shared_key( config: &SubgraphConfiguration, shared_key: &str, @@ -264,6 +327,7 @@ mod tests { use tower::ServiceExt; use super::*; + use crate::plugins::response_cache::connectors::ConnectorCacheSource; use crate::plugins::response_cache::plugin::StorageInterface; use crate::plugins::response_cache::storage::redis::Config; use crate::plugins::response_cache::storage::redis::Storage; @@ -294,7 +358,11 @@ mod tests { }, subgraphs: HashMap::new(), }); - let service = InvalidationService::new(config, invalidation); + let service = InvalidationService::new( + config, + Arc::new(ConnectorCacheConfiguration::default()), + invalidation, + ); let req = router::Request::fake_builder() .method(http::Method::POST) .header(AUTHORIZATION, "testttt") @@ -360,7 +428,11 @@ mod tests { .collect(), }); // Trying to invalidation with shared_key on subgraph test for a subgraph foo - let service = InvalidationService::new(config, invalidation); + let service = InvalidationService::new( + config, + Arc::new(ConnectorCacheConfiguration::default()), + invalidation, + ); let req = router::Request::fake_builder() .method(http::Method::POST) .header(AUTHORIZATION, "test_test") @@ -435,7 +507,11 @@ mod tests { .collect(), }); // Trying to invalidation with shared_key on subgraph test for a subgraph foo - let service = InvalidationService::new(config, invalidation); + let service = InvalidationService::new( + config, + Arc::new(ConnectorCacheConfiguration::default()), + invalidation, + ); let req = router::Request::fake_builder() .method(http::Method::POST) .header(AUTHORIZATION, "test_test") @@ -515,7 +591,11 @@ mod tests { .collect(), }); // Trying to invalidation with shared_key on subgraph test for a subgraph foo - let service = InvalidationService::new(config, invalidation); + let service = InvalidationService::new( + config, + Arc::new(ConnectorCacheConfiguration::default()), + invalidation, + ); let req = router::Request::fake_builder() .method(http::Method::POST) .header(AUTHORIZATION, "test") @@ -566,7 +646,11 @@ mod tests { subgraphs: HashMap::new(), }); // Trying to invalidation with shared_key on subgraph test for a subgraph foo - let service = InvalidationService::new(config, invalidation); + let service = InvalidationService::new( + config, + Arc::new(ConnectorCacheConfiguration::default()), + invalidation, + ); let req = router::Request::fake_builder() .method(http::Method::POST) .header(AUTHORIZATION, "test") @@ -596,4 +680,138 @@ mod tests { .contains("failed to deserialize the request body into JSON: unknown field") ); } + + #[test] + fn validate_connector_shared_key_all_config() { + let config = ConnectorCacheConfiguration { + all: ConnectorCacheSource { + invalidation: Some(SubgraphInvalidationConfig { + enabled: true, + shared_key: "my_secret".to_string(), + }), + ..Default::default() + }, + sources: HashMap::new(), + }; + let req = InvalidationRequest::ConnectorSource { + source: "any_source".to_string(), + }; + assert!(validate_connector_shared_key(&config, "my_secret", &req)); + } + + #[test] + fn validate_connector_shared_key_source_specific() { + let config = ConnectorCacheConfiguration { + all: ConnectorCacheSource::default(), + sources: [( + "mysubgraph.my_api".to_string(), + ConnectorCacheSource { + invalidation: Some(SubgraphInvalidationConfig { + enabled: true, + shared_key: "source_secret".to_string(), + }), + ..Default::default() + }, + )] + .into_iter() + .collect(), + }; + let req = InvalidationRequest::ConnectorSource { + source: "mysubgraph.my_api".to_string(), + }; + assert!(validate_connector_shared_key( + &config, + "source_secret", + &req + )); + } + + #[test] + fn validate_connector_shared_key_mismatch() { + let config = ConnectorCacheConfiguration { + all: ConnectorCacheSource { + invalidation: Some(SubgraphInvalidationConfig { + enabled: true, + shared_key: "correct_key".to_string(), + }), + ..Default::default() + }, + sources: HashMap::new(), + }; + let req = InvalidationRequest::ConnectorSource { + source: "any_source".to_string(), + }; + assert!(!validate_connector_shared_key(&config, "wrong_key", &req)); + } + + #[test] + fn validate_connector_shared_key_non_connector() { + let config = ConnectorCacheConfiguration { + all: ConnectorCacheSource { + invalidation: Some(SubgraphInvalidationConfig { + enabled: true, + shared_key: "my_secret".to_string(), + }), + ..Default::default() + }, + sources: HashMap::new(), + }; + let req = InvalidationRequest::Subgraph { + subgraph: "test".to_string(), + }; + assert!(!validate_connector_shared_key(&config, "my_secret", &req)); + } + + #[test] + fn validate_connector_shared_key_by_source_all() { + let config = ConnectorCacheConfiguration { + all: ConnectorCacheSource { + invalidation: Some(SubgraphInvalidationConfig { + enabled: true, + shared_key: "all_key".to_string(), + }), + ..Default::default() + }, + sources: HashMap::new(), + }; + assert!(validate_connector_shared_key_by_source( + &config, + "all_key", + "unknown_source" + )); + } + + #[test] + fn validate_connector_shared_key_by_source_specific() { + let config = ConnectorCacheConfiguration { + all: ConnectorCacheSource::default(), + sources: [( + "mysubgraph.my_api".to_string(), + ConnectorCacheSource { + invalidation: Some(SubgraphInvalidationConfig { + enabled: true, + shared_key: "source_key".to_string(), + }), + ..Default::default() + }, + )] + .into_iter() + .collect(), + }; + assert!(validate_connector_shared_key_by_source( + &config, + "source_key", + "mysubgraph.my_api" + )); + } + + #[test] + fn validate_connector_shared_key_by_source_no_config() { + let config = ConnectorCacheConfiguration::default(); + assert!(!validate_connector_shared_key_by_source( + &config, + "any_key", + "any_source" + )); + } } diff --git a/apollo-router/src/plugins/response_cache/mod.rs b/apollo-router/src/plugins/response_cache/mod.rs index c685c38640..472c38868e 100644 --- a/apollo-router/src/plugins/response_cache/mod.rs +++ b/apollo-router/src/plugins/response_cache/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod cache_control; pub(crate) mod cache_key; +pub(crate) mod connectors; pub(crate) mod debugger; pub(crate) mod invalidation; pub(crate) mod invalidation_endpoint; diff --git a/apollo-router/src/plugins/response_cache/plugin.rs b/apollo-router/src/plugins/response_cache/plugin.rs index fcf24575da..5b093ec4fe 100644 --- a/apollo-router/src/plugins/response_cache/plugin.rs +++ b/apollo-router/src/plugins/response_cache/plugin.rs @@ -41,6 +41,9 @@ use tracing::Level; use tracing::Span; use super::cache_control::CacheControl; +use super::connectors::ConnectorCacheConfiguration; +use super::connectors::ConnectorCacheService; +use super::connectors::ConnectorRequestCacheService; use super::invalidation::Invalidation; use super::invalidation_endpoint::InvalidationEndpointConfig; use super::invalidation_endpoint::InvalidationService; @@ -80,6 +83,7 @@ use crate::plugins::telemetry::LruSizeInstrument; use crate::plugins::telemetry::dynamic_attribute::SpanDynAttribute; use crate::plugins::telemetry::span_ext::SpanMarkError; use crate::query_planner::OperationKind; +use crate::services::connect; use crate::services::subgraph; use crate::services::subgraph::SubgraphRequestId; use crate::services::supergraph; @@ -112,6 +116,7 @@ pub(crate) struct ResponseCache { pub(super) storage: Arc, endpoint_config: Option>, subgraphs: Arc>, + connectors: Arc, entity_type: Option, enabled: bool, debug: bool, @@ -126,15 +131,17 @@ pub(crate) struct ResponseCache { } #[derive(Debug, Clone, Hash, PartialEq, Eq)] -struct PrivateQueryKey { - query_hash: String, - has_private_id: bool, +pub(super) struct PrivateQueryKey { + pub(super) query_hash: String, + pub(super) has_private_id: bool, } #[derive(Clone, Default)] pub(crate) struct StorageInterface { all: Option>>, subgraphs: HashMap>>, + connector_all: Option>>, + connector_sources: HashMap>>, } impl StorageInterface { @@ -143,6 +150,15 @@ impl StorageInterface { storage.get() } + /// Get storage for a connector source, falling back to connector `all` storage. + pub(crate) fn get_connector(&self, source_name: &str) -> Option<&Storage> { + let storage = self + .connector_sources + .get(source_name) + .or(self.connector_all.as_ref())?; + storage.get() + } + /// Activate all storages so they can start emitting metrics. pub(crate) fn activate(&self) { if let Some(all) = &self.all @@ -155,6 +171,16 @@ impl StorageInterface { storage.activate(); } } + if let Some(all) = &self.connector_all + && let Some(storage) = all.get() + { + storage.activate(); + } + for storage in self.connector_sources.values() { + if let Some(storage) = storage.get() { + storage.activate(); + } + } } } @@ -181,6 +207,8 @@ impl From for StorageInterface { Self { all: Some(Arc::new(storage.into())), subgraphs: HashMap::new(), + connector_all: None, + connector_sources: HashMap::new(), } } } @@ -198,8 +226,13 @@ pub(crate) struct Config { debug: bool, /// Configure invalidation per subgraph + #[serde(default)] pub(crate) subgraph: SubgraphConfiguration, + /// Configure response caching per connector source + #[serde(default)] + pub(crate) connector: ConnectorCacheConfiguration, + /// Global invalidation configuration invalidation: Option, @@ -354,6 +387,51 @@ impl PluginPrivate for ResponseCache { } } + // Initialize connector storage + if init.config.enabled + && init.config.connector.all.enabled.unwrap_or(true) + && let Some(config) = init.config.connector.all.redis.clone() + { + let storage = Arc::new(OnceLock::new()); + storage_interface.connector_all = Some(storage.clone()); + connect_or_spawn_reconnection_task(config, storage, drop_tx.subscribe()).await?; + } + + for (source, source_config) in &init.config.connector.sources { + if init + .config + .connector + .is_source_enabled(init.config.enabled, source) + { + match source_config.redis.clone() { + Some(config) => { + if Some(&config) != init.config.connector.all.redis.as_ref() + || storage_interface.connector_all.is_none() + { + let storage = Arc::new(OnceLock::new()); + storage_interface + .connector_sources + .insert(source.clone(), storage.clone()); + connect_or_spawn_reconnection_task( + config, + storage, + drop_tx.subscribe(), + ) + .await?; + } + } + None => { + if storage_interface.connector_all.is_none() { + return Err( + format!("you must have a redis configured either for all connectors or for connector source {source:?}") + .into(), + ); + } + } + } + } + } + let storage_interface = Arc::new(storage_interface); let invalidation = Invalidation::new(storage_interface.clone()).await?; @@ -364,6 +442,7 @@ impl PluginPrivate for ResponseCache { debug: init.config.debug, endpoint_config: init.config.invalidation.clone().map(Arc::new), subgraphs: Arc::new(init.config.subgraph), + connectors: Arc::new(init.config.connector), private_queries: Arc::new(RwLock::new(LruCache::new( init.config.private_queries_buffer_size, ))), @@ -480,6 +559,111 @@ impl PluginPrivate for ResponseCache { } } + fn connector_service(&self, service: connect::BoxService) -> connect::BoxService { + if !self.enabled { + return service; + } + + let storage = self.storage.clone(); + let connectors_config = self.connectors.clone(); + let private_queries = self.private_queries.clone(); + let debug = self.debug; + let supergraph_schema = self.supergraph_schema.clone(); + let subgraph_enums = self.subgraph_enums.clone(); + let lru_size_instrument = self.lru_size_instrument.clone(); + + ServiceBuilder::new() + .service(ConnectorCacheService { + service: ServiceBuilder::new() + .buffered() + .service(service) + .boxed_clone(), + storage, + connectors_config, + private_queries, + debug, + supergraph_schema, + subgraph_enums, + lru_size_instrument, + }) + .boxed() + } + + fn connector_request_service( + &self, + service: crate::services::connector::request_service::BoxService, + source_name: String, + ) -> crate::services::connector::request_service::BoxService { + if !self + .connectors + .is_source_enabled(self.enabled, &source_name) + { + // Even when caching is disabled for this connector source, we still need to + // propagate Cache-Control headers from the connector HTTP response into the + // shared context. This ensures the supergraph response Cache-Control header + // correctly reflects all upstream responses (matching the subgraph behavior). + let connector_ttl = self + .connector_ttl(&source_name) + .unwrap_or_else(|| Duration::from_secs(60 * 60 * 24)); + return ServiceBuilder::new() + .map_response( + move |response: crate::services::connector::request_service::Response| { + if let Ok( + apollo_federation::connectors::runtime::http_json_transport::TransportResponse::Http( + ref http_response, + ), + ) = response.transport_result + { + update_cache_control( + &response.context, + &CacheControl::new( + &http_response.inner.headers, + connector_ttl.into(), + ) + .ok() + .unwrap_or_else(CacheControl::no_store), + ); + } + response + }, + ) + .service(service) + .boxed(); + } + + let connector_ttl = self + .connector_ttl(&source_name) + .unwrap_or_else(|| Duration::from_secs(60 * 60 * 24)); + let storage = self.storage.clone(); + let private_id_key = self + .connectors + .get(&source_name) + .private_id + .clone() + .or_else(|| self.connectors.all.private_id.clone()); + let source_name_owned = source_name; + + let debug = self.debug; + + ServiceBuilder::new() + .service(ConnectorRequestCacheService { + service: ServiceBuilder::new() + .buffered() + .service(service) + .boxed_clone(), + storage, + source_name: source_name_owned, + connector_ttl, + private_id_key, + debug, + supergraph_schema: self.supergraph_schema.clone(), + subgraph_enums: self.subgraph_enums.clone(), + private_queries: self.private_queries.clone(), + lru_size_instrument: self.lru_size_instrument.clone(), + }) + .boxed() + } + fn web_endpoints(&self) -> MultiMap { let mut map = MultiMap::new(); // At least 1 subgraph enabled caching @@ -509,16 +693,43 @@ impl PluginPrivate for ResponseCache { .unwrap_or_default() }); + let any_connector_caching_enabled = self.connectors.all.enabled.unwrap_or(false) + || self + .connectors + .sources + .values() + .any(|s| s.enabled.unwrap_or(false)); + + let any_connector_invalidation_enabled = self + .connectors + .all + .invalidation + .as_ref() + .map(|i| i.enabled) + .unwrap_or_default() + || self.connectors.sources.values().any(|s| { + s.invalidation + .as_ref() + .map(|i| i.enabled) + .unwrap_or_default() + }); + if self.enabled - && any_caching_enabled - && (global_invalidation_enabled || any_subgraph_invalidation_enabled) + && (any_caching_enabled || any_connector_caching_enabled) + && (global_invalidation_enabled + || any_subgraph_invalidation_enabled + || any_connector_invalidation_enabled) { match &self.endpoint_config { Some(endpoint_config) => { let endpoint = Endpoint::from_router_service( endpoint_config.path.clone(), - InvalidationService::new(self.subgraphs.clone(), self.invalidation.clone()) - .boxed(), + InvalidationService::new( + self.subgraphs.clone(), + self.connectors.clone(), + self.invalidation.clone(), + ) + .boxed(), ); tracing::info!( "Response cache invalidation endpoint listening on: {}{}", @@ -569,6 +780,8 @@ impl ResponseCache { let storage = Arc::new(StorageInterface { all: Some(Arc::new(storage.into())), subgraphs: HashMap::new(), + connector_all: None, + connector_sources: HashMap::new(), }); let invalidation = Invalidation::new(storage.clone()).await?; Ok(Self { @@ -577,6 +790,7 @@ impl ResponseCache { enabled: true, debug: true, subgraphs: Arc::new(subgraphs), + connectors: Arc::new(ConnectorCacheConfiguration::default()), private_queries: Arc::new(RwLock::new(LruCache::new(DEFAULT_LRU_PRIVATE_QUERIES_SIZE))), endpoint_config: Some(Arc::new(InvalidationEndpointConfig { path: String::from("/invalidation"), @@ -611,6 +825,8 @@ impl ResponseCache { let storage = Arc::new(StorageInterface { all: Some(Default::default()), subgraphs: HashMap::new(), + connector_all: None, + connector_sources: HashMap::new(), }); let invalidation = Invalidation::new(storage.clone()).await?; let (drop_tx, _drop_rx) = broadcast::channel(2); @@ -630,6 +846,7 @@ impl ResponseCache { }, subgraphs, }), + connectors: Arc::new(ConnectorCacheConfiguration::default()), private_queries: Arc::new(RwLock::new(LruCache::new(DEFAULT_LRU_PRIVATE_QUERIES_SIZE))), endpoint_config: Some(Arc::new(InvalidationEndpointConfig { path: String::from("/invalidation"), @@ -679,6 +896,16 @@ impl ResponseCache { .map(|t| t.0) .or_else(|| self.subgraphs.all.ttl.clone().map(|ttl| ttl.0)) } + + // Returns the configured ttl for this connector source + fn connector_ttl(&self, source_name: &str) -> Option { + self.connectors + .get(source_name) + .ttl + .clone() + .map(|t| t.0) + .or_else(|| self.connectors.all.ttl.clone().map(|ttl| ttl.0)) + } } impl Drop for ResponseCache { @@ -1217,15 +1444,21 @@ impl CacheService { } fn get_private_id(&self, context: &Context) -> Option { - let private_id_value = context.get_json_value(self.private_id_key_name.as_ref()?)?; - let private_id = private_id_value.as_str()?; - - let mut digest = blake3::Hasher::new(); - digest.update(private_id.as_bytes()); - Some(digest.finalize().to_hex().to_string()) + hash_private_id(context, self.private_id_key_name.as_ref()?) } } +/// Hashes a private ID value from the request context using blake3. +/// Used by all cache service types (subgraph, connector, connector request) to generate +/// the private_id suffix for cache keys. +pub(super) fn hash_private_id(context: &Context, key_name: &str) -> Option { + let value = context.get_json_value(key_name)?; + let id = value.as_str()?; + let mut digest = blake3::Hasher::new(); + digest.update(id.as_bytes()); + Some(digest.finalize().to_hex().to_string()) +} + #[allow(clippy::too_many_arguments)] async fn cache_lookup_root( name: String, @@ -1631,7 +1864,7 @@ async fn cache_lookup_entities( } } -fn update_cache_control(context: &Context, cache_control: &CacheControl) { +pub(super) fn update_cache_control(context: &Context, cache_control: &CacheControl) { context.extensions().with_lock(|lock| { if let Some(c) = lock.get_mut::() { *c = c.merge(cache_control); @@ -1959,7 +2192,7 @@ fn extract_cache_keys( } /// Get invalidation keys from @cacheTag directives in supergraph schema for entities -fn get_invalidation_entity_keys_from_schema( +pub(super) fn get_invalidation_entity_keys_from_schema( supergraph_schema: &Arc>, subgraph_name: &str, subgraph_enums: &HashMap, @@ -2312,13 +2545,13 @@ fn get_entity_key_from_selection_set( } /// represents the result of a cache lookup for an entity type and key -struct IntermediateResult { - key: String, - invalidation_keys: Vec, - typename: String, +pub(super) struct IntermediateResult { + pub(super) key: String, + pub(super) invalidation_keys: Vec, + pub(super) typename: String, // Only set when debug mode is enabled - entity_key: Option>, - cache_entry: Option, + pub(super) entity_key: Option>, + pub(super) cache_entry: Option, } // build a new list of representations without the ones we got from the cache @@ -2574,14 +2807,16 @@ async fn insert_entities_in_result( Ok((new_entities, new_errors)) } -fn external_invalidation_keys>(invalidation_keys: I) -> Vec { +pub(super) fn external_invalidation_keys>( + invalidation_keys: I, +) -> Vec { invalidation_keys .into_iter() .filter(|k| !k.starts_with(INTERNAL_CACHE_TAG_PREFIX)) .collect() } -fn assemble_response_from_errors( +pub(super) fn assemble_response_from_errors( graphql_errors: &[Error], result: &mut Vec, ) -> (Vec, Vec) { diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__cache_key__tests__connector_entity_cache_key_format.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__cache_key__tests__connector_entity_cache_key_format.snap new file mode 100644 index 0000000000..6303687507 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__cache_key__tests__connector_entity_cache_key_format.snap @@ -0,0 +1,5 @@ +--- +source: apollo-router/src/plugins/response_cache/cache_key.rs +expression: hash +--- +version:1.2:connector:mysubgraph.my_api:type:User:representation:f6e8c0c4f36d6c0d11330be52b76316559e0390ff75fd002ed3fa73f9b6470f7:hash:abc123:data:def456 diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__cache_key__tests__connector_root_cache_key_format.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__cache_key__tests__connector_root_cache_key_format.snap new file mode 100644 index 0000000000..537bac3148 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__cache_key__tests__connector_root_cache_key_format.snap @@ -0,0 +1,5 @@ +--- +source: apollo-router/src/plugins/response_cache/cache_key.rs +expression: hash +--- +version:1.2:connector:mysubgraph.my_api:type:Query:hash:abc123:data:def456 diff --git a/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__cache_key__tests__hash_operation_deterministic.snap b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__cache_key__tests__hash_operation_deterministic.snap new file mode 100644 index 0000000000..6446d859d7 --- /dev/null +++ b/apollo-router/src/plugins/response_cache/snapshots/apollo_router__plugins__response_cache__cache_key__tests__hash_operation_deterministic.snap @@ -0,0 +1,5 @@ +--- +source: apollo-router/src/plugins/response_cache/cache_key.rs +expression: hash1 +--- +bf010e596ec24d732919deab3a74ec26765f9544c62f43e658fe1de4c45f959f diff --git a/apollo-router/src/plugins/response_cache/tests.rs b/apollo-router/src/plugins/response_cache/tests.rs index 47cea496d4..615059f685 100644 --- a/apollo-router/src/plugins/response_cache/tests.rs +++ b/apollo-router/src/plugins/response_cache/tests.rs @@ -12,13 +12,20 @@ use tokio_stream::wrappers::IntervalStream; use tower::Service; use tower::ServiceExt; use uuid::Uuid; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; use super::plugin::ResponseCache; +use crate::Configuration; use crate::Context; use crate::MockedSubgraphs; use crate::TestHarness; use crate::configuration::subgraph::SubgraphConfiguration; use crate::graphql; +use crate::json_ext::ValueExt; use crate::metrics::FutureMetricsExt; use crate::plugin::test::MockSubgraph; use crate::plugin::test::MockSubgraphService; @@ -32,8 +39,12 @@ use crate::plugins::response_cache::plugin::Subgraph; use crate::plugins::response_cache::storage::CacheStorage; use crate::plugins::response_cache::storage::redis::Config; use crate::plugins::response_cache::storage::redis::Storage; +use crate::router_factory::RouterSuperServiceFactory; +use crate::router_factory::YamlRouterFactory; +use crate::services::new_service::ServiceFactory; use crate::services::subgraph; use crate::services::supergraph; +use crate::uplink::license_enforcement::LicenseState; const SCHEMA: &str = include_str!("../../testdata/orga_supergraph_cache_key.graphql"); const SCHEMA_CACHE_TAG: &str = @@ -3914,7 +3925,7 @@ async fn no_store_on_subgraph_timeout() { .subgraph_hook(|name, service| { if name == "orga" { tower::service_fn(|_req: subgraph::Request| async move { - tokio::time::sleep(Duration::from_millis(500)).await; + tokio::time::sleep(Duration::from_secs(2)).await; // Unreachable in practice — the traffic shaping timeout fires first. Err::("orga sleep exceeded".into()) }) @@ -4051,3 +4062,1056 @@ async fn no_store_on_partial_subgraph_failure() { "expected errors in response body due to failing subgraph" ); } + +// ================================================================================================ +// Connector response cache integration tests +// ================================================================================================ + +const CONNECTOR_SCHEMA: &str = include_str!("../../testdata/connector_response_cache.graphql"); + +/// Helper to create a router service with connector caching enabled via YamlRouterFactory. +/// +/// We cannot use TestHarness because connectors are extracted during YamlRouterFactory +/// initialization, not during TestHarness construction. +async fn create_connector_cache_factory( + connector_uri: &str, + namespace: &str, + extra_config: Option, +) -> impl crate::services::new_service::ServiceFactory< + crate::services::router::Request, + Service = impl tower::Service< + crate::services::router::Request, + Response = crate::services::router::Response, + Error = tower::BoxError, + >, +> { + let connector_url = format!("{connector_uri}/"); + + let mut config = serde_json_bytes::json!({ + "include_subgraph_errors": { "all": true }, + "connectors": { + "sources": { + "connectors.json": { + "override_url": connector_url + } + } + }, + "response_cache": { + "enabled": true, + "debug": true, + "connector": { + "all": { + "enabled": true, + "redis": { + "urls": ["redis://127.0.0.1:6379"], + "pool_size": 1, + "namespace": namespace, + "required_to_start": true, + }, + "ttl": "10m", + } + } + } + }); + + if let Some(extra) = extra_config { + config.deep_merge(extra); + } + + let config: Configuration = serde_json_bytes::from_value(config).unwrap(); + let mut factory = YamlRouterFactory; + factory + .create( + false, + Arc::new(config.clone()), + Arc::new(crate::spec::Schema::parse(CONNECTOR_SCHEMA, &config).unwrap()), + None, + None, + Arc::new(LicenseState::Licensed { limits: None }), + ) + .await + .unwrap() +} + +async fn create_connector_cache_service( + connector_uri: &str, + namespace: &str, + extra_config: Option, +) -> impl tower::Service< + crate::services::router::Request, + Response = crate::services::router::Response, + Error = tower::BoxError, +> { + create_connector_cache_factory(connector_uri, namespace, extra_config) + .await + .create() +} + +/// Make a supergraph query request with cache debug header enabled. +fn make_connector_cache_request(query: &str) -> crate::services::router::Request { + make_connector_cache_request_with_cache_control(query, None) +} + +/// Make a supergraph query request WITHOUT the cache debug header. +fn make_connector_cache_request_no_debug(query: &str) -> crate::services::router::Request { + supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .build() + .unwrap() + .try_into() + .unwrap() +} + +fn make_connector_cache_request_with_cache_control( + query: &str, + cache_control: Option<&str>, +) -> crate::services::router::Request { + let mut builder = supergraph::Request::fake_builder() + .query(query) + .context(Context::new()) + .header( + HeaderName::from_static(CACHE_DEBUG_HEADER_NAME), + HeaderValue::from_static("true"), + ); + if let Some(cc) = cache_control { + builder = builder.header(CACHE_CONTROL, HeaderValue::from_str(cc).unwrap()); + } + builder.build().unwrap().try_into().unwrap() +} + +/// Extract the response body as a JSON value from a router response. +async fn connector_response_body( + mut response: crate::services::router::Response, +) -> serde_json::Value { + let bytes = response.next_response().await.unwrap().unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +#[tokio::test] +async fn connector_root_field_cache_miss_then_hit() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=300") + .set_body_json(serde_json::json!([ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"} + ])), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + let service = create_connector_cache_service(&uri, &namespace, None).await; + + // First request: cache miss + let request = make_connector_cache_request("query { users { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "first request should return data, got: {body:?}" + ); + let received_after_first = mock_server.received_requests().await.unwrap().len(); + assert!( + received_after_first >= 1, + "mock server should have received at least 1 request" + ); + + // Wait for async cache insert + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second request: cache hit — recreate service to ensure no in-memory state + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request("query { users { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "second request should return data, got: {body:?}" + ); + + // Wiremock should NOT have received a new request for /users (served from cache) + let received_after_second = mock_server.received_requests().await.unwrap().len(); + assert_eq!( + received_after_first, received_after_second, + "second request should be served from cache, but mock received new requests" + ); +} + +#[tokio::test] +async fn connector_root_field_no_store() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "no-store") + .set_body_json(serde_json::json!([ + {"id": 1, "name": "Alice"} + ])), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + let service = create_connector_cache_service(&uri, &namespace, None).await; + + // First request + let request = make_connector_cache_request("query { users { id name } }"); + let _response = service.oneshot(request).await.unwrap(); + let received_after_first = mock_server.received_requests().await.unwrap().len(); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second request should NOT be cached due to no-store + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request("query { users { id name } }"); + let _response = service.oneshot(request).await.unwrap(); + let received_after_second = mock_server.received_requests().await.unwrap().len(); + + assert!( + received_after_second > received_after_first, + "no-store responses should not be cached, but no new request was made" + ); +} + +#[tokio::test] +async fn connector_entity_cache_miss_then_hit() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=300") + .set_body_json(serde_json::json!({"id": 1, "name": "Alice"})), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + let service = create_connector_cache_service(&uri, &namespace, None).await; + + // First request: cache miss + let request = make_connector_cache_request("query { user(id: \"1\") { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "first request should return data, got: {body:?}" + ); + let received_after_first = mock_server.received_requests().await.unwrap().len(); + assert!( + received_after_first >= 1, + "mock server should have received at least 1 request" + ); + + // Wait for async cache insert + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second request: cache hit + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request("query { user(id: \"1\") { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "second request should return data, got: {body:?}" + ); + + let received_after_second = mock_server.received_requests().await.unwrap().len(); + assert_eq!( + received_after_first, received_after_second, + "second request should be served from cache, but mock received new requests" + ); +} + +#[tokio::test] +async fn connector_cache_disabled() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=300") + .set_body_json(serde_json::json!([ + {"id": 1, "name": "Alice"} + ])), + ) + .mount(&mock_server) + .await; + + // Disable connector caching explicitly + let extra_config = serde_json_bytes::json!({ + "response_cache": { + "connector": { + "all": { + "enabled": false, + } + } + } + }); + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + let service = + create_connector_cache_service(&uri, &namespace, Some(extra_config.clone())).await; + + // First request + let request = make_connector_cache_request("query { users { id name } }"); + let _response = service.oneshot(request).await.unwrap(); + let received_after_first = mock_server.received_requests().await.unwrap().len(); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second request should still hit the backend (caching disabled) + let service = create_connector_cache_service(&uri, &namespace, Some(extra_config)).await; + let request = make_connector_cache_request("query { users { id name } }"); + let _response = service.oneshot(request).await.unwrap(); + let received_after_second = mock_server.received_requests().await.unwrap().len(); + + assert!( + received_after_second > received_after_first, + "with caching disabled, every request should hit the backend" + ); +} + +#[tokio::test] +async fn connector_root_field_with_cache_tag() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=300") + .set_body_json(serde_json::json!([ + {"id": 1, "name": "Alice"} + ])), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + let service = create_connector_cache_service(&uri, &namespace, None).await; + + // Execute query — the schema has @cacheTag(format: "users") on the users field + let request = make_connector_cache_request("query { users { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "request should return data, got: {body:?}" + ); + + // Wait for async cache insert + tokio::time::sleep(Duration::from_secs(2)).await; + + // Verify it was cached by checking second request doesn't hit mock + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request("query { users { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "cached request should return data, got: {body:?}" + ); +} + +/// Request with `Cache-Control: no-store` should allow cache lookup but prevent storing. +#[tokio::test] +async fn connector_root_field_request_no_store() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=300") + .set_body_json(serde_json::json!([ + {"id": 1, "name": "Alice"} + ])), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + // First request WITH no-store: response should NOT be cached + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request_with_cache_control( + "query { users { id name } }", + Some("no-store"), + ); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "first request should return data, got: {body:?}" + ); + let received_after_first = mock_server.received_requests().await.unwrap().len(); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second request WITHOUT no-store: should be a cache miss since first request didn't store + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request("query { users { id name } }"); + let _response = service.oneshot(request).await.unwrap(); + let received_after_second = mock_server.received_requests().await.unwrap().len(); + + assert!( + received_after_second > received_after_first, + "no-store request should prevent caching, but second request was served from cache" + ); +} + +/// Request with `Cache-Control: no-cache` should skip cache lookup but allow storing. +#[tokio::test] +async fn connector_root_field_request_no_cache() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=300") + .set_body_json(serde_json::json!([ + {"id": 1, "name": "Alice"} + ])), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + // First request with no-cache: should hit backend (skip cache), but store the response + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request_with_cache_control( + "query { users { id name } }", + Some("no-cache"), + ); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "first request should return data, got: {body:?}" + ); + let received_after_first = mock_server.received_requests().await.unwrap().len(); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second request WITHOUT no-cache: should be served from cache (first request stored it) + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request("query { users { id name } }"); + let _response = service.oneshot(request).await.unwrap(); + let received_after_second = mock_server.received_requests().await.unwrap().len(); + + assert_eq!( + received_after_first, received_after_second, + "no-cache should still allow storing, so second request should be served from cache" + ); +} + +/// Request with `Cache-Control: no-cache, no-store` should bypass cache entirely. +#[tokio::test] +async fn connector_root_field_request_no_cache_no_store() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=300") + .set_body_json(serde_json::json!([ + {"id": 1, "name": "Alice"} + ])), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + // First request with both no-cache and no-store: bypass cache entirely + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request_with_cache_control( + "query { users { id name } }", + Some("no-cache, no-store"), + ); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "first request should return data, got: {body:?}" + ); + let received_after_first = mock_server.received_requests().await.unwrap().len(); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second request without cache-control: should be a cache miss (first didn't store) + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request("query { users { id name } }"); + let _response = service.oneshot(request).await.unwrap(); + let received_after_second = mock_server.received_requests().await.unwrap().len(); + + assert!( + received_after_second > received_after_first, + "no-cache+no-store should bypass cache entirely, but second request was served from cache" + ); +} + +/// Entity query with `Cache-Control: no-cache` should skip cache lookup. +#[tokio::test] +async fn connector_entity_request_no_cache() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=300") + .set_body_json(serde_json::json!({"id": 1, "name": "Alice"})), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + // First request (no special headers): populates cache + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request("query { user(id: \"1\") { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "first request should return data, got: {body:?}" + ); + let received_after_first = mock_server.received_requests().await.unwrap().len(); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second request with no-cache: should skip cache and hit backend + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request_with_cache_control( + "query { user(id: \"1\") { id name } }", + Some("no-cache"), + ); + let _response = service.oneshot(request).await.unwrap(); + let received_after_second = mock_server.received_requests().await.unwrap().len(); + + assert!( + received_after_second > received_after_first, + "no-cache request should skip cache lookup and hit backend" + ); +} + +/// Entity query with `Cache-Control: no-store` should allow cache lookup but prevent storing. +#[tokio::test] +async fn connector_entity_request_no_store() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=300") + .set_body_json(serde_json::json!({"id": 1, "name": "Alice"})), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + // First request WITH no-store: response should NOT be cached + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request_with_cache_control( + "query { user(id: \"1\") { id name } }", + Some("no-store"), + ); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "first request should return data, got: {body:?}" + ); + let received_after_first = mock_server.received_requests().await.unwrap().len(); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second request WITHOUT no-store: should be a cache miss since first request didn't store + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request("query { user(id: \"1\") { id name } }"); + let _response = service.oneshot(request).await.unwrap(); + let received_after_second = mock_server.received_requests().await.unwrap().len(); + + assert!( + received_after_second > received_after_first, + "no-store request should prevent caching, but second request was served from cache" + ); +} + +/// When a connector returns `Cache-Control: private` and no `private_id` is configured, +/// the response must NOT be stored in cache (prevents cross-user cache pollution). +#[tokio::test] +async fn connector_root_field_private_no_id_not_stored() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "private, max-age=300") + .set_body_json(serde_json::json!([ + {"id": 1, "name": "Alice"} + ])), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + // No private_id configured — private responses must not be cached + let service = create_connector_cache_service(&uri, &namespace, None).await; + + // First request + let request = make_connector_cache_request("query { users { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "first request should return data, got: {body:?}" + ); + let received_after_first = mock_server.received_requests().await.unwrap().len(); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second request: should NOT be served from cache (private without private_id) + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request("query { users { id name } }"); + let _response = service.oneshot(request).await.unwrap(); + let received_after_second = mock_server.received_requests().await.unwrap().len(); + + assert!( + received_after_second > received_after_first, + "private response without private_id should not be cached, but second request was served from cache" + ); +} + +/// When a connector returns `Cache-Control: private` and no `private_id` is configured, +/// entity responses must NOT be stored in cache. +#[tokio::test] +async fn connector_entity_private_no_id_not_stored() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "private, max-age=300") + .set_body_json(serde_json::json!({"id": 1, "name": "Alice"})), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + // No private_id configured — private responses must not be cached + let service = create_connector_cache_service(&uri, &namespace, None).await; + + // First request + let request = make_connector_cache_request("query { user(id: \"1\") { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "first request should return data, got: {body:?}" + ); + let received_after_first = mock_server.received_requests().await.unwrap().len(); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second request: should NOT be served from cache (private without private_id) + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request("query { user(id: \"1\") { id name } }"); + let _response = service.oneshot(request).await.unwrap(); + let received_after_second = mock_server.received_requests().await.unwrap().len(); + + assert!( + received_after_second > received_after_first, + "private entity response without private_id should not be cached, but second request was served from cache" + ); +} + +#[tokio::test] +async fn connector_mutation_not_cached() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/users")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=300") + .set_body_json(serde_json::json!({"id": 1, "name": "Alice"})), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + let service = create_connector_cache_service(&uri, &namespace, None).await; + + // First request: mutation + let request = + make_connector_cache_request("mutation { createUser(name: \"Alice\") { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "first mutation should return data, got: {body:?}" + ); + let received_after_first = mock_server.received_requests().await.unwrap().len(); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second request: same mutation — should NOT be served from cache + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = + make_connector_cache_request("mutation { createUser(name: \"Alice\") { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "second mutation should return data, got: {body:?}" + ); + let received_after_second = mock_server.received_requests().await.unwrap().len(); + + assert!( + received_after_second > received_after_first, + "mutation responses should not be cached, but second request was served from cache" + ); +} + +#[tokio::test] +async fn connector_root_field_debug_requires_header() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=300") + .set_body_json(serde_json::json!([ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bob"} + ])), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + // Request WITHOUT debug header — should not have apolloCacheDebugging extension + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request_no_debug("query { users { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "request without debug header should return data, got: {body:?}" + ); + assert!( + body.pointer("/extensions/apolloCacheDebugging").is_none(), + "response should NOT contain apolloCacheDebugging without the debug header, got: {body:?}" + ); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Request WITH debug header — should have apolloCacheDebugging extension (cache hit) + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request("query { users { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "request with debug header should return data, got: {body:?}" + ); + assert!( + body.pointer("/extensions/apolloCacheDebugging").is_some(), + "response should contain apolloCacheDebugging with the debug header, got: {body:?}" + ); +} + +#[tokio::test] +async fn connector_entity_debug_requires_header() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=300") + .set_body_json(serde_json::json!({"id": 1, "name": "Alice"})), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + // Request WITHOUT debug header — should not have apolloCacheDebugging extension + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request_no_debug("query { user(id: \"1\") { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "request without debug header should return data, got: {body:?}" + ); + assert!( + body.pointer("/extensions/apolloCacheDebugging").is_none(), + "response should NOT contain apolloCacheDebugging without the debug header, got: {body:?}" + ); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Request WITH debug header — should have apolloCacheDebugging extension (cache hit) + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request("query { user(id: \"1\") { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "request with debug header should return data, got: {body:?}" + ); + assert!( + body.pointer("/extensions/apolloCacheDebugging").is_some(), + "response should contain apolloCacheDebugging with the debug header, got: {body:?}" + ); +} + +/// A malformed `Cache-Control` header on a root field request should return a GraphQL error. +#[tokio::test] +async fn connector_root_field_invalid_cache_control_header() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=300") + .set_body_json(serde_json::json!([ + {"id": 1, "name": "Alice"} + ])), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request_with_cache_control( + "query { users { id name } }", + Some("max-age=notanumber"), + ); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + + let errors = body.get("errors").and_then(|e| e.as_array()); + assert!( + errors.is_some_and(|errs| !errs.is_empty()), + "response should contain errors for invalid cache-control header, got: {body:?}" + ); + assert_eq!( + errors.unwrap()[0] + .pointer("/extensions/code") + .and_then(|v| v.as_str()), + Some("INVALID_CACHE_CONTROL_HEADER"), + "error should have INVALID_CACHE_CONTROL_HEADER extension code, got: {body:?}" + ); + + let received = mock_server.received_requests().await.unwrap().len(); + assert_eq!( + received, 0, + "upstream should not be called when cache-control header is invalid" + ); +} + +/// A malformed `Cache-Control` header on an entity query should return a GraphQL error. +#[tokio::test] +async fn connector_entity_invalid_cache_control_header() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "public, max-age=300") + .set_body_json(serde_json::json!({"id": 1, "name": "Alice"})), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + let service = create_connector_cache_service(&uri, &namespace, None).await; + let request = make_connector_cache_request_with_cache_control( + "query { user(id: \"1\") { id name } }", + Some("max-age=notanumber"), + ); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + + let errors = body.get("errors").and_then(|e| e.as_array()); + assert!( + errors.is_some_and(|errs| !errs.is_empty()), + "response should contain errors for invalid cache-control header, got: {body:?}" + ); + assert_eq!( + errors.unwrap()[0] + .pointer("/extensions/code") + .and_then(|v| v.as_str()), + Some("INVALID_CACHE_CONTROL_HEADER"), + "error should have INVALID_CACHE_CONTROL_HEADER extension code, got: {body:?}" + ); + + let received = mock_server.received_requests().await.unwrap().len(); + assert_eq!( + received, 0, + "upstream should not be called when cache-control header is invalid" + ); +} + +/// When a known-private root field query bypasses cache (no private_id configured), +/// a debug entry with `key: "-"` and `shouldStore: false` should appear in `apolloCacheDebugging`. +#[tokio::test] +async fn connector_root_field_private_debug_entry() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "private, max-age=300") + .set_body_json(serde_json::json!([ + {"id": 1, "name": "Alice"} + ])), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + // Use a shared factory so the in-memory private_queries LRU persists across requests + let factory = create_connector_cache_factory(&uri, &namespace, None).await; + + // First request: populates the private query LRU + let service = factory.create(); + let request = make_connector_cache_request("query { users { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "first request should return data, got: {body:?}" + ); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second request: known-private bypass path should include debug entry + let service = factory.create(); + let request = make_connector_cache_request("query { users { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + + let debug_entries = body + .pointer("/extensions/apolloCacheDebugging/data") + .expect("response should contain apolloCacheDebugging extension with data"); + + let entries = debug_entries + .as_array() + .expect("debug entries should be an array"); + let private_entry = entries + .iter() + .find(|e| e.pointer("/key").and_then(|v| v.as_str()) == Some("-")); + assert!( + private_entry.is_some(), + "should have a debug entry with key '-' for the known-private bypass, got: {entries:?}" + ); + + let entry = private_entry.unwrap(); + assert_eq!( + entry.pointer("/shouldStore").and_then(|v| v.as_bool()), + Some(false), + "known-private debug entry should have shouldStore: false, got: {entry:?}" + ); +} + +/// When a known-private entity query bypasses cache (no private_id configured), +/// a debug entry with `key: "-"` and `shouldStore: false` should appear in `apolloCacheDebugging`. +#[tokio::test] +async fn connector_entity_private_debug_entry() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/users/1")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("cache-control", "private, max-age=300") + .set_body_json(serde_json::json!({"id": 1, "name": "Alice"})), + ) + .mount(&mock_server) + .await; + + let uri = mock_server.uri(); + let namespace = Uuid::new_v4().to_string(); + + // Use a shared factory so the in-memory private_queries LRU persists across requests + let factory = create_connector_cache_factory(&uri, &namespace, None).await; + + // First request: populates the private query LRU + let service = factory.create(); + let request = make_connector_cache_request("query { user(id: \"1\") { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + assert!( + body.get("data").is_some(), + "first request should return data, got: {body:?}" + ); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Second request: known-private bypass path should include debug entry + let service = factory.create(); + let request = make_connector_cache_request("query { user(id: \"1\") { id name } }"); + let response = service.oneshot(request).await.unwrap(); + let body = connector_response_body(response).await; + + let debug_entries = body + .pointer("/extensions/apolloCacheDebugging/data") + .expect("response should contain apolloCacheDebugging extension with data"); + + let entries = debug_entries + .as_array() + .expect("debug entries should be an array"); + let private_entry = entries + .iter() + .find(|e| e.pointer("/key").and_then(|v| v.as_str()) == Some("-")); + assert!( + private_entry.is_some(), + "should have a debug entry with key '-' for the known-private bypass, got: {entries:?}" + ); + + let entry = private_entry.unwrap(); + assert_eq!( + entry.pointer("/shouldStore").and_then(|v| v.as_bool()), + Some(false), + "known-private debug entry should have shouldStore: false, got: {entry:?}" + ); +} diff --git a/apollo-router/src/plugins/telemetry/config_new/cache/mod.rs b/apollo-router/src/plugins/telemetry/config_new/cache/mod.rs index ea04d43b35..5ff5e5ef21 100644 --- a/apollo-router/src/plugins/telemetry/config_new/cache/mod.rs +++ b/apollo-router/src/plugins/telemetry/config_new/cache/mod.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use attributes::CacheAttributes; use opentelemetry::Key; use opentelemetry::KeyValue; +use opentelemetry::metrics::Counter; use schemars::JsonSchema; use serde::Deserialize; use tower::BoxError; @@ -229,3 +230,63 @@ impl Instrumented for CacheInstruments { } } } + +/// Cache instruments for connector services. +/// +/// Unlike `CacheInstruments` which is typed on `subgraph::Request/Response` and uses the +/// `CustomCounter` generic machinery, this struct directly holds an OTel counter and reads +/// cache hit/miss data from the request context. This avoids needing `Selector`/`Selectors` +/// trait impls for connector request/response types. +pub(crate) struct ConnectorCacheInstruments { + counter: Option>, + source_name: String, +} + +impl ConnectorCacheInstruments { + pub(crate) fn new(counter: Option>, source_name: String) -> Self { + Self { + counter, + source_name, + } + } + + /// Read cache hit/miss data from context and record metrics. + /// Call this after the connector service response is available. + pub(crate) fn on_response(&self, context: &crate::Context) { + let Some(counter) = &self.counter else { + return; + }; + + let cache_info: ResponseCacheSubgraph = match context + .get(ResponseCacheMetricContextKey::new(self.source_name.clone())) + .ok() + .flatten() + { + Some(cache_info) => cache_info, + None => { + return; + } + }; + + for (entity_type, ResponseCacheHitMiss { hit, miss }) in &cache_info.0 { + if *hit > 0 { + counter.add( + *hit as f64, + &[ + KeyValue::new(ENTITY_TYPE, entity_type.to_string()), + KeyValue::new(CACHE_HIT, true), + ], + ); + } + if *miss > 0 { + counter.add( + *miss as f64, + &[ + KeyValue::new(ENTITY_TYPE, entity_type.to_string()), + KeyValue::new(CACHE_HIT, false), + ], + ); + } + } + } +} diff --git a/apollo-router/src/plugins/telemetry/config_new/instruments.rs b/apollo-router/src/plugins/telemetry/config_new/instruments.rs index 22b9a1b191..c231c6e6af 100644 --- a/apollo-router/src/plugins/telemetry/config_new/instruments.rs +++ b/apollo-router/src/plugins/telemetry/config_new/instruments.rs @@ -25,6 +25,7 @@ use super::Selector; use super::cache::CACHE_METRIC; use super::cache::CacheInstruments; use super::cache::CacheInstrumentsConfig; +use super::cache::ConnectorCacheInstruments; use super::cache::attributes::CacheAttributes; use super::graphql::FIELD_EXECUTION; use super::graphql::FIELD_LENGTH; @@ -1102,6 +1103,21 @@ impl InstrumentsConfig { }), } } + + pub(crate) fn new_connector_cache_instruments( + &self, + static_instruments: Arc>, + source_name: String, + ) -> ConnectorCacheInstruments { + let counter = if self.cache.attributes.response_cache.is_enabled() { + static_instruments + .get(RESPONSE_CACHE_METRIC) + .and_then(|s| s.as_counter_f64().cloned()) + } else { + None + }; + ConnectorCacheInstruments::new(counter, source_name) + } } #[derive(Debug)] diff --git a/apollo-router/src/plugins/telemetry/mod.rs b/apollo-router/src/plugins/telemetry/mod.rs index 736b21dd38..4d240d5039 100644 --- a/apollo-router/src/plugins/telemetry/mod.rs +++ b/apollo-router/src/plugins/telemetry/mod.rs @@ -13,6 +13,7 @@ use ::tracing::Span; use ::tracing::info_span; use config_new::Selectors; use config_new::cache::CacheInstruments; +use config_new::cache::ConnectorCacheInstruments; use config_new::connector::instruments::ConnectorInstruments; use config_new::instruments::InstrumentsConfig; use config_new::instruments::StaticInstrument; @@ -137,6 +138,7 @@ use crate::services::SubgraphRequest; use crate::services::SubgraphResponse; use crate::services::SupergraphRequest; use crate::services::SupergraphResponse; +use crate::services::connect; use crate::services::connector; use crate::services::execution; use crate::services::layers::apq::PERSISTED_QUERY_CACHE_HIT; @@ -1189,6 +1191,45 @@ impl PluginPrivate for Telemetry { .boxed() } + fn connector_service(&self, service: connect::BoxService) -> connect::BoxService { + let config = self.config.clone(); + let static_cache_instruments = self + .builtin_instruments + .read() + .cache_custom_instruments + .clone(); + ServiceBuilder::new() + .map_future_with_request_data( + move |request: &connect::Request| { + let connectors = + crate::plugins::connectors::query_plans::get_connectors(&request.context); + let source_name = connectors + .as_ref() + .and_then(|c| c.get(&request.service_name)) + .map(|c| c.source_config_key()) + .unwrap_or_default(); + let cache_instruments = config + .instrumentation + .instruments + .new_connector_cache_instruments( + static_cache_instruments.clone(), + source_name, + ); + (request.context.clone(), cache_instruments) + }, + move |(context, cache_instruments): (Context, ConnectorCacheInstruments), + f: BoxFuture<'static, Result>| async move { + let result = f.await; + if result.is_ok() { + cache_instruments.on_response(&context); + } + result + }, + ) + .service(service) + .boxed() + } + fn http_client_service( &self, _subgraph_name: &str, diff --git a/apollo-router/src/services/connect.rs b/apollo-router/src/services/connect.rs index 898faa4968..a5b3354ce7 100644 --- a/apollo-router/src/services/connect.rs +++ b/apollo-router/src/services/connect.rs @@ -10,6 +10,7 @@ use static_assertions::assert_impl_all; use tower::BoxError; use crate::Context; +use crate::batching::BatchQuery; use crate::graphql; use crate::graphql::Request as GraphQLRequest; use crate::query_planner::fetch::Variables; @@ -69,3 +70,11 @@ impl Request { } } } + +impl Request { + pub(crate) fn is_part_of_batch(&self) -> bool { + self.context + .extensions() + .with_lock(|lock| lock.contains_key::()) + } +} diff --git a/apollo-router/src/services/connector/request_service.rs b/apollo-router/src/services/connector/request_service.rs index c6ad5013f4..4bd1ab320b 100644 --- a/apollo-router/src/services/connector/request_service.rs +++ b/apollo-router/src/services/connector/request_service.rs @@ -28,6 +28,7 @@ use tower::BoxError; use tower::ServiceExt; use crate::Context; +use crate::batching::BatchQuery; use crate::error::FetchError; use crate::graphql; use crate::layers::DEFAULT_BUFFER_SIZE; @@ -82,6 +83,14 @@ pub struct Request { pub(crate) operation: Option>>, } +impl Request { + pub(crate) fn is_part_of_batch(&self) -> bool { + self.context + .extensions() + .with_lock(|lock| lock.contains_key::()) + } +} + /// Response type for a connector #[derive(Debug)] pub struct Response { diff --git a/apollo-router/src/services/connector_service.rs b/apollo-router/src/services/connector_service.rs index 15720cc740..551e555496 100644 --- a/apollo-router/src/services/connector_service.rs +++ b/apollo-router/src/services/connector_service.rs @@ -30,6 +30,7 @@ use crate::plugins::telemetry::consts::CONNECT_SPAN_NAME; use crate::query_planner::fetch::SubgraphSchemas; use crate::services::ConnectRequest; use crate::services::ConnectResponse; +use crate::services::Plugins; use crate::services::connector::request_service::ConnectorRequestServiceFactory; use crate::spec::Schema; @@ -236,6 +237,7 @@ pub(crate) struct ConnectorServiceFactory { pub(crate) connectors_by_service_name: Arc, Connector>>, _connect_spec_version_instrument: Option>, pub(crate) connector_request_service_factory: Arc, + pub(crate) plugins: Arc, } impl ConnectorServiceFactory { @@ -245,6 +247,7 @@ impl ConnectorServiceFactory { subscription_config: Option, connectors_by_service_name: Arc, Connector>>, connector_request_service_factory: Arc, + plugins: Arc, ) -> Self { Self { subgraph_schemas, @@ -255,6 +258,7 @@ impl ConnectorServiceFactory { schema.connectors.as_ref(), ), connector_request_service_factory, + plugins, } } @@ -270,6 +274,7 @@ impl ConnectorServiceFactory { Default::default(), Default::default(), )), + Default::default(), ) } } @@ -278,13 +283,16 @@ impl ServiceFactory for ConnectorServiceFactory { type Service = BoxService; fn create(&self) -> Self::Service { - ConnectorService { - _schema: self.schema.clone(), - _subgraph_schemas: self.subgraph_schemas.clone(), - _subscription_config: self.subscription_config.clone(), - connectors_by_service_name: self.connectors_by_service_name.clone(), - connector_request_service_factory: self.connector_request_service_factory.clone(), - } - .boxed() + self.plugins.iter().rev().fold( + ConnectorService { + _schema: self.schema.clone(), + _subgraph_schemas: self.subgraph_schemas.clone(), + _subscription_config: self.subscription_config.clone(), + connectors_by_service_name: self.connectors_by_service_name.clone(), + connector_request_service_factory: self.connector_request_service_factory.clone(), + } + .boxed(), + |acc, (_, e)| e.connector_service(acc), + ) } } diff --git a/apollo-router/src/services/supergraph/service.rs b/apollo-router/src/services/supergraph/service.rs index 34d29969b4..7577a41f30 100644 --- a/apollo-router/src/services/supergraph/service.rs +++ b/apollo-router/src/services/supergraph/service.rs @@ -571,6 +571,7 @@ impl PluggableSupergraphServiceBuilder { self.plugins.clone(), connector_sources, )), + self.plugins.clone(), )), Arc::new(configuration.experimental_hoist_orphan_errors.clone()), )); diff --git a/apollo-router/src/testdata/connector_response_cache.graphql b/apollo-router/src/testdata/connector_response_cache.graphql new file mode 100644 index 0000000000..9acab2a4db --- /dev/null +++ b/apollo-router/src/testdata/connector_response_cache.graphql @@ -0,0 +1,70 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.5", for: EXECUTION) + @link(url: "https://specs.apollo.dev/connect/v0.1", for: EXECUTION) + @join__directive(graphs: [CONNECTORS], name: "link", args: {url: "https://specs.apollo.dev/connect/v0.1", import: ["@connect", "@source"]}) + @join__directive(graphs: [CONNECTORS], name: "source", args: {name: "json", http: {baseURL: "https://jsonplaceholder.typicode.com/"}}) +{ + query: Query + mutation: Mutation +} + +directive @join__directive(graphs: [join__Graph!], name: String!, args: join__DirectiveArguments) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field(graph: join__Graph, requires: join__FieldSet, provides: join__FieldSet, type: String, external: Boolean, override: String, usedOverridden: Boolean, overrideLabel: String, contextArguments: [join__ContextArgument!]) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements(graph: join__Graph!, interface: String!) repeatable on OBJECT | INTERFACE + +directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember(graph: join__Graph!, member: String!) repeatable on UNION + +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + +input join__ContextArgument { + name: String! + type: String! + context: String! + selection: join__FieldValue! +} + +scalar join__DirectiveArguments + +scalar join__FieldSet + +scalar join__FieldValue + +enum join__Graph { + CONNECTORS @join__graph(name: "connectors", url: "none") +} + +scalar link__Import + +enum link__Purpose { + SECURITY + EXECUTION +} + +type Query + @join__type(graph: CONNECTORS) +{ + users: [User] @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users"}, selection: "id name"}) @join__directive(graphs: [CONNECTORS], name: "federation__cacheTag", args: {format: "users"}) + user(id: ID!): User @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {GET: "/users/{$args.id}"}, selection: "id name", entity: true}) +} + +type Mutation + @join__type(graph: CONNECTORS) +{ + createUser(name: String!): User @join__field(graph: CONNECTORS) @join__directive(graphs: [CONNECTORS], name: "connect", args: {source: "json", http: {POST: "/users"}, selection: "id name"}) +} + +type User + @join__type(graph: CONNECTORS, key: "id") +{ + id: ID! + name: String @join__field(graph: CONNECTORS) +} diff --git a/docs/shared/config/response_cache.mdx b/docs/shared/config/response_cache.mdx index 0ea45181af..08cacfb83f 100644 --- a/docs/shared/config/response_cache.mdx +++ b/docs/shared/config/response_cache.mdx @@ -37,6 +37,33 @@ response_cache: enabled: false shared_key: '' subgraphs: {} + connector: + all: + enabled: true + ttl: 30s + private_id: null + redis: + urls: + - redis://127.0.0.1:6379 + username: example_username + password: example_password + fetch_timeout: 150ms + insert_timeout: 500ms + invalidate_timeout: 1s + maintenance_timeout: 500ms + namespace: example_namespace + tls: + certificate_authorities: null + client_authentication: + certificate_chain: example_certificate_chain + key: example_key + required_to_start: false + pool_size: 5 + metrics_interval: 1s + invalidation: + enabled: false + shared_key: '' + sources: {} ``` diff --git a/docs/source/routing/performance/caching/response-caching/customization.mdx b/docs/source/routing/performance/caching/response-caching/customization.mdx index 0189fb394c..3ddfe23451 100644 --- a/docs/source/routing/performance/caching/response-caching/customization.mdx +++ b/docs/source/routing/performance/caching/response-caching/customization.mdx @@ -19,14 +19,15 @@ Consider these advanced customization options when: For basic response caching setup, see the [Quickstart](/router/performance/caching/response-caching/quickstart) page. - Response caching is happening at the subgraph service level and this plugin is happening right after coprocessor and Rhai plugins which means you'll always call the coprocessor or Rhai script even if the response is cached (both at request and response level). + Response caching happens at the subgraph service level (for subgraphs) and the connector service level (for + connectors). This plugin runs right after coprocessor and Rhai plugins, which means you'll always call the coprocessor + or Rhai script even if the response is cached (both at request and response level). ## Private data caching A subgraph can return a response with the header `Cache-Control: private`, indicating that it contains user-personalized data. Although this usually forbids intermediate servers from storing data, the router can recognize different users and store their data in different parts of the cache. - Use private data caching when: - Your subgraph returns user-specific data that can be cached (shopping cart, user preferences, personalized recommendations) @@ -78,6 +79,21 @@ fn supergraph_service(service) { } ``` +For connector sources, configure `private_id` in the `connector` block: + +```yaml title="router.yaml" +response_cache: + enabled: true + connector: + all: + enabled: true + redis: + urls: ["redis://..."] + sources: + "accounts.rest_api": + private_id: "user_id" +``` + ### How private data caching works The router performs the following sequence to determine whether a particular query returns private data: @@ -86,14 +102,14 @@ The router performs the following sequence to determine whether a particular que 2. When the subgraph returns the response with private data, the router recognizes it and stores the data in a user-specific part of the cache. 3. The router stores the query in a list of known queries with private data. 4. When the router subsequently sees a known query: - - If the private ID isn't provided, the router doesn't check the cache and instead transmits the subgraph response directly. - - If the private ID is provided, the router queries the part of the cache for the current user and checks the subgraph if nothing is available. + +- If the private ID isn't provided, the router doesn't check the cache and instead transmits the subgraph response directly. +- If the private ID is provided, the router queries the part of the cache for the current user and checks the subgraph if nothing is available. ## Custom cache keys To store data for a particular request in different cache entries, configure the cache key through the `apollo::response_cache::key` context entry. - Use custom cache keys when you need to: - Cache different versions of the same query based on request headers (locale, currency, feature flags) @@ -105,6 +121,7 @@ Use custom cache keys when you need to: You can customize the response cache key with the `apollo::response_cache::key` context entry. Data in this entry modifies the data used to generate the cache key, and it can be any valid JSON. You can apply customizations at multiple scales using different fields in `apollo::response_cache::key`: + - An `all` field, which affects all subgraph requests - A `subgraphs` field, which contains an object with per-subgraph customization - A field for each operation name, which affects only a specific operation @@ -115,16 +132,16 @@ Example: ```json { - "all": 1, - "subgraph_operation1": "key1", - "subgraph_operation2": { - "data": "key2" - }, - "subgraphs": { - "my_subgraph": { - "locale": "be" - } + "all": 1, + "subgraph_operation1": "key1", + "subgraph_operation2": { + "data": "key2" + }, + "subgraphs": { + "my_subgraph": { + "locale": "be" } + } } ``` @@ -211,6 +228,28 @@ response_cache: enabled: false # Disable for a specific subgraph ``` +### Per-source Redis instances (connectors) + +Configure a global Redis instance for connectors and override it for specific connector sources: + +```yaml title="router.yaml" +response_cache: + enabled: true + connector: + all: + enabled: true + redis: + urls: ["redis://..."] + sources: + "products.rest_api": + redis: + urls: ["redis://products-cache:6379"] + pool_size: 15 + namespace: products_connector_cache + "inventory.stock_api": + enabled: false # Disable caching for this source +``` + ### Redis URL formats @@ -248,7 +287,7 @@ or, if configured with multiple URLs: [ "redis|rediss[-cluster] :// [[username:]password@] host [:port]", "redis|rediss[-cluster] :// [[username:]password@] host1 [:port1]", - "redis|rediss[-cluster] :// [[username:]password@] host2 [:port2]" + "redis|rediss[-cluster] :// [[username:]password@] host2 [:port2]", ] ``` @@ -265,7 +304,7 @@ response_cache: enabled: true # Configure Redis globally redis: - urls: [ "rediss://redis.example.com:6379" ] + urls: ["rediss://redis.example.com:6379"] #highlight-start username: root password: ${env.REDIS_PASSWORD} @@ -312,6 +351,20 @@ response_cache: ttl: 6h ``` +For connector sources, configure TTL with `connector.all.ttl` or per source. Connectors respect `Cache-Control` headers from the upstream HTTP response directly—the configured TTL is used as a fallback when no header is present: + +```yaml title="router.yaml" +response_cache: + enabled: true + connector: + all: + enabled: true + ttl: 24h + sources: + "products.rest_api": + ttl: 6h +``` + ### Namespace prefix When using the same Redis instance for multiple purposes, the `namespace` option defines a prefix for all the keys defined by the router: diff --git a/docs/source/routing/performance/caching/response-caching/faq.mdx b/docs/source/routing/performance/caching/response-caching/faq.mdx index 7e37c881e8..c103fd222f 100644 --- a/docs/source/routing/performance/caching/response-caching/faq.mdx +++ b/docs/source/routing/performance/caching/response-caching/faq.mdx @@ -140,3 +140,19 @@ During internal testing\*, the Router was able to fetch at least 5000 entities p 98% of inserts under 15ms. \*Tests conducted against a single-shard Redis cluster running on a [`redis-standard-small`](https://docs.cloud.google.com/memorystore/docs/cluster/cluster-node-specification#node_type_specification) instance. + +### How does response caching work with Apollo Connectors? + +Connectors use the same response caching infrastructure as subgraphs, but with a separate `response_cache.connector` configuration block. The router caches connector responses at both the entity level and the root field (HTTP request) level. Configuration uses `connector.all` for global defaults and `connector.sources` for per-source overrides, where sources are identified by `subgraph_name.source_name`. + +### How do I identify a connector source in the configuration? + +Connector sources use the `subgraph_name.source_name` format. For example, if your subgraph is named `products` and the connector source is `my_rest_api`, the source identifier is `products.my_rest_api`. This format appears in the `connector.sources` configuration map, invalidation requests, and telemetry labels. + +### Can I cache subgraphs and connectors simultaneously? + +Yes. The `response_cache.subgraph` and `response_cache.connector` blocks are independent. You can enable caching for subgraphs, connectors, or both. Each has its own Redis configuration, TTLs, and invalidation settings. + +### Do connectors support `@cacheControl` directives? + +Connectors don't use `@cacheControl` directives because they call HTTP APIs directly. Instead, the router reads the `Cache-Control` HTTP headers returned by the upstream REST API. If the API returns `Cache-Control: max-age=300`, the router caches the response for 300 seconds. The configured `ttl` serves as a fallback when no `Cache-Control` header is present. diff --git a/docs/source/routing/performance/caching/response-caching/invalidation.mdx b/docs/source/routing/performance/caching/response-caching/invalidation.mdx index 9ccd6e9846..be196763d7 100644 --- a/docs/source/routing/performance/caching/response-caching/invalidation.mdx +++ b/docs/source/routing/performance/caching/response-caching/invalidation.mdx @@ -25,6 +25,7 @@ The router caches origin query responses—specifically, root query fields as co The `Cache-Control` header itself is derived from `@cacheControl` directives in your origin schema. When an origin response contains multiple entity representations, the origin generates a `Cache-Control` header with the minimum TTL value across all representations in that response. (You can use the [cache debugger](/router/performance/caching/response-caching/observability#cache-debugger) to inspect these headers.) When responding to client queries, the router: + 1. Calculates the overall response TTL by taking the minimum TTL from all cached origin responses included in the client response 2. Generates a `Cache-Control` header for the client response reflecting this minimum TTL 3. Returns cached data as long as the TTL hasn't expired @@ -41,11 +42,30 @@ response_cache: subgraph: all: enabled: true - ttl: 60s # Default TTL for all subgraphs + ttl: 60s # Default TTL for all subgraphs + redis: + urls: ["redis://localhost:6379"] +``` + +For connector sources, configure the TTL in the `connector` block: + +```yaml title="router.yaml" +response_cache: + enabled: true + connector: + all: + enabled: true + ttl: 60s # Default TTL for all connector sources redis: urls: ["redis://localhost:6379"] ``` + + +Apollo Connectors don't use `@cacheControl` directives because they call HTTP APIs directly. Instead, the router reads the `Cache-Control` HTTP headers returned by the upstream REST API. If the API returns `Cache-Control: max-age=300`, the router caches the response for 300 seconds. The configured `ttl` serves as a fallback when no `Cache-Control` header is present. + + + ### Control TTL with `@cacheControl` For GraphQL origins that support the `@cacheControl` directive (such as Apollo Server), you can set field-level and type-level TTLs directly in your schema. The origin translates these directives into `Cache-Control` headers in its HTTP responses. The router reads those `Cache-Control` headers to determine TTLs—the directives themselves don't affect what the router caches, only the headers the source sends back. @@ -104,7 +124,7 @@ type Post @cacheControl(maxAge: 240) { } type Comment { - post: Post! # Cached for up to 240 seconds + post: Post! # Cached for up to 240 seconds body: String! } ``` @@ -115,7 +135,7 @@ Field-level settings override type-level settings: ```graphql type Comment { - post: Post! @cacheControl(maxAge: 120) # Overrides the 240s type-level setting + post: Post! @cacheControl(maxAge: 120) # Overrides the 240s type-level setting body: String! } ``` @@ -132,8 +152,8 @@ The `scope` argument controls whether cached data can be shared across users: ```graphql type User { id: ID! - name: String @cacheControl(maxAge: 3600) # Public: same for all users - cart: [Product!]! @cacheControl(maxAge: 60, scope: PRIVATE) # Private: per-user + name: String @cacheControl(maxAge: 3600) # Public: same for all users + cart: [Product!]! @cacheControl(maxAge: 60, scope: PRIVATE) # Private: per-user } ``` @@ -142,6 +162,7 @@ See the [Customization](/router/performance/caching/response-caching/customizati #### Learn more about TTL For complete details on cache control configuration in Apollo Server, including: + - Dynamic TTL setting in resolvers - Default `maxAge` behavior for root fields vs. other fields - Recommendations for TTL configuration @@ -153,6 +174,7 @@ See the [Apollo Server caching documentation](https://www.apollographql.com/docs Active invalidation enables you to remove specific cached data before its TTL expires. This is useful when you know data has changed and want to ensure clients receive fresh data immediately. Use active invalidation when: + - Changes happen infrequently and unpredictably (product updates, price changes) - You need immediate cache updates when data changes - The cost of serving stale data is high @@ -199,6 +221,31 @@ response_cache: shared_key: ${env.INVALIDATION_SHARED_KEY_PRODUCTS} ``` +For connector sources, configure invalidation in the `connector` block: + +```yaml title="router.yaml" +response_cache: + enabled: true + + invalidation: + listen: "127.0.0.1:3000" + path: "/invalidation" + + connector: + all: + enabled: true + redis: + urls: ["redis://..."] + invalidation: + enabled: true + shared_key: ${env.INVALIDATION_SHARED_KEY} + sources: + "products.my_rest_api": + invalidation: + enabled: true + shared_key: ${env.INVALIDATION_SHARED_KEY_PRODUCTS} +``` + #### Configuration options ##### `listen` @@ -265,6 +312,7 @@ flowchart RL ``` One invalidation request can invalidate multiple cached entries at the same time. It can invalidate: + - All cached entries for a specific subgraph - All cached entries for a specific type in a specific subgraph - All cached entries marked with a cache tag in specific subgraphs @@ -275,10 +323,7 @@ Consider the following subgraph schema, which is part of a federated schema: ```graphql title=accounts.graphql extend schema - @link( - url: "https://specs.apollo.dev/federation/v2.12" - import: ["@key", "@requires", "@external", "@cacheTag"] - ) + @link(url: "https://specs.apollo.dev/federation/v2.12", import: ["@key", "@requires", "@external", "@cacheTag"]) type Query { user(id: ID!): User @cacheTag(format: "profile") @@ -297,7 +342,6 @@ type Post @key(fields: "id") { id: ID! content: String! @external } - ``` #### By subgraph @@ -305,10 +349,12 @@ type Post @key(fields: "id") { To invalidate all cached data from the `accounts` subgraph, send a request with the following format: ```json -[{ - "kind": "subgraph", - "subgraph": "accounts" -}] +[ + { + "kind": "subgraph", + "subgraph": "accounts" + } +] ``` #### By entity type @@ -316,11 +362,13 @@ To invalidate all cached data from the `accounts` subgraph, send a request with To invalidate all cached data for entity type `User` in the `accounts` subgraph, send a JSON payload in this format: ```json -[{ - "kind": "type", - "subgraph": "accounts", - "type": "User" -}] +[ + { + "kind": "type", + "subgraph": "accounts", + "type": "User" + } +] ``` #### By cache tag @@ -330,31 +378,37 @@ To invalidate all cached data with a specific cache tag `profile` in the `accoun Send a JSON payload in this format: ```json -[{ - "kind": "cache_tag", - "subgraphs": ["accounts"], - "cache_tag": "profile" -}] +[ + { + "kind": "cache_tag", + "subgraphs": ["accounts"], + "cache_tag": "profile" + } +] ``` You can also add a dynamic cache tag containing the entity key to the `User` entity type, specified like this: `@cacheTag(format: "user-{$key.id}")`. This enables you to invalidate cached data for a specific `User`, such as one with an ID of `42`: ```json -[{ - "kind": "cache_tag", - "subgraphs": ["accounts"], - "cache_tag": "user-42" -}] +[ + { + "kind": "cache_tag", + "subgraphs": ["accounts"], + "cache_tag": "user-42" + } +] ``` Invalidate root fields with parameters using dynamic cache tags. Set a cache tag on root fields with parameters by interpolating parameters in the cache tag format using `$args`. For example, on the `postsByUser` root field, set `@cacheTag(format: "posts-user-{$args.userId}")`, which becomes `posts-user-42` when you pass `42` as the `userId` parameter: ```json -[{ - "kind": "cache_tag", - "subgraphs": ["accounts"], - "cache_tag": "posts-user-42" -}] +[ + { + "kind": "cache_tag", + "subgraphs": ["accounts"], + "cache_tag": "posts-user-42" + } +] ``` @@ -368,11 +422,10 @@ The `@cacheTag` directive has the following constraints regarding its applicatio - The `format` must always generate a valid string (not an object) ❌ Invalid example (field not in all keys): + ```graphql -type Product - @key(fields: "upc") @key(fields: "name") - @cacheTag(format: "product-{$key.name}") { -# Error at composition: name isn't in all @key directives +type Product @key(fields: "upc") @key(fields: "name") @cacheTag(format: "product-{$key.name}") { + # Error at composition: name isn't in all @key directives upc: String! name: String! price: Int @@ -380,10 +433,9 @@ type Product ``` ✅ Valid example (field in all keys): + ```graphql -type Product - @key(fields: "upc") @key(fields: "upc isbn") - @cacheTag(format: "product-{$key.upc}") { +type Product @key(fields: "upc") @key(fields: "upc isbn") @cacheTag(format: "product-{$key.upc}") { upc: String! isbn: String! name: String! @@ -392,6 +444,7 @@ type Product ``` ❌ Invalid example (format generates an object): + ```graphql type Product @key(fields: "upc country { name }") @@ -404,6 +457,7 @@ type Product ``` ✅ Valid example (format generates a string): + ```graphql type Product @key(fields: "upc country { name }") @@ -424,12 +478,14 @@ 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. 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 @@ -437,20 +493,25 @@ The following example shows a response payload that sets cache tags for entities ```json { - "data": {"_entities": [ - {"__typename": "User", "id": 42, "name": "Alice"}, - {"__typename": "User", "id": 1023, "name": "Bob"}, - {"__typename": "User", "id": 7, "name": "Charlie"} - ]}, - "extensions": {"apolloEntityCacheTags": [ - ["users", "user-42"], - ["users", "user-1023"], - ["users", "user-7"] - ]} + "data": { + "_entities": [ + { "__typename": "User", "id": 42, "name": "Alice" }, + { "__typename": "User", "id": 1023, "name": "Bob" }, + { "__typename": "User", "id": 7, "name": "Charlie" } + ] + }, + "extensions": { + "apolloEntityCacheTags": [ + ["users", "user-42"], + ["users", "user-1023"], + ["users", "user-7"] + ] + } } ``` 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"]` @@ -460,11 +521,13 @@ Because each entity is cached separately with its own tags, you can invalidate i 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" -}] +[ + { + "kind": "cache_tag", + "subgraphs": ["your-subgraph-name"], + "cache_tag": "user-42" + } +] ``` #### Root field cache tags @@ -487,6 +550,51 @@ The following example shows a response payload that sets cache tags for a `homep 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. +### Connector invalidation methods + +For Apollo Connectors, the invalidation endpoint supports connector-specific request formats. Connector sources are identified using the `subgraph_name.source_name` format. + +#### By connector source + +To invalidate all cached data from a connector source, send a request with the following format: + +```json +[ + { + "kind": "connector", + "source": "products.my_rest_api" + } +] +``` + +#### By entity type + +To invalidate all cached data for a specific entity type in a connector source, use `"source"` instead of `"subgraph"`: + +```json +[ + { + "kind": "type", + "source": "products.my_rest_api", + "type": "Product" + } +] +``` + +#### By cache tag + +To invalidate cached data with a specific cache tag in connector sources, use `"sources"` instead of `"subgraphs"`: + +```json +[ + { + "kind": "cache_tag", + "sources": ["products.my_rest_api"], + "cache_tag": "product-42" + } +] +``` + ### 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: @@ -533,20 +641,11 @@ You can use both `@cacheControl` (for TTL-based passive invalidation) and `@cach Here's a simple example combining both directives: ```graphql -extend schema - @link( - url: "https://specs.apollo.dev/federation/v2.12" - import: ["@key", "@cacheTag"] - ) +extend schema @link(url: "https://specs.apollo.dev/federation/v2.12", import: ["@key", "@cacheTag"]) -directive @cacheControl( - maxAge: Int - scope: CacheControlScope -) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION +directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION -type User @key(fields: "id") - @cacheControl(maxAge: 60) - @cacheTag(format: "user-{$key.id}") { +type User @key(fields: "id") @cacheControl(maxAge: 60) @cacheTag(format: "user-{$key.id}") { id: ID! name: String! email: String! @@ -554,6 +653,7 @@ type User @key(fields: "id") ``` In this example: + - The `@cacheControl(maxAge: 60)` directive sets a 60-second TTL—data automatically expires after one minute - The `@cacheTag(format: "user-{$key.id}")` directive enables immediate invalidation when user data changes diff --git a/docs/source/routing/performance/caching/response-caching/observability.mdx b/docs/source/routing/performance/caching/response-caching/observability.mdx index 6786961f81..b8e512f580 100644 --- a/docs/source/routing/performance/caching/response-caching/observability.mdx +++ b/docs/source/routing/performance/caching/response-caching/observability.mdx @@ -28,6 +28,8 @@ telemetry: # You can add more custom attributes using subgraph selectors ``` +The `apollo.router.response.cache` instrument tracks cache hits and misses for both subgraph and connector requests. For connector sources, the source name appears in the `subgraph_name.source_name` format. + You can use custom instruments to create metrics for the subgraph service. The following example creates a custom instrument to generate a histogram that measures the subgraph request duration when there's at least one cache hit for the "inventory" subgraph: ```yaml title="router.yaml" @@ -111,6 +113,8 @@ The `response_cache.store` span shows how much time was spent inserting data int For invalidation, look for the `invalidation_endpoint` span. +The `response_cache.lookup` and `response_cache.store` spans also appear for connector requests. For connectors, `subgraph.name` contains the connector source identifier in `subgraph_name.source_name` format. + Available attributes on `response_cache.lookup`: - `kind`: `root` or `entity`. Indicates whether the cache lookup is for a root field or an entity. - `subgraph.name`: The subgraph name @@ -130,7 +134,7 @@ Available attributes on `response_cache.store`: ## Logs -The router supports a [`response_cache` selector](/router/configuration/telemetry/instrumentation/selectors#subgraph) in telemetry for the subgraph service. The selector returns either the number of cache hits or misses by an entity for a subgraph request or the cache status (`hit`|`partial_hit`|`miss`) for a subgraph request. +The router supports a [`response_cache` selector](/router/configuration/telemetry/instrumentation/selectors#subgraph) in telemetry for the subgraph and connector services. The selector returns either the number of cache hits or misses by an entity for a request or the cache status (`hit`|`partial_hit`|`miss`) for a request. For example, display a log containing all subgraph response data that's not cached: @@ -261,7 +265,7 @@ response_cache: src="../../../../images/response-cache/sandbox-dropdown.png" /> -- A list of cached or potentially cached entries appears. This list helps you understand the cache status of your data: +- A list of cached or potentially cached entries appears, including both subgraph and connector cache entries. For connector sources, the `source` column displays names in the `subgraph_name.source_name` format. This list helps you understand the cache status of your data: - If the `Created at` column contains data, the value has been stored in the cache - If the `source` column is `products`, the data for this call was fetched from the `products` subgraph, even if it is now cached - If the `Created at` column is empty, the entry hasn't been cached. This might happen for multiple reasons (see [Troubleshoot](#troubleshoot)). In this example, the `accounts` subgraph entry isn't cached because it contains private, uncacheable data. diff --git a/docs/source/routing/performance/caching/response-caching/overview.mdx b/docs/source/routing/performance/caching/response-caching/overview.mdx index 06ed5951b2..3cabb31e7d 100644 --- a/docs/source/routing/performance/caching/response-caching/overview.mdx +++ b/docs/source/routing/performance/caching/response-caching/overview.mdx @@ -1,7 +1,7 @@ --- title: Response Caching -subtitle: Cache subgraph responses to improve query performance -description: Learn how response caching in GraphOS Router enables entity-level caching to reduce subgraph load and improve query latency. +subtitle: Cache subgraph and connector responses to improve query performance +description: Learn how response caching in GraphOS Router enables entity-level caching to reduce subgraph and connector load and improve query latency. minVersion: Router v2.10.0 --- @@ -13,13 +13,13 @@ Developer and Standard plans require Router v2.6.0 or later. -Learn how GraphOS Router can cache subgraph query responses using Redis to improve your query latency for entities in the supergraph. +Learn how GraphOS Router can cache subgraph and connector responses using Redis to improve your query latency for entities in the supergraph. ## Overview An entity gets its fields from one or more subgraphs. To respond to a client request for an entity, GraphOS Router must make multiple subgraph requests. Different clients requesting the same entity can make redundant, identical subgraph requests. -Response caching enables the router to cache origin responses and reuse them across queries. (An _origin_ is a data source.) The router caches two kinds of data: +Response caching enables the router to cache origin responses and reuse them across queries. An _origin_ is a data source—either a subgraph (via GraphQL) or an [Apollo Connector](/graphos/connectors) (via REST/HTTP APIs). The router caches two kinds of data: - **Root query fields**: Cached as complete units (the entire response for that root field) - **Entity representations**: Cached independently—each origin's contribution to an entity is cached separately and can be reused across different queries. (See [_entities query](/federation/subgraph-spec/#understanding-query_entities).) diff --git a/docs/source/routing/performance/caching/response-caching/quickstart.mdx b/docs/source/routing/performance/caching/response-caching/quickstart.mdx index 2a2a231476..d52702b9b6 100644 --- a/docs/source/routing/performance/caching/response-caching/quickstart.mdx +++ b/docs/source/routing/performance/caching/response-caching/quickstart.mdx @@ -64,6 +64,36 @@ response_cache: ``` +### Configure caching for Apollo Connectors + +To cache responses from [Apollo Connectors](/graphos/connectors), add a `connector` block alongside the `subgraph` block. Connector sources are identified using the `subgraph_name.source_name` format. + +```yaml title="router.yaml" +response_cache: + enabled: true + connector: + all: + enabled: true + ttl: 10m # Required: fallback TTL when responses don't include Cache-Control headers + redis: + urls: ["redis://localhost:6379"] + invalidation: + enabled: true + shared_key: ${env.INVALIDATION_SHARED_KEY} + # Configure overrides for specific connector sources + sources: + "products.my_rest_api": + ttl: 5m # Override TTL for this source + "inventory.stock_api": + enabled: false # Disable caching for this source +``` + + + +The `connector` configuration block mirrors the `subgraph` block, but uses `sources` (keyed by `subgraph_name.source_name`) instead of `subgraphs` (keyed by subgraph name). + + + ### Identify what data to cache To identify which subgraphs would benefit most from caching, you can enable metrics and increase their granularity. Keep in mind that more granularity leads to higher metric cardinality, which might increase costs in your APM. @@ -85,7 +115,9 @@ telemetry: # You can add more custom attributes using subgraph selectors ``` -You can use the `apollo.router.response.cache` metric to create a dashboard similar to the following example: +The `apollo.router.response.cache` metric tracks cache hits and misses for both subgraph and connector requests. + +You can use this metric to create a dashboard similar to the following example: + +For Apollo Connectors, TTLs are determined by the `Cache-Control` HTTP headers returned by the REST API endpoints your connectors call. You don't need `@cacheControl` directives since connectors work with HTTP APIs that return standard HTTP headers directly. The `ttl` configured in `connector.all` or `connector.sources` serves as a fallback when the API doesn't return a `Cache-Control` header. + + + With caching enabled, you can see the difference in the dashboard: more cache hits and fewer cache misses.