Skip to content

Commit e885998

Browse files
author
Sylvain Lebresne
committed
Add test for QP optimization of __typename
The optimization of apollographql#2137 is meant to have no impact on the generated plans: it's an optimization that ensure the query planner does not needlessly evaluate ineffient options just because __typename happens to be resolvable from any subgraph. But this make testing that optimization a bit more complicated. To work around this, this patch adds a new behaviour to the query planner whereby along the generation of the plan, it also exposes some statistics on the plan generation. As of this commit, the only thing exposed is the number of plan that were evaluated under the hood, as that is what we care to check here (but it is also one of the main contributor to time spent query planning, so arguably an important statistic). There was probably more hacky ways to test this optimization, but it while this statistic is currently only use for testing, this feels like something that could have other uses and be useful to enrich later.
1 parent b7ccc01 commit e885998

File tree

3 files changed

+205
-10
lines changed

3 files changed

+205
-10
lines changed

query-planner-js/src/__tests__/buildPlan.test.ts

+153
Original file line numberDiff line numberDiff line change
@@ -4055,4 +4055,157 @@ describe('__typename handling', () => {
40554055
}
40564056
`);
40574057
});
4058+
4059+
it('does not needlessly consider options for __typename', () => {
4060+
const subgraph1 = {
4061+
name: 'Subgraph1',
4062+
typeDefs: gql`
4063+
type Query {
4064+
s: S
4065+
}
4066+
4067+
type S @key(fields: "id") {
4068+
id: ID
4069+
}
4070+
`
4071+
}
4072+
4073+
const subgraph2 = {
4074+
name: 'Subgraph2',
4075+
typeDefs: gql`
4076+
type S @key(fields: "id") {
4077+
id: ID
4078+
t: T @shareable
4079+
}
4080+
4081+
type T @key(fields: "id") {
4082+
id: ID!
4083+
x: Int
4084+
}
4085+
`
4086+
}
4087+
4088+
const subgraph3 = {
4089+
name: 'Subgraph3',
4090+
typeDefs: gql`
4091+
type S @key(fields: "id") {
4092+
id: ID
4093+
t: T @shareable
4094+
}
4095+
4096+
type T @key(fields: "id") {
4097+
id: ID!
4098+
y: Int
4099+
}
4100+
`
4101+
}
4102+
4103+
const [api, queryPlanner] = composeAndCreatePlanner(subgraph1, subgraph2, subgraph3);
4104+
// This tests the patch from https://github.com/apollographql/federation/pull/2137.
4105+
// Namely, the schema is such that `x` can only be fetched from one subgraph, but
4106+
// technically __typename can be fetched from 2 subgraphs. However, the optimization
4107+
// we test for is that we actually don't consider both choices for __typename and
4108+
// instead only evaluate a single query plan (the assertion on `evaluatePlanCount`)
4109+
let operation = operationFromDocument(api, gql`
4110+
query {
4111+
s {
4112+
t {
4113+
__typename
4114+
x
4115+
}
4116+
}
4117+
}
4118+
`);
4119+
4120+
let plan = queryPlanner.buildQueryPlan(operation);
4121+
expect(queryPlanner.lastGeneratedPlanStatistics()?.evaluatedPlanCount).toBe(1);
4122+
expect(plan).toMatchInlineSnapshot(`
4123+
QueryPlan {
4124+
Sequence {
4125+
Fetch(service: "Subgraph1") {
4126+
{
4127+
s {
4128+
__typename
4129+
id
4130+
}
4131+
}
4132+
},
4133+
Flatten(path: "s") {
4134+
Fetch(service: "Subgraph2") {
4135+
{
4136+
... on S {
4137+
__typename
4138+
id
4139+
}
4140+
} =>
4141+
{
4142+
... on S {
4143+
t {
4144+
__typename
4145+
x
4146+
}
4147+
}
4148+
}
4149+
},
4150+
},
4151+
},
4152+
}
4153+
`);
4154+
4155+
// Almost the same test, but we artificially create a case where the result set
4156+
// for `s` has a __typename alongside just an inline fragments. This should
4157+
// change nothing to the example (the __typename on `s` is trivially fetched
4158+
// from the 1st subgraph and does not create new choices), but an early bug
4159+
// in the implementation made this example forgo the optimization of the
4160+
// __typename within `t`. We make sure this is not case (that we still only
4161+
// consider a single choice of plan).
4162+
operation = operationFromDocument(api, gql`
4163+
query {
4164+
s {
4165+
__typename
4166+
... on S {
4167+
t {
4168+
__typename
4169+
x
4170+
}
4171+
}
4172+
}
4173+
}
4174+
`);
4175+
4176+
plan = queryPlanner.buildQueryPlan(operation);
4177+
expect(queryPlanner.lastGeneratedPlanStatistics()?.evaluatedPlanCount).toBe(1);
4178+
expect(plan).toMatchInlineSnapshot(`
4179+
QueryPlan {
4180+
Sequence {
4181+
Fetch(service: "Subgraph1") {
4182+
{
4183+
s {
4184+
__typename
4185+
id
4186+
}
4187+
}
4188+
},
4189+
Flatten(path: "s") {
4190+
Fetch(service: "Subgraph2") {
4191+
{
4192+
... on S {
4193+
__typename
4194+
id
4195+
}
4196+
} =>
4197+
{
4198+
... on S {
4199+
t {
4200+
__typename
4201+
x
4202+
}
4203+
}
4204+
}
4205+
},
4206+
},
4207+
},
4208+
}
4209+
`);
4210+
});
40584211
});

query-planner-js/src/buildPlan.ts

+43-8
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ class QueryPlanningTaversal<RV extends Vertex> {
230230
readonly startFetchIdGen: number,
231231
readonly hasDefers: boolean,
232232
readonly variableDefinitions: VariableDefinitions,
233+
private readonly statistics: PlanningStatistics | undefined,
233234
private readonly startVertex: RV,
234235
private readonly rootKind: SchemaRootKind,
235236
readonly costFunction: CostFunction,
@@ -445,6 +446,10 @@ class QueryPlanningTaversal<RV extends Vertex> {
445446
debug.log(() => `Reduced plans to consider to ${planCount} plans`);
446447
}
447448

449+
if (this.statistics) {
450+
this.statistics.evaluatedPlanCount += planCount;
451+
}
452+
448453
debug.log(() => `All branches:${this.closedBranches.map((opts, i) => `\n${i}:${opts.map((opt => `\n - ${simultaneousPathsToString(opt)}`))}`)}`);
449454

450455
// Note that usually, we'll have a majority of branches with just one option. We can group them in
@@ -528,6 +533,7 @@ class QueryPlanningTaversal<RV extends Vertex> {
528533
0,
529534
false,
530535
this.variableDefinitions,
536+
undefined,
531537
edge.head,
532538
'query',
533539
this.costFunction,
@@ -2012,6 +2018,10 @@ function withSiblingTypenameOptimizedAway(operation: Operation): Operation {
20122018
);
20132019
}
20142020

2021+
export type PlanningStatistics = {
2022+
evaluatedPlanCount: number,
2023+
}
2024+
20152025
export function computeQueryPlan({
20162026
config,
20172027
supergraphSchema,
@@ -2022,14 +2032,21 @@ export function computeQueryPlan({
20222032
supergraphSchema: Schema,
20232033
federatedQueryGraph: QueryGraph,
20242034
operation: Operation,
2025-
}): QueryPlan {
2035+
}): {
2036+
plan: QueryPlan,
2037+
statistics: PlanningStatistics,
2038+
} {
20262039
if (operation.rootKind === 'subscription') {
20272040
throw ERRORS.UNSUPPORTED_FEATURE.err(
20282041
'Query planning does not currently support subscriptions.',
20292042
{ nodes: [parse(operation.toString())] },
20302043
);
20312044
}
20322045

2046+
const statistics: PlanningStatistics = {
2047+
evaluatedPlanCount: 0,
2048+
};
2049+
20332050
const reuseQueryFragments = config.reuseQueryFragments ?? true;
20342051
let fragments = operation.selectionSet.fragments
20352052
if (fragments && reuseQueryFragments) {
@@ -2062,7 +2079,10 @@ export function computeQueryPlan({
20622079
debug.group(() => `Computing plan for\n${operation}`);
20632080
if (operation.selectionSet.isEmpty()) {
20642081
debug.groupEnd('Empty plan');
2065-
return { kind: 'QueryPlan' };
2082+
return {
2083+
plan: { kind: 'QueryPlan' },
2084+
statistics,
2085+
};
20662086
}
20672087

20682088
const root = federatedQueryGraph.root(operation.rootKind);
@@ -2086,6 +2106,7 @@ export function computeQueryPlan({
20862106
processor,
20872107
root,
20882108
deferConditions,
2109+
statistics,
20892110
})
20902111
} else {
20912112
rootNode = computePlanInternal({
@@ -2095,11 +2116,15 @@ export function computeQueryPlan({
20952116
processor,
20962117
root,
20972118
hasDefers,
2119+
statistics,
20982120
});
20992121
}
21002122

21012123
debug.groupEnd('Query plan computed');
2102-
return { kind: 'QueryPlan', node: rootNode };
2124+
return {
2125+
plan: { kind: 'QueryPlan', node: rootNode },
2126+
statistics,
2127+
};
21032128
}
21042129

21052130
function computePlanInternal({
@@ -2109,16 +2134,18 @@ function computePlanInternal({
21092134
processor,
21102135
root,
21112136
hasDefers,
2137+
statistics,
21122138
}: {
21132139
supergraphSchema: Schema,
21142140
federatedQueryGraph: QueryGraph,
21152141
operation: Operation,
21162142
processor: FetchGroupProcessor<PlanNode | undefined, DeferredNode>
21172143
root: RootVertex,
21182144
hasDefers: boolean,
2145+
statistics: PlanningStatistics,
21192146
}): PlanNode | undefined {
21202147
if (operation.rootKind === 'mutation') {
2121-
const dependencyGraphs = computeRootSerialDependencyGraph(supergraphSchema, operation, federatedQueryGraph, root, hasDefers);
2148+
const dependencyGraphs = computeRootSerialDependencyGraph(supergraphSchema, operation, federatedQueryGraph, root, hasDefers, statistics);
21222149
let allMain: (PlanNode | undefined)[] = [];
21232150
let allDeferred: DeferredNode[] = [];
21242151
let primarySelection: SelectionSet | undefined = undefined;
@@ -2143,7 +2170,7 @@ function computePlanInternal({
21432170
deferred: allDeferred,
21442171
});
21452172
} else {
2146-
const dependencyGraph = computeRootParallelDependencyGraph(supergraphSchema, operation, federatedQueryGraph, root, 0, hasDefers);
2173+
const dependencyGraph = computeRootParallelDependencyGraph(supergraphSchema, operation, federatedQueryGraph, root, 0, hasDefers, statistics);
21472174
const { main, deferred } = dependencyGraph.process(processor);
21482175
return processRootNodes({
21492176
processor,
@@ -2162,13 +2189,15 @@ function computePlanForDeferConditionals({
21622189
processor,
21632190
root,
21642191
deferConditions,
2192+
statistics,
21652193
}: {
21662194
supergraphSchema: Schema,
21672195
federatedQueryGraph: QueryGraph,
21682196
operation: Operation,
21692197
processor: FetchGroupProcessor<PlanNode | undefined, DeferredNode>
21702198
root: RootVertex,
21712199
deferConditions: SetMultiMap<string, string>,
2200+
statistics: PlanningStatistics,
21722201
}): PlanNode | undefined {
21732202
return generateConditionNodes(
21742203
operation,
@@ -2181,6 +2210,7 @@ function computePlanForDeferConditionals({
21812210
processor,
21822211
root,
21832212
hasDefers: true,
2213+
statistics,
21842214
}),
21852215
);
21862216
}
@@ -2296,6 +2326,7 @@ function computeRootParallelDependencyGraph(
22962326
root: RootVertex,
22972327
startFetchIdGen: number,
22982328
hasDefer: boolean,
2329+
statistics: PlanningStatistics,
22992330
): FetchDependencyGraph {
23002331
return computeRootParallelBestPlan(
23012332
supergraphSchema,
@@ -2305,6 +2336,7 @@ function computeRootParallelDependencyGraph(
23052336
root,
23062337
startFetchIdGen,
23072338
hasDefer,
2339+
statistics,
23082340
)[0];
23092341
}
23102342

@@ -2316,6 +2348,7 @@ function computeRootParallelBestPlan(
23162348
root: RootVertex,
23172349
startFetchIdGen: number,
23182350
hasDefers: boolean,
2351+
statistics: PlanningStatistics,
23192352
): [FetchDependencyGraph, OpPathTree<RootVertex>, number] {
23202353
const planningTraversal = new QueryPlanningTaversal(
23212354
supergraphSchema,
@@ -2324,10 +2357,11 @@ function computeRootParallelBestPlan(
23242357
startFetchIdGen,
23252358
hasDefers,
23262359
variables,
2360+
statistics,
23272361
root,
23282362
root.rootKind,
23292363
defaultCostFunction,
2330-
emptyContext
2364+
emptyContext,
23312365
);
23322366
const plan = planningTraversal.findBestPlan();
23332367
// Getting no plan means the query is essentially unsatisfiable (it's a valid query, but we can prove it will never return a result),
@@ -2358,16 +2392,17 @@ function computeRootSerialDependencyGraph(
23582392
federatedQueryGraph: QueryGraph,
23592393
root: RootVertex,
23602394
hasDefers: boolean,
2395+
statistics: PlanningStatistics,
23612396
): FetchDependencyGraph[] {
23622397
const rootType = hasDefers ? supergraphSchema.schemaDefinition.rootType(root.rootKind) : undefined;
23632398
// We have to serially compute a plan for each top-level selection.
23642399
const splittedRoots = splitTopLevelFields(operation.selectionSet);
23652400
const graphs: FetchDependencyGraph[] = [];
23662401
let startingFetchId: number = 0;
2367-
let [prevDepGraph, prevPaths] = computeRootParallelBestPlan(supergraphSchema, splittedRoots[0], operation.variableDefinitions, federatedQueryGraph, root, startingFetchId, hasDefers);
2402+
let [prevDepGraph, prevPaths] = computeRootParallelBestPlan(supergraphSchema, splittedRoots[0], operation.variableDefinitions, federatedQueryGraph, root, startingFetchId, hasDefers, statistics);
23682403
let prevSubgraph = onlyRootSubgraph(prevDepGraph);
23692404
for (let i = 1; i < splittedRoots.length; i++) {
2370-
const [newDepGraph, newPaths] = computeRootParallelBestPlan(supergraphSchema, splittedRoots[i], operation.variableDefinitions, federatedQueryGraph, root, prevDepGraph.nextFetchId(), hasDefers);
2405+
const [newDepGraph, newPaths] = computeRootParallelBestPlan(supergraphSchema, splittedRoots[i], operation.variableDefinitions, federatedQueryGraph, root, prevDepGraph.nextFetchId(), hasDefers, statistics);
23712406
const newSubgraph = onlyRootSubgraph(newDepGraph);
23722407
if (prevSubgraph === newSubgraph) {
23732408
// The new operation (think 'mutation' operation) is on the same subgraph than the previous one, so we can concat them in a single fetch

0 commit comments

Comments
 (0)