Skip to content

Commit c99ee54

Browse files
committed
fix(federation): keep condition exclusions duplicate-free so equality and cache lookups are set-based
`ExcludedConditions` is compared as a set (equal length plus one-way membership), but `add_item` appended unconditionally. Once a condition was added twice, equality stopped being symmetric ([A, A] == [A, B] but not the reverse), and `ConditionResolverCache::contains` could return a resolution cached under [A, A] for a request excluding [A, B]. Make `add_item` a no-op when the condition is already excluded, matching `ExcludedDestinations::add_excluded`. The length+containment equality is then a correct set equality, so the cache key is independent of how a set of exclusions was built. Current planner and composition call sites already skip edges whose condition is excluded before adding it, so plans do not change.
1 parent c3829d4 commit c99ee54

2 files changed

Lines changed: 156 additions & 1 deletion

File tree

‎apollo-federation/src/query_graph/graph_path.rs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ use crate::schema::position::SchemaRootDefinitionKind;
5656
use crate::utils::FallibleIterator;
5757
use crate::utils::logging::snapshot;
5858

59+
#[cfg(test)]
60+
mod excluded_conditions_tests;
5961
pub(crate) mod operation;
6062
pub(crate) mod transition;
6163

@@ -279,15 +281,21 @@ impl ExcludedConditions {
279281
self.0.contains(condition)
280282
}
281283

282-
/// Immutable version of `push`.
284+
/// Immutable version of `push`. Like `ExcludedDestinations::add_excluded`, this keeps the
285+
/// `Vec` free of duplicates, which the set equality below relies on.
283286
pub(crate) fn add_item(&self, value: &SelectionSet) -> ExcludedConditions {
287+
if self.0.iter().any(|excluded| excluded.as_ref() == value) {
288+
return self.clone();
289+
}
284290
let mut result = self.0.as_ref().clone();
285291
result.push(value.clone().into());
286292
ExcludedConditions(Arc::new(result))
287293
}
288294
}
289295

290296
impl PartialEq for ExcludedConditions {
297+
/// See if two `ExcludedConditions` have the same set of values, regardless of their ordering.
298+
/// This is only a set equality because `add_item` never inserts duplicates.
291299
fn eq(&self, other: &ExcludedConditions) -> bool {
292300
Arc::ptr_eq(&self.0, &other.0)
293301
|| (self.0.len() == other.0.len() && self.0.iter().all(|x| other.0.contains(x)))
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//! `ExcludedConditions` denotes a set: equality and condition-resolver cache lookups must not
2+
//! depend on how many times (or in which order) a condition was added.
3+
use std::collections::BTreeSet;
4+
5+
use super::*;
6+
use crate::Supergraph;
7+
use crate::query_graph::build_query_graph::build_federated_query_graph;
8+
use crate::query_graph::condition_resolver::CachingConditionResolver;
9+
use crate::query_graph::condition_resolver::ConditionResolverCache;
10+
11+
/// A query graph, one edge with conditions, and two distinct selection sets on its head type.
12+
fn fixture() -> (Arc<QueryGraph>, EdgeIndex, [SelectionSet; 2]) {
13+
let supergraph = Supergraph::new_with_router_specs(include_str!(
14+
"../../../tests/query_plan/supergraphs/handles_case_of_key_chains_in_parallel_requires.graphql"
15+
))
16+
.unwrap();
17+
let api = supergraph.to_api_schema(Default::default()).unwrap();
18+
let graph =
19+
Arc::new(build_federated_query_graph(supergraph.schema, api, None, Some(true)).unwrap());
20+
let edge = graph
21+
.graph
22+
.edge_indices()
23+
.find(|e| graph.edge_weight(*e).unwrap().conditions.is_some())
24+
.unwrap();
25+
let a = graph
26+
.edge_weight(edge)
27+
.unwrap()
28+
.conditions
29+
.as_deref()
30+
.unwrap()
31+
.clone();
32+
let b = SelectionSet::parse(a.schema.clone(), a.type_position.clone(), "__typename").unwrap();
33+
assert_ne!(a, b);
34+
(graph, edge, [a, b])
35+
}
36+
37+
fn exclusions(selections: &[SelectionSet; 2], indices: &[usize]) -> ExcludedConditions {
38+
indices
39+
.iter()
40+
.fold(ExcludedConditions::default(), |list, i| {
41+
list.add_item(&selections[*i])
42+
})
43+
}
44+
45+
/// A reference resolver whose answer is a function of the *set* of excluded conditions (computed
46+
/// independently of `ExcludedConditions`' equality), and which counts uncached resolutions.
47+
struct Resolver {
48+
graph: Arc<QueryGraph>,
49+
cache: ConditionResolverCache,
50+
calls: usize,
51+
}
52+
53+
impl CachingConditionResolver for Resolver {
54+
fn query_graph(&self) -> &QueryGraph {
55+
&self.graph
56+
}
57+
fn resolver_cache(&mut self) -> &mut ConditionResolverCache {
58+
&mut self.cache
59+
}
60+
fn resolve_without_cache(
61+
&mut self,
62+
_edge: EdgeIndex,
63+
_context: &OpGraphPathContext,
64+
_destinations: &ExcludedDestinations,
65+
conditions: &ExcludedConditions,
66+
_extra: Option<&SelectionSet>,
67+
) -> Result<ConditionResolution, FederationError> {
68+
self.calls += 1;
69+
let excluded: BTreeSet<_> = conditions.0.iter().map(|s| s.to_string()).collect();
70+
Ok(ConditionResolution::Satisfied {
71+
cost: excluded.len() as f64,
72+
path_tree: None,
73+
context_map: None,
74+
})
75+
}
76+
}
77+
78+
fn resolve(resolver: &mut Resolver, edge: EdgeIndex, conditions: &ExcludedConditions) -> f64 {
79+
match resolver
80+
.resolve_with_cache(
81+
edge,
82+
&Default::default(),
83+
&Default::default(),
84+
conditions,
85+
None,
86+
)
87+
.unwrap()
88+
{
89+
ConditionResolution::Satisfied { cost, .. } => cost,
90+
ConditionResolution::Unsatisfied { .. } => unreachable!(),
91+
}
92+
}
93+
94+
#[test]
95+
fn exclusion_set_equality_is_symmetric_after_repeated_additions() {
96+
let (_, _, selections) = fixture();
97+
let repeated = exclusions(&selections, &[0, 0]);
98+
let distinct = exclusions(&selections, &[0, 1]);
99+
assert_eq!(repeated == distinct, distinct == repeated);
100+
assert_ne!(repeated, distinct);
101+
// Order and repeats don't matter for equal sets.
102+
assert_eq!(distinct, exclusions(&selections, &[1, 0, 1]));
103+
assert_eq!(exclusions(&selections, &[1, 0, 1]), distinct);
104+
}
105+
106+
#[test]
107+
fn repeated_condition_exclusions_do_not_hit_cache_for_a_different_set() {
108+
let (graph, edge, selections) = fixture();
109+
let mut resolver = Resolver {
110+
graph,
111+
cache: ConditionResolverCache::new(),
112+
calls: 0,
113+
};
114+
// Warmed under {a} (spelled [a, a]), a request for {a, b} must not reuse that entry.
115+
assert_eq!(
116+
resolve(&mut resolver, edge, &exclusions(&selections, &[0, 0])),
117+
1.0
118+
);
119+
assert_eq!(
120+
resolve(&mut resolver, edge, &exclusions(&selections, &[0, 1])),
121+
2.0
122+
);
123+
}
124+
125+
#[test]
126+
fn equal_exclusion_sets_share_one_cache_entry() {
127+
let (graph, edge, selections) = fixture();
128+
let mut resolver = Resolver {
129+
graph,
130+
cache: ConditionResolverCache::new(),
131+
calls: 0,
132+
};
133+
for (spelling, expected) in [
134+
(&[0, 1][..], 2.0),
135+
(&[1, 0, 1], 2.0),
136+
(&[0, 0, 1], 2.0),
137+
(&[1, 1], 1.0),
138+
(&[1], 1.0),
139+
] {
140+
assert_eq!(
141+
resolve(&mut resolver, edge, &exclusions(&selections, spelling)),
142+
expected
143+
);
144+
}
145+
// Guards against "fixing" transparency by not caching: {a, b} and {b} each resolve once.
146+
assert_eq!(resolver.calls, 2);
147+
}

0 commit comments

Comments
 (0)