Skip to content

Commit 2a97f37

Browse files
author
Sylvain Lebresne
authored
Over-aggressive optimisation can lead to generating non-optimal query plans (#2623)
When there is multiple valid choices for multiple paths of a query, the number of theoretically possible ends up a cartesian product so it explodes fairly quickly, and generating too many plans can be very costly. To manage this in general, we generate the possible plans incrementally and in an order that is likely to yield "good" plans early, and use the cost of previously computed plan to possibly cut early further computation. The code doing this was however not optimal as we weren't using that potential of early exit as aggressively as we could. This commit fixes that. Further, when building plans, after we've computed all the possible options to get to a particular leaf of the query being plan, we're applying an heuristic that checks if an option is guaranteed to be always better than another one (and remove the latter one if so), which the goal of reducing the number of actual full plan we need to evaluate. But the implementation of that heuristic was incorrect: while it was correctly removing options that we did meant to remove, it was also sometimes removing options that really should be kept. This resulted in planning sometimes not produce the most optimal possible plan. This commit fixes that heuristic to only exclude what can be safely excluded.
1 parent 3299d52 commit 2a97f37

File tree

5 files changed

+447
-79
lines changed

5 files changed

+447
-79
lines changed

.changeset/slimy-geckos-invite.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@apollo/query-planner": patch
3+
"@apollo/query-graphs": patch
4+
---
5+
6+
Fix query planner heuristic that could lead to ignoring some valid option and yielding a non-optimal query plan.
7+

query-graphs-js/src/graphPath.ts

+143-4
Original file line numberDiff line numberDiff line change
@@ -232,22 +232,131 @@ export class GraphPath<TTrigger, RV extends Vertex = Vertex, TNullEdge extends n
232232
return this.props.edgeIndexes.length;
233233
}
234234

235-
subgraphJumps(): number {
235+
/**
236+
* That method first look for the biggest common prefix to `this` and `that` (assuming that both path are build as choices
237+
* of the same "query path"), and the count how many subgraph jumps each of the path has after said prefix.
238+
*
239+
* Note that this method always return someting but the biggest common prefix considered might well be empty.
240+
*
241+
* Please note that this method assumes that the 2 paths have the same root, and will fail if that's not the case.
242+
*/
243+
countSubgraphJumpsAfterLastCommonVertex(that: GraphPath<TTrigger, RV, TNullEdge>): {
244+
thisJumps: number,
245+
thatJumps: number
246+
} {
247+
const { vertex, index } = this.findLastCommonVertex(that);
248+
return {
249+
thisJumps: this.subgraphJumpsAtIdx(vertex, index),
250+
thatJumps: that.subgraphJumpsAtIdx(vertex, index),
251+
};
252+
}
253+
254+
private findLastCommonVertex(that: GraphPath<TTrigger, RV, TNullEdge>): { vertex: Vertex, index: number } {
255+
let vertex: Vertex = this.root;
256+
assert(that.root === vertex, () => `Expected both path to start on the same root, but 'this' has root ${vertex} while 'that' has ${that.root}`);
257+
258+
const minSize = Math.min(this.size, that.size);
259+
let index = 0;
260+
for (; index < minSize; index++) {
261+
const thisEdge = this.edgeAt(index, vertex);
262+
const thatEdge = that.edgeAt(index, vertex);
263+
if (thisEdge !== thatEdge) {
264+
break;
265+
}
266+
if (thisEdge) {
267+
vertex = thisEdge.tail;
268+
}
269+
}
270+
return { vertex, index};
271+
}
272+
273+
private subgraphJumpsAtIdx(vertex: Vertex, index: number): number {
236274
let jumps = 0;
237-
let v: Vertex = this.root;
238-
for (let i = 0; i < this.size; i++) {
275+
let v: Vertex = vertex;
276+
for (let i = index; i < this.size; i++) {
239277
const edge = this.edgeAt(i, v);
240278
if (!edge) {
241279
continue;
242280
}
243-
if (edge.transition.kind === 'KeyResolution' || edge.transition.kind === 'RootTypeResolution') {
281+
if (edge.changesSubgraph()) {
244282
++jumps;
245283
}
246284
v = edge.tail;
247285
}
248286
return jumps;
249287
}
250288

289+
subgraphJumps(): number {
290+
return this.subgraphJumpsAtIdx(this.root, 0);
291+
}
292+
293+
isEquivalentSaveForTypeExplosionTo(that: GraphPath<TTrigger, RV, TNullEdge>): boolean {
294+
// We're looking a the specific case were both path are basically equivalent except
295+
// for a single step of type-explosion, so if either the paths don't start and end in the
296+
// same vertex, or if `other` is not exactly 1 more step than `this`, we're done.
297+
if (this.root !== that.root || this.tail !== that.tail || this.size !== that.size - 1) {
298+
return false;
299+
}
300+
301+
// If that's true, then we get to our comparison.
302+
let thisV: Vertex = this.root;
303+
let thatV: Vertex = that.root;
304+
for (let i = 0; i < this.size; i++) {
305+
let thisEdge = this.edgeAt(i, thisV);
306+
let thatEdge = that.edgeAt(i, thatV);
307+
if (thisEdge !== thatEdge) {
308+
// First difference. If it's not a "type-explosion", that is `that` is a cast from an
309+
// interface to one of the implementation, then we're not in the case we're looking for.
310+
if (!thisEdge || !thatEdge || !isInterfaceType(thatV.type) || thatEdge.transition.kind !== 'DownCast') {
311+
return false;
312+
}
313+
thatEdge = that.edgeAt(i+1, thatEdge.tail);
314+
if (!thatEdge) {
315+
return false;
316+
}
317+
thisV = thisEdge.tail;
318+
thatV = thatEdge.tail;
319+
320+
// At that point, we want both path to take the "same" key, but because one is starting
321+
// from the interface while the other one from an implementation, they won't be technically
322+
// the "same" edge object. So we check that both are key, to the same subgraph and type,
323+
// and with the same condition.
324+
if (thisEdge.transition.kind !== 'KeyResolution'
325+
|| thatEdge.transition.kind !== 'KeyResolution'
326+
|| thisEdge.tail.source !== thatEdge.tail.source
327+
|| thisV !== thatV
328+
|| !thisEdge.conditions!.equals(thatEdge.conditions!)
329+
) {
330+
return false;
331+
}
332+
333+
// So far, so good. `thisV` and `thatV` are positioned on the vertex after which the path
334+
// must be equal again. So check that it's true, and if it is, we're good.
335+
// Note that for `this`, the last edge we looked at was `i`, so the next is `i+1`. And
336+
// for `that`, we've skipped over one more edge, so need to use `j+1`.
337+
for (let j = i + 1; j < this.size; j++) {
338+
thisEdge = this.edgeAt(j, thisV);
339+
thatEdge = that.edgeAt(j+1, thatV);
340+
if (thisEdge !== thatEdge) {
341+
return false;
342+
}
343+
if (thisEdge) {
344+
thisV = thisEdge.tail;
345+
thatV = thatEdge!.tail;
346+
}
347+
}
348+
return true;
349+
}
350+
if (thisEdge) {
351+
thisV = thisEdge.tail;
352+
thatV = thatEdge!.tail;
353+
}
354+
}
355+
// If we get here, both path are actually exactly the same. So technically there is not additional
356+
// type explosion, but they are equivalent and we can return `true`.
357+
return true;
358+
}
359+
251360
[Symbol.iterator](): PathIterator<TTrigger, TNullEdge> {
252361
const path = this;
253362
return {
@@ -1814,6 +1923,35 @@ export function advanceSimultaneousPathsWithOperation<V extends Vertex>(
18141923
// is unsatisfiable. But as we've only taken type preserving transitions, we cannot get an empty results at this point if we haven't
18151924
// had one when testing direct transitions above (in which case we have exited the method early).
18161925
assert(pathWithOperation.length > 0, () => `Unexpected empty options after non-collecting path ${pathWithNonCollecting} for ${operation}`);
1926+
1927+
// There is a special case we can deal with now. Namely, suppose we have a case where a query
1928+
// is reaching an interface I in a subgraph S1, we query some field of that interface x, and
1929+
// say that x is provided in subgraph S2 but by an @interfaceObject for I.
1930+
// As we look for direct options for I.x in S1 initially, as we didn't found `x`, we will have tried
1931+
// to type-explode I (in say implementation A and B). And in some case doing so is necessary, but
1932+
// it may also lead for the type-exploding option to look like:
1933+
// [
1934+
// I(S1) -[... on A]-> A(S1) -[key]-> I(S2) -[x] -> Int(S2),
1935+
// I(S1) -[... on B]-> B(S1) -[key]-> I(S2) -[x] -> Int(S2),
1936+
// ]
1937+
// But as we look at indirect option now (still from I in S1), we will note that we can also
1938+
// do:
1939+
// I(S1) -[key]-> I(S2) -[x] -> Int(S2),
1940+
// And while both options are technically valid, the new one really subsume the first one: there
1941+
// is no point in type-exploding to take a key to the same exact subgraph if using the key
1942+
// on the interface directly works.
1943+
//
1944+
// So here, we look for that case and remove any type-exploding option that the new path
1945+
// render unecessary.
1946+
// Do note that we only make that check when the new option is a single-path option, because
1947+
// this gets kind of complicated otherwise.
1948+
if (pathWithNonCollecting.tailIsInterfaceObject()) {
1949+
for (const indirectOption of pathWithOperation) {
1950+
if (indirectOption.length === 1) {
1951+
options = options.filter((opt) => !opt.every((p) => indirectOption[0].isEquivalentSaveForTypeExplosionTo(p)));
1952+
}
1953+
}
1954+
}
18171955
options = options.concat(pathWithOperation);
18181956
}
18191957
debug.groupEnd();
@@ -1853,6 +1991,7 @@ export function advanceSimultaneousPathsWithOperation<V extends Vertex>(
18531991
return createLazyOptions(allOptions, subgraphSimultaneousPaths, updatedContext);
18541992
}
18551993

1994+
18561995
export function createInitialOptions<V extends Vertex>(
18571996
initialPath: OpGraphPath<V>,
18581997
initialContext: PathContext,

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

+97-3
Original file line numberDiff line numberDiff line change
@@ -4423,8 +4423,7 @@ describe('__typename handling', () => {
44234423
t: T @shareable
44244424
}
44254425
4426-
type T @key(fields: "id") {
4427-
id: ID!
4426+
type T {
44284427
x: Int
44294428
}
44304429
`
@@ -4438,7 +4437,7 @@ describe('__typename handling', () => {
44384437
t: T @shareable
44394438
}
44404439
4441-
type T @key(fields: "id") {
4440+
type T {
44424441
id: ID!
44434442
y: Int
44444443
}
@@ -6170,3 +6169,98 @@ describe('`debug.maxEvaluatedPlans` configuration', () => {
61706169
});
61716170
});
61726171

6172+
test('correctly generate plan built from some non-individually optimal branch options', () => {
6173+
// The idea of this test is that the query has 2 leaf fields, `t.x` and `t.y`, whose
6174+
// options are:
6175+
// 1. `t.x`:
6176+
// a. S1(get t.x)
6177+
// b. S2(get t.id) -> S3(get t.x using key id)
6178+
// 2. `t.y`:
6179+
// a. S2(get t.id) -> S3(get t.y using key id)
6180+
//
6181+
// And the idea is that "individually", for just `t.x`, getting it all in `S1` using option a.,
6182+
// but for the whole plan, using option b. is actually better since it avoid querying `S1`
6183+
// entirely (and `S2`/`S2` have to be queried anyway).
6184+
//
6185+
// Anyway, this test make sure we do correctly generate the plan using 1.b and 2.a, and do
6186+
// not ignore 1.b in favor of 1.a in particular (which a bug did at one point).
6187+
6188+
const subgraph1 = {
6189+
name: 'Subgraph1',
6190+
typeDefs: gql`
6191+
type Query {
6192+
t: T @shareable
6193+
}
6194+
6195+
type T {
6196+
x: Int @shareable
6197+
}
6198+
`
6199+
}
6200+
6201+
const subgraph2 = {
6202+
name: 'Subgraph2',
6203+
typeDefs: gql`
6204+
type Query {
6205+
t: T @shareable
6206+
}
6207+
6208+
type T @key(fields: "id") {
6209+
id: ID!
6210+
}
6211+
`
6212+
}
6213+
6214+
const subgraph3 = {
6215+
name: 'Subgraph3',
6216+
typeDefs: gql`
6217+
type T @key(fields: "id") {
6218+
id: ID!
6219+
x: Int @shareable
6220+
y: Int
6221+
}
6222+
`
6223+
}
6224+
6225+
const [api, queryPlanner] = composeAndCreatePlanner(subgraph1, subgraph2, subgraph3);
6226+
const operation = operationFromDocument(api, gql`
6227+
{
6228+
t {
6229+
x
6230+
y
6231+
}
6232+
}
6233+
`);
6234+
6235+
const plan = queryPlanner.buildQueryPlan(operation);
6236+
expect(plan).toMatchInlineSnapshot(`
6237+
QueryPlan {
6238+
Sequence {
6239+
Fetch(service: "Subgraph2") {
6240+
{
6241+
t {
6242+
__typename
6243+
id
6244+
}
6245+
}
6246+
},
6247+
Flatten(path: "t") {
6248+
Fetch(service: "Subgraph3") {
6249+
{
6250+
... on T {
6251+
__typename
6252+
id
6253+
}
6254+
} =>
6255+
{
6256+
... on T {
6257+
y
6258+
x
6259+
}
6260+
}
6261+
},
6262+
},
6263+
},
6264+
}
6265+
`);
6266+
});

0 commit comments

Comments
 (0)