Skip to content
Merged
Show file tree
Hide file tree
Changes from 45 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
24b228e
feat: add mode to support toggling between cost calculation modes
carodewig Jan 21, 2026
4808218
feat: compute cost on each subgraph response when enabled
carodewig Jan 21, 2026
007da11
fix: support cost calculation for `_entities` query
carodewig Jan 21, 2026
bea0e31
test: update snapshots for new actuals
carodewig Jan 21, 2026
23d4e3c
test: add call count to context to assert in tests
carodewig Jan 21, 2026
65ca9c2
test: add integration tests for demand control
carodewig Jan 22, 2026
1f77c2c
test: clean up tests to simplify via #[values]
carodewig Jan 22, 2026
9e23447
test: additional cases based on federated_ships_fragment
carodewig Jan 22, 2026
6641c6b
test: fix snapshot with new cost
carodewig Jan 22, 2026
337f6a5
doc: add new config option to the docs
carodewig Jan 22, 2026
7e84668
doc: create changeset
carodewig Jan 22, 2026
b73e877
Merge branch 'dev' into caroline/demand-control-actuals
carodewig Jan 22, 2026
643a14e
chore: refactor StrategyConfig::validate
carodewig Jan 23, 2026
468b80b
test: test_cache_metrics is super flaky
carodewig Jan 26, 2026
0b25099
test: remove dbg! that can cause test to timeout
carodewig Jan 28, 2026
32285c3
chore: refactor s/legacy/response_shape
carodewig Jan 29, 2026
314bbe1
Merge branch 'dev' into caroline/demand-control-actuals
carodewig Jan 29, 2026
e0113cc
feat: add configuration options for per-subgraph control
carodewig Jan 22, 2026
02aebb0
feat: compute estimated cost per subgraph
carodewig Jan 23, 2026
fae723e
chore: don't duplicate context update
carodewig Jan 23, 2026
6a5a8b0
feat: store estimated cost by subgraph in context
carodewig Jan 23, 2026
db2d8ff
feat: reject subgraph queries when they are too expensive
carodewig Jan 23, 2026
66647a3
test: check that per-subgraph all does not override configured max
carodewig Jan 23, 2026
d2910b4
test: ensure that requests can partially succeed by excluding just on…
carodewig Jan 23, 2026
698a525
test: add tests that demonstrate changes based on list size
carodewig Jan 23, 2026
4fcddbd
test: add cost/result by subgraph to snapshots
carodewig Jan 23, 2026
6fda99c
chore: doc line for tests
carodewig Jan 23, 2026
04cc5da
feat: send per-subgraph costs to rhai
carodewig Jan 23, 2026
c0657c8
test: add test for rhai access to estimates/actuals/results by subgraph
carodewig Jan 23, 2026
59813f1
test: make sure that coprocessors can access new costs
carodewig Jan 23, 2026
8f71074
chore: fix lints
carodewig Jan 26, 2026
f9150f1
test: ensure that requests are not rejected in measure mode
carodewig Jan 26, 2026
20197e8
test: remove redundant tests
carodewig Jan 26, 2026
ee6ce91
doc: add subgraph to sample config
carodewig Jan 26, 2026
76a178a
doc: fix typo
carodewig Jan 26, 2026
ad768d2
doc: add subgraph-level demand control documentation
carodewig Jan 26, 2026
c639761
doc: include errors, context, and rhai in docs
carodewig Jan 26, 2026
1f659df
doc: add changeset
carodewig Jan 26, 2026
641c33a
test: refactor requests_exceeding_one_subgraph_cost_are_accepted for …
carodewig Jan 27, 2026
4b043f7
test: use upsert to avoid clobbering existing values
carodewig Jan 28, 2026
ac0201a
chore: address typos
carodewig Jan 29, 2026
aee2352
chore: reformat doc
carodewig Jan 29, 2026
a20370e
chore: remove trailing *
carodewig Jan 29, 2026
9128300
test: demand control non-clobbery
aaronArinder Jan 28, 2026
1ce842e
doc: use html bc <> breaks things
carodewig Jan 29, 2026
ee36cf6
Merge branch 'dev' into caroline/demand-control-by-subgraph
carodewig Feb 3, 2026
e58dcb4
test: fix renaming from merge
carodewig Feb 3, 2026
e441c64
doc: apply PR suggestions
carodewig Feb 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changesets/feat_caroline_demand_control_by_subgraph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
### Support subgraph-level demand control ([PR #8829](https://github.com/apollographql/router/pull/8829))

Subgraph-level demand control lets you enforce per-subgraph query cost limits in Apollo Router, in addition to the
existing global cost limit for the whole supergraph. This helps you protect specific backend services that have
different
capacity or cost profiles from being overwhelmed by expensive operations.

When a subgraph‑specific cost limit is exceeded, the router:

* Still runs the rest of the operation, including other subgraphs whose cost is within limits.
* Skips calls to only the over‑budget subgraph, and composes the response as if that subgraph had returned null, instead
of rejecting the entire query.

Per‑subgraph limits apply to the total work for that subgraph in a single operation. For each request, the router tracks
the aggregate estimated cost per subgraph across the entire query plan. If the same subgraph is fetched multiple times
(for example, through entity lookups, nested fetches, or conditional branches), those costs are summed together and the
subgraph’s limit is enforced against that total.

#### Configuration

```yaml
demand_control:
enabled: true
mode: enforce
strategy:
static_estimated:
max: 10
list_size: 10
actual_cost_mode: by_subgraph
subgraphs: # <---- everything from here down is new (all fields optional)
all:
max: 8
list_size: 10
subgraphs:
products:
max: 6
# list_size omitted, 10 implied because of all.list_size
reviews:
list_size: 50
# max omitted, 8 implied because of all.max
```

#### Example

Consider a topProducts query, which fetches a list of products from a products subgraph and then performs an entity
lookup for each product in a reviews subgraph. Assume that the products cost is 10 and the reviews cost is 5, leading to
a total estimated cost of 15 (10 + 5).

Previously, you would only be able to restrict that query via `demand_control.static_estimated.max`:

* If you set it <= 15, the query would execute
* If you set it >15, the query would be rejected

This feature allows much more granular control. In addition to `demand_control.static_estimated.max`, which operates as
before, there are also per subgraph maxes.

For example, if you set `max = 20` and `reviews.max = 2`, the query will 'pass' the aggregate check (15 < 20) and will
execute on the products subgraph (no limit specified), but will not execute against the reviews subgraph (5 > 2). The
result will be composed as if the reviews subgraph had returned null.

By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/8829
28 changes: 28 additions & 0 deletions .changesets/fix_caroline_demand_control_actuals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
### Demand control actual costs should consider each subgraph fetch ([PR #8827](https://github.com/apollographql/router/pull/8827))

The demand control feature estimates query costs by summing together the cost of each subgraph operation. This allows it
to capture any intermediate work that must be completed to return a complete response.

Prior to this version, the actual query cost computation only considered the final response shape; it did not include
any of the intermediate work done in its total.

This version fixes that behavior to compute the actual query cost as the sum of all subgraph response costs. This more
accurately reflects the work done per operation and allows a more meaningful comparison
between actual and estimated costs.

Note: if you would like to disable the new actual cost computation behavior, you should set the router configuration
option `demand_control.strategy.static_estimated.actual_cost_mode` to `response_shape`.

```yaml
demand_control:
enabled: true
mode: enforce
strategy:
static_estimated:
max: 10
list_size: 10
actual_cost_mode: by_subgraph # the default value
# actual_cost_mode: response_shape # revert to prior actual cost computation mode
```

By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/8827
1 change: 1 addition & 0 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ or ( binary_id(=apollo-router::integration_tests) & test(=integration::connector
or ( binary_id(=apollo-router::integration_tests) & test(=integration::connectors::authentication::test_aws_sig_v4_signing) )
or ( binary_id(=apollo-router::integration_tests) & test(=integration::coprocessor::test_coprocessor_response_handling) )
or ( binary_id(=apollo-router::integration_tests) & test(=integration::coprocessor::test_error_not_propagated_to_client) )
or ( binary_id(=apollo-router::integration_tests) & test(=integration::entity_cache::test_cache_metrics) )
or ( binary_id(=apollo-router::integration_tests) & test(=integration::file_upload::it_fails_incompatible_query_order) )
or ( binary_id(=apollo-router::integration_tests) & test(=integration::file_upload::it_fails_invalid_file_order) )
or ( binary_id(=apollo-router::integration_tests) & test(=integration::file_upload::it_fails_invalid_multipart_order) )
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ expression: "&schema"
}
]
},
"ActualCostMode": {
"oneOf": [
{
"const": "by_subgraph",
"description": "Computes the cost of each subgraph response and sums them to get the total query cost.",
"type": "string"
},
{
"const": "response_shape",
"deprecated": true,
"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.",
"type": "string"
}
]
},
"All": {
"enum": [
"all"
Expand Down Expand Up @@ -9137,6 +9152,14 @@ expression: "&schema"
"static_estimated": {
"additionalProperties": false,
"properties": {
"actual_cost_mode": {
"allOf": [
{
"$ref": "#/definitions/ActualCostMode"
}
],
"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* `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."
},
"list_size": {
"description": "The assumed length of lists returned by the operation.",
"format": "uint32",
Expand All @@ -9147,6 +9170,21 @@ expression: "&schema"
"description": "The maximum cost of a query",
"format": "double",
"type": "number"
},
"subgraph": {
"allOf": [
{
"$ref": "#/definitions/SubgraphSubgraphStrategyConfigConfiguration"
}
],
"default": {
"all": {
"list_size": null,
"max": null
},
"subgraphs": {}
},
"description": "Cost control by subgraph"
}
},
"required": [
Expand Down Expand Up @@ -10418,6 +10456,28 @@ expression: "&schema"
},
"type": "object"
},
"SubgraphStrategyConfig": {
"properties": {
"list_size": {
"description": "The assumed length of lists returned by the operation for this subgraph.",
"format": "uint32",
"minimum": 0,
"type": [
"integer",
"null"
]
},
"max": {
"description": "The maximum query cost routed to this subgraph.",
"format": "double",
"type": [
"number",
"null"
]
}
},
"type": "object"
},
"SubgraphSubgraphApqConfiguration": {
"description": "Configuration options pertaining to the subgraph server component.",
"properties": {
Expand Down Expand Up @@ -10501,6 +10561,32 @@ expression: "&schema"
},
"type": "object"
},
"SubgraphSubgraphStrategyConfigConfiguration": {
"description": "Configuration options pertaining to the subgraph server component.",
"properties": {
"all": {
"allOf": [
{
"$ref": "#/definitions/SubgraphStrategyConfig"
}
],
"default": {
"list_size": null,
"max": null
},
"description": "options applying to all subgraphs"
},
"subgraphs": {
"additionalProperties": {
"$ref": "#/definitions/SubgraphStrategyConfig"
},
"default": {},
"description": "per subgraph options",
"type": "object"
}
},
"type": "object"
},
"SubgraphTlsClientConfiguration": {
"description": "Configuration options pertaining to the subgraph server component.",
"properties": {
Expand Down
15 changes: 15 additions & 0 deletions apollo-router/src/configuration/subgraph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,21 @@ where
pub(crate) fn get(&self, subgraph_name: &str) -> &T {
self.subgraphs.get(subgraph_name).unwrap_or(&self.all)
}

// Create a new `SubgraphConfiguration<V>` by extracting a value `V` from `&T`
pub(crate) fn extract<V: Default + Serialize + JsonSchema>(
&self,
extract_fn: fn(&T) -> V,
) -> SubgraphConfiguration<V> {
SubgraphConfiguration {
all: extract_fn(&self.all),
subgraphs: self
.subgraphs
.iter()
.map(|(k, v)| (k.clone(), extract_fn(v)))
.collect(),
}
}
}

impl<T> Debug for SubgraphConfiguration<T>
Expand Down
74 changes: 74 additions & 0 deletions apollo-router/src/plugins/demand_control/cost_calculator/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,78 @@ mod directives;
pub(in crate::plugins::demand_control) mod schema;
pub(crate) mod static_cost;

use std::collections::HashMap;
use std::ops::AddAssign;

use crate::plugins::demand_control::DemandControlError;

#[derive(Clone, Default, Debug, serde::Serialize, serde::Deserialize)]
pub(crate) struct CostBySubgraph(HashMap<String, f64>);
impl CostBySubgraph {
pub(crate) fn new(subgraph: &str, value: f64) -> Self {
Self(HashMap::from([(subgraph.to_string(), value)]))
}

pub(crate) fn add_or_insert(&mut self, subgraph: &str, value: f64) {
if let Some(subgraph_cost) = self.0.get_mut(subgraph) {
*subgraph_cost += value;
} else {
self.0.insert(subgraph.to_string(), value);
}
}

pub(crate) fn get(&self, subgraph: &str) -> Option<f64> {
self.0.get(subgraph).copied()
}

pub(crate) fn total(&self) -> f64 {
self.0.values().sum()
}

/// Creates a new `CostBySubgraph` where each value in the map is the maximum of its value
/// in the two input `CostBySubgraph`s.
///
/// ```rust
/// let cost1 = CostBySubgraph::new("hello", 1.0);
/// let mut cost2 = CostBySubgraph::new("hello", 2.0);
/// cost2.add_or_insert("world", 1.0);
///
/// let max = CostBySubgraph::maximum(cost1, cost2);
/// assert_eq!(max.0.get("hello"), Some(2.0));
/// assert_eq!(max.0.get("world"), Some(1.0));
/// ```
pub(crate) fn maximum(mut cost1: Self, cost2: Self) -> Self {
for (subgraph, value) in cost2.0.into_iter() {
if let Some(subgraph_cost) = cost1.0.get_mut(&subgraph) {
*subgraph_cost = subgraph_cost.max(value);
} else {
cost1.0.insert(subgraph, value);
}
}

cost1
}
}

impl AddAssign for CostBySubgraph {
fn add_assign(&mut self, rhs: Self) {
for (subgraph, value) in rhs.0.into_iter() {
if let Some(subgraph_cost) = self.0.get_mut(&subgraph) {
*subgraph_cost += value;
} else {
self.0.insert(subgraph, value);
}
}
}
}

#[cfg(test)]
impl From<&[(&str, f64)]> for CostBySubgraph {
fn from(values: &[(&str, f64)]) -> Self {
let mut cost = Self(HashMap::default());
for (subgraph, value) in values {
cost.add_or_insert(subgraph, *value);
}
cost
}
}
Loading