Skip to content

Commit ef38f88

Browse files
carodewigthe-gigi
authored andcommitted
fix: demand control actual costs should consider each subgraph fetch (#8827)
1 parent c7ce30a commit ef38f88

File tree

46 files changed

+1478
-76
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+1478
-76
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
### Demand control actual costs should consider each subgraph fetch ([PR #8827](https://github.com/apollographql/router/pull/8827))
2+
3+
The demand control feature estimates query costs by summing together the cost of each subgraph operation. This allows it
4+
to capture any intermediate work that must be completed to return a complete response.
5+
6+
Prior to this version, the actual query cost computation only considered the final response shape; it did not include
7+
any of the intermediate work done in its total.
8+
9+
This version fixes that behavior to compute the actual query cost as the sum of all subgraph response costs. This more
10+
accurately reflects the work done per operation and allows a more meaningful comparison
11+
between actual and estimated costs.
12+
13+
Note: if you would like to disable the new actual cost computation behavior, you should set the router configuration
14+
option `demand_control.strategy.static_estimated.actual_cost_mode` to `response_shape`.
15+
16+
```yaml
17+
demand_control:
18+
enabled: true
19+
mode: enforce
20+
strategy:
21+
static_estimated:
22+
max: 10
23+
list_size: 10
24+
actual_cost_mode: by_subgraph # the default value
25+
# actual_cost_mode: response_shape # revert to prior actual cost computation mode
26+
```
27+
28+
By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/8827

.config/nextest.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ or ( binary_id(=apollo-router::integration_tests) & test(=integration::connector
6767
or ( binary_id(=apollo-router::integration_tests) & test(=integration::connectors::authentication::test_aws_sig_v4_signing) )
6868
or ( binary_id(=apollo-router::integration_tests) & test(=integration::coprocessor::test_coprocessor_response_handling) )
6969
or ( binary_id(=apollo-router::integration_tests) & test(=integration::coprocessor::test_error_not_propagated_to_client) )
70+
or ( binary_id(=apollo-router::integration_tests) & test(=integration::entity_cache::test_cache_metrics) )
7071
or ( binary_id(=apollo-router::integration_tests) & test(=integration::file_upload::it_fails_incompatible_query_order) )
7172
or ( binary_id(=apollo-router::integration_tests) & test(=integration::file_upload::it_fails_invalid_file_order) )
7273
or ( binary_id(=apollo-router::integration_tests) & test(=integration::file_upload::it_fails_invalid_multipart_order) )

apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,21 @@ expression: "&schema"
115115
}
116116
]
117117
},
118+
"ActualCostMode": {
119+
"oneOf": [
120+
{
121+
"const": "by_subgraph",
122+
"description": "Computes the cost of each subgraph response and sums them to get the total query cost.",
123+
"type": "string"
124+
},
125+
{
126+
"const": "by_response_shape",
127+
"deprecated": true,
128+
"description": "Computes the cost based on the final structure of the composed response, not including any\ninterim structures from subgraph responses that did not make it to the composed response.",
129+
"type": "string"
130+
}
131+
]
132+
},
118133
"All": {
119134
"enum": [
120135
"all"
@@ -9137,6 +9152,14 @@ expression: "&schema"
91379152
"static_estimated": {
91389153
"additionalProperties": false,
91399154
"properties": {
9155+
"actual_cost_mode": {
9156+
"allOf": [
9157+
{
9158+
"$ref": "#/definitions/ActualCostMode"
9159+
}
9160+
],
9161+
"description": "The strategy used to calculate the actual cost incurred by an operation.\n\n* `by_subgraph` (default) computes the cost of each subgraph response and sums them\n to get the total query cost.\n* `by_response_shape` computes the cost based on the final structure of the composed\n response, not including any interim structures from subgraph responses that did not\n make it to the composed response."
9162+
},
91409163
"list_size": {
91419164
"description": "The assumed length of lists returned by the operation.",
91429165
"format": "uint32",

apollo-router/src/plugins/demand_control/cost_calculator/mod.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,22 @@ mod directives;
22
pub(in crate::plugins::demand_control) mod schema;
33
pub(crate) mod static_cost;
44

5+
use std::collections::HashMap;
6+
57
use crate::plugins::demand_control::DemandControlError;
8+
9+
#[derive(Clone, Default, Debug, serde::Serialize, serde::Deserialize)]
10+
pub(crate) struct CostBySubgraph(HashMap<String, f64>);
11+
impl CostBySubgraph {
12+
pub(crate) fn add_or_insert(&mut self, subgraph: &str, value: f64) {
13+
if let Some(subgraph_cost) = self.0.get_mut(subgraph) {
14+
*subgraph_cost += value;
15+
} else {
16+
self.0.insert(subgraph.to_string(), value);
17+
}
18+
}
19+
20+
pub(crate) fn total(&self) -> f64 {
21+
self.0.values().sum()
22+
}
23+
}

apollo-router/src/plugins/demand_control/cost_calculator/static_cost.rs

Lines changed: 45 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -553,53 +553,57 @@ impl<'schema> ResponseCostCalculator<'schema> {
553553
if field.name == TYPENAME {
554554
return;
555555
}
556-
if let Some(definition) = self.schema.output_field_definition(parent_ty, &field.name) {
557-
match value {
558-
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
559-
self.cost += definition
560-
.cost_directive()
561-
.map_or(0.0, |cost| cost.weight());
562-
}
563-
Value::Array(items) => {
564-
for item in items {
565-
self.visit_list_item(request, variables, parent_ty, field, item);
566-
}
567-
}
568-
Value::Object(children) => {
569-
self.cost += definition
570-
.cost_directive()
571-
.map_or(1.0, |cost| cost.weight());
572-
self.visit_selections(request, variables, &field.selection_set, children);
556+
557+
let definition = self.schema.output_field_definition(parent_ty, &field.name);
558+
559+
// We need to have a field definition for later processing, unless the query is an
560+
// `_entities` query. If the field should be there and isn't, return now.
561+
let is_entities_query = parent_ty == "Query" && field.name == "_entities";
562+
if definition.is_none() && !is_entities_query {
563+
tracing::debug!(
564+
"Failed to get schema definition for field {}.{}. The resulting response cost will be a partial result.",
565+
parent_ty,
566+
field.name,
567+
);
568+
return;
569+
}
570+
571+
match value {
572+
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
573+
self.cost += definition
574+
.and_then(|d| d.cost_directive())
575+
.map_or(0.0, |cost| cost.weight());
576+
}
577+
Value::Array(items) => {
578+
for item in items {
579+
self.visit_list_item(request, variables, parent_ty, field, item);
573580
}
574581
}
582+
Value::Object(children) => {
583+
self.cost += definition
584+
.and_then(|d| d.cost_directive())
585+
.map_or(1.0, |cost| cost.weight());
586+
self.visit_selections(request, variables, &field.selection_set, children);
587+
}
588+
}
575589

576-
if include_argument_score {
577-
for argument in &field.arguments {
578-
if let Some(argument_definition) = definition.argument_by_name(&argument.name) {
579-
if let Ok(score) = score_argument(
580-
&argument.value,
581-
argument_definition,
582-
self.schema,
583-
variables,
584-
) {
585-
self.cost += score;
586-
}
587-
} else {
588-
tracing::debug!(
589-
"Failed to get schema definition for argument {}.{}({}:). The resulting response cost will be a partial result.",
590-
parent_ty,
591-
field.name,
592-
argument.name,
593-
)
590+
if include_argument_score && let Some(definition) = definition {
591+
for argument in &field.arguments {
592+
if let Some(argument_definition) = definition.argument_by_name(&argument.name) {
593+
if let Ok(score) =
594+
score_argument(&argument.value, argument_definition, self.schema, variables)
595+
{
596+
self.cost += score;
594597
}
598+
} else {
599+
tracing::debug!(
600+
"Failed to get schema definition for argument {}.{}({}:). The resulting response cost will be a partial result.",
601+
parent_ty,
602+
field.name,
603+
argument.name,
604+
)
595605
}
596606
}
597-
} else {
598-
tracing::debug!(
599-
"Failed to get schema definition for field {}.{}. The resulting response cost will be a partial result.",
600-
parent_ty,
601-
field.name,
602-
)
603607
}
604608
}
605609
}

apollo-router/src/plugins/demand_control/mod.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use crate::json_ext::Object;
3434
use crate::layers::ServiceBuilderExt;
3535
use crate::plugin::Plugin;
3636
use crate::plugin::PluginInit;
37+
use crate::plugins::demand_control::cost_calculator::CostBySubgraph;
3738
use crate::plugins::demand_control::cost_calculator::schema::DemandControlledSchema;
3839
use crate::plugins::demand_control::strategy::Strategy;
3940
use crate::plugins::demand_control::strategy::StrategyFactory;
@@ -51,6 +52,9 @@ pub(crate) const COST_ACTUAL_KEY: &str = "apollo::demand_control::actual_cost";
5152
pub(crate) const COST_RESULT_KEY: &str = "apollo::demand_control::result";
5253
pub(crate) const COST_STRATEGY_KEY: &str = "apollo::demand_control::strategy";
5354

55+
pub(crate) const COST_BY_SUBGRAPH_ACTUAL_KEY: &str =
56+
"apollo::demand_control::actual_cost_by_subgraph";
57+
5458
/// Algorithm for calculating the cost of an incoming query.
5559
#[derive(Clone, Debug, Deserialize, JsonSchema)]
5660
#[serde(deny_unknown_fields, rename_all = "snake_case")]
@@ -73,6 +77,16 @@ pub(crate) enum StrategyConfig {
7377
list_size: u32,
7478
/// The maximum cost of a query
7579
max: f64,
80+
81+
/// The strategy used to calculate the actual cost incurred by an operation.
82+
///
83+
/// * `by_subgraph` (default) computes the cost of each subgraph response and sums them
84+
/// to get the total query cost.
85+
/// * `by_response_shape` computes the cost based on the final structure of the composed
86+
/// response, not including any interim structures from subgraph responses that did not
87+
/// make it to the composed response.
88+
#[serde(default)]
89+
actual_cost_mode: ActualCostMode,
7690
},
7791

7892
#[cfg(test)]
@@ -82,6 +96,41 @@ pub(crate) enum StrategyConfig {
8296
},
8397
}
8498

99+
#[derive(Copy, Clone, Debug, Default, Deserialize, JsonSchema)]
100+
#[serde(rename_all = "snake_case")]
101+
pub(crate) enum ActualCostMode {
102+
/// Computes the cost of each subgraph response and sums them to get the total query cost.
103+
#[default]
104+
BySubgraph,
105+
106+
/// Computes the cost based on the final structure of the composed response, not including any
107+
/// interim structures from subgraph responses that did not make it to the composed response.
108+
#[deprecated(since = "TBD", note = "use `BySubgraph` instead")]
109+
#[warn(deprecated_in_future)]
110+
ByResponseShape,
111+
}
112+
113+
impl StrategyConfig {
114+
fn validate(&self) -> Result<(), BoxError> {
115+
let actual_cost_mode = match self {
116+
StrategyConfig::StaticEstimated {
117+
actual_cost_mode, ..
118+
} => actual_cost_mode,
119+
#[cfg(test)]
120+
StrategyConfig::Test { .. } => return Ok(()),
121+
};
122+
123+
#[allow(deprecated_in_future)]
124+
if matches!(actual_cost_mode, ActualCostMode::ByResponseShape) {
125+
tracing::warn!(
126+
"Actual cost computation mode `by_response_shape` will be deprecated in the future; migrate to `by_subgraph` when possible",
127+
);
128+
}
129+
130+
Ok(())
131+
}
132+
}
133+
85134
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, Eq, PartialEq)]
86135
#[serde(deny_unknown_fields, rename_all = "snake_case")]
87136
pub(crate) enum Mode {
@@ -268,6 +317,30 @@ impl Context {
268317
Ok(estimated.zip(actual).map(|(est, act)| est - act))
269318
}
270319

320+
pub(crate) fn get_actual_cost_by_subgraph(
321+
&self,
322+
) -> Result<Option<CostBySubgraph>, DemandControlError> {
323+
self.get::<&str, CostBySubgraph>(COST_BY_SUBGRAPH_ACTUAL_KEY)
324+
.map_err(|e| DemandControlError::ContextSerializationError(e.to_string()))
325+
}
326+
327+
pub(crate) fn update_actual_cost_by_subgraph(
328+
&self,
329+
subgraph: &str,
330+
cost: f64,
331+
) -> Result<(), DemandControlError> {
332+
// combine this cost with the cost that already exists in the context
333+
self.upsert(
334+
COST_BY_SUBGRAPH_ACTUAL_KEY,
335+
|mut existing_cost: CostBySubgraph| {
336+
existing_cost.add_or_insert(subgraph, cost);
337+
existing_cost
338+
},
339+
)
340+
.map_err(|e| DemandControlError::ContextSerializationError(e.to_string()))?;
341+
Ok(())
342+
}
343+
271344
pub(crate) fn insert_cost_result(&self, result: String) -> Result<(), DemandControlError> {
272345
self.insert(COST_RESULT_KEY, result)
273346
.map_err(|e| DemandControlError::ContextSerializationError(e.to_string()))?;
@@ -348,6 +421,8 @@ impl Plugin for DemandControl {
348421
.insert(subgraph_name.clone(), demand_controlled_subgraph_schema);
349422
}
350423

424+
init.config.strategy.validate()?;
425+
351426
Ok(DemandControl {
352427
strategy_factory: StrategyFactory::new(
353428
init.config.clone(),
@@ -501,7 +576,7 @@ impl Plugin for DemandControl {
501576
|(subgraph_name, req): (String, Arc<Valid<ExecutableDocument>>), fut| async move {
502577
let resp: subgraph::Response = fut.await?;
503578
let strategy = resp.context.get_demand_control_context().map(|c| c.strategy).expect("must have strategy");
504-
Ok(match strategy.on_subgraph_response(req.as_ref(), &resp) {
579+
Ok(match strategy.on_subgraph_response(req.as_ref(), &resp, &subgraph_name) {
505580
Ok(_) => resp,
506581
Err(err) => subgraph::Response::builder()
507582
.errors(

apollo-router/src/plugins/demand_control/strategy/mod.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,12 @@ impl Strategy {
5252
&self,
5353
request: &ExecutableDocument,
5454
response: &subgraph::Response,
55+
subgraph_name: &str,
5556
) -> Result<(), DemandControlError> {
56-
match self.inner.on_subgraph_response(request, response) {
57+
match self
58+
.inner
59+
.on_subgraph_response(request, response, subgraph_name)
60+
{
5761
Err(e) if self.mode == Mode::Enforce => Err(e),
5862
_ => Ok(()),
5963
}
@@ -93,8 +97,13 @@ impl StrategyFactory {
9397

9498
pub(crate) fn create(&self) -> Strategy {
9599
let strategy: Arc<dyn StrategyImpl> = match &self.config.strategy {
96-
StrategyConfig::StaticEstimated { list_size, max } => Arc::new(StaticEstimated {
100+
StrategyConfig::StaticEstimated {
101+
list_size,
102+
max,
103+
actual_cost_mode,
104+
} => Arc::new(StaticEstimated {
97105
max: *max,
106+
actual_cost_mode: *actual_cost_mode,
98107
cost_calculator: StaticCostCalculator::new(
99108
self.supergraph_schema.clone(),
100109
self.subgraph_schemas.clone(),
@@ -122,6 +131,7 @@ pub(crate) trait StrategyImpl: Send + Sync {
122131
&self,
123132
request: &ExecutableDocument,
124133
response: &subgraph::Response,
134+
subgraph_name: &str,
125135
) -> Result<(), DemandControlError>;
126136
fn on_execution_response(
127137
&self,

0 commit comments

Comments
 (0)