Skip to content

Commit f3ab499

Browse files
fix(query-planning): Prevent error for satisfiable @shareable mutation fields (#3304)
This PR fixes a bug where query planning may unexpectedly error while planning an operation with a satisfiable `@shareable` mutation field. Specifically, this PR: 1. Updates query planning on mutation fields to only consider one initial subgraph at a time for a traversal, and returns the plan with lowest cost. - Previously, this was mixing up options that started in different subgraphs in the same traversal, which would sometimes generate query planning errors if those differing options were chosen. There were a few ways to fix this, but the least invasive/simplest was just to do multiple traversals, each with a limited initial subgraph. - Non-local selection estimation was also appropriately updated to account for this (if it weren't, it would systemically overestimate the number of non-local selections). 2. Updates a particular query planning optimization to avoid discarding an option if another option with less subgraph jumps/cost is found, if that option starts in a different subgraph and the option is for a mutation operation. 3. Updates query planning to ignore `@key` edges at top-level (previously only root type resolution edges were ignored). This PR was originally filed as part of #3303, but was split off so it could be merged faster in the next patch release.
1 parent 4bda3a4 commit f3ab499

File tree

6 files changed

+338
-22
lines changed

6 files changed

+338
-22
lines changed

.changeset/many-rings-glow.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@apollo/query-planner": patch
3+
"@apollo/query-graphs": patch
4+
---
5+
6+
Fixes a bug where query planning may unexpectedly error due to attempting to generate a plan where a `@shareable` mutation field is called more than once across multiple subgraphs. ([#3304](https://github.com/apollographql/federation/pull/3304))

query-graphs-js/src/__tests__/graphPath.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ function createOptions(supergraph: Schema, queryGraph: QueryGraph): Simultaneous
7272
[],
7373
[],
7474
new Map(),
75+
null,
7576
);
7677
}
7778

query-graphs-js/src/graphPath.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1490,8 +1490,11 @@ function advancePathWithNonCollectingAndTypePreservingTransitions<TTrigger, V ex
14901490
// We have edges between Query objects so that if a field returns a query object, we can jump to any subgraph
14911491
// at that point. However, there is no point of using those edges at the beginning of a path, except for when
14921492
// we have a @defer, in which case we want to allow re-jumping to the same subgraph.
1493-
if (isTopLevelPath && edge.transition.kind === 'RootTypeResolution' && !(toAdvance.deferOnTail && edge.isKeyOrRootTypeEdgeToSelf())) {
1494-
debug.groupEnd(`Ignored: edge is a top-level "RootTypeResolution"`);
1493+
if (isTopLevelPath
1494+
&& (edge.transition.kind === 'RootTypeResolution' || edge.transition.kind === 'KeyResolution')
1495+
&& !(toAdvance.deferOnTail && edge.isKeyOrRootTypeEdgeToSelf())
1496+
) {
1497+
debug.groupEnd(`Ignored: edge is a top-level "RootTypeResolution" or "KeyResolution"`);
14951498
continue;
14961499
}
14971500

@@ -1573,7 +1576,11 @@ function advancePathWithNonCollectingAndTypePreservingTransitions<TTrigger, V ex
15731576
let backToPreviousSubgraph: boolean;
15741577
if (subgraphEnteringEdge.edge.transition.kind === 'SubgraphEnteringTransition') {
15751578
assert(toAdvance.root instanceof RootVertex, () => `${toAdvance} should be a root path if it starts with subgraph entering edge ${subgraphEnteringEdge.edge}`);
1576-
prevSubgraphEnteringVertex = rootVertexForSubgraph(toAdvance.graph, edge.tail.source, toAdvance.root.rootKind);
1579+
// Since mutation options need to originate from the same subgraph, we pretend we cannot find a root vertex
1580+
// in another subgraph (effectively skipping the optimization).
1581+
prevSubgraphEnteringVertex = toAdvance.root.rootKind !== 'mutation'
1582+
? rootVertexForSubgraph(toAdvance.graph, edge.tail.source, toAdvance.root.rootKind)
1583+
: undefined;
15771584
// If the entering edge is the root entering of subgraphs, then the "prev subgraph" is really `edge.tail.source` and
15781585
// so `edge` always get us back to that (but `subgraphEnteringEdge.edge.head.source` would be `FEDERATED_GRAPH_ROOT_SOURCE`,
15791586
// so the test we do in the `else` branch would not work here).
@@ -2383,6 +2390,7 @@ export function createInitialOptions<V extends Vertex>(
23832390
excludedEdges: ExcludedDestinations,
23842391
excludedConditions: ExcludedConditions,
23852392
overrideConditions: Map<string, boolean>,
2393+
initialSubgraphConstraint: string | null,
23862394
): SimultaneousPathsWithLazyIndirectPaths<V>[] {
23872395
const lazyInitialPath = new SimultaneousPathsWithLazyIndirectPaths(
23882396
[initialPath],
@@ -2393,7 +2401,12 @@ export function createInitialOptions<V extends Vertex>(
23932401
overrideConditions,
23942402
);
23952403
if (isFederatedGraphRootType(initialPath.tail.type)) {
2396-
const initialOptions = lazyInitialPath.indirectOptions(initialContext, 0);
2404+
let initialOptions = lazyInitialPath.indirectOptions(initialContext, 0);
2405+
if (initialSubgraphConstraint !== null) {
2406+
initialOptions.paths = initialOptions
2407+
.paths
2408+
.filter((path) => path.tail.source === initialSubgraphConstraint);
2409+
}
23972410
return createLazyOptions(initialOptions.paths.map(p => [p]), lazyInitialPath, initialContext, overrideConditions);
23982411
} else {
23992412
return [lazyInitialPath];

query-graphs-js/src/nonLocalSelectionsEstimation.ts

Lines changed: 99 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -600,13 +600,18 @@ export class NonLocalSelectionsMetadata {
600600
* This calls {@link checkNonLocalSelectionsLimitExceeded} for each of the
601601
* selections in the open branches stack; see that function's doc comment for
602602
* more information.
603+
*
604+
* To support mutations, we allow indicating the initial subgraph is
605+
* constrained, in which case indirect options will be ignored until the first
606+
* field (similar to query planning).
603607
*/
604608
checkNonLocalSelectionsLimitExceededAtRoot(
605609
stack: [Selection, SimultaneousPathsWithLazyIndirectPaths[]][],
606610
state: NonLocalSelectionsState,
607611
supergraphSchema: Schema,
608612
inconsistentAbstractTypesRuntimes: Set<string>,
609613
overrideConditions: Map<string, boolean>,
614+
isInitialSubgraphConstrained: boolean,
610615
): boolean {
611616
for (const [selection, simultaneousPaths] of stack) {
612617
const tailVertices = new Set<Vertex>();
@@ -616,7 +621,10 @@ export class NonLocalSelectionsMetadata {
616621
}
617622
}
618623
const tailVerticesInfo =
619-
this.estimateVerticesWithIndirectOptions(tailVertices);
624+
this.estimateVerticesWithIndirectOptions(
625+
tailVertices,
626+
isInitialSubgraphConstrained,
627+
);
620628

621629
// Note that top-level selections aren't avoided via fully-local selection
622630
// set optimization, so we always add them here.
@@ -626,12 +634,16 @@ export class NonLocalSelectionsMetadata {
626634

627635
if (selection.selectionSet) {
628636
const selectionHasDefer = selection.hasDefer();
637+
const isInitialSubgraphConstrainedAfterElement =
638+
isInitialSubgraphConstrained
639+
&& selection.kind === 'FragmentSelection';
629640
const nextVertices = this.estimateNextVerticesForSelection(
630641
selection.element,
631642
tailVerticesInfo,
632643
state,
633644
supergraphSchema,
634645
overrideConditions,
646+
isInitialSubgraphConstrainedAfterElement,
635647
);
636648
if (this.checkNonLocalSelectionsLimitExceeded(
637649
selection.selectionSet,
@@ -641,6 +653,7 @@ export class NonLocalSelectionsMetadata {
641653
supergraphSchema,
642654
inconsistentAbstractTypesRuntimes,
643655
overrideConditions,
656+
isInitialSubgraphConstrainedAfterElement,
644657
)) {
645658
return true;
646659
}
@@ -674,6 +687,10 @@ export class NonLocalSelectionsMetadata {
674687
* Note that this function takes in whether the parent selection of the
675688
* selection set has @defer, as that affects whether the optimization is
676689
* disabled for that selection set.
690+
*
691+
* To support mutations, we allow indicating the initial subgraph is
692+
* constrained, in which case indirect options will be ignored until the first
693+
* field (similar to query planning).
677694
*/
678695
private checkNonLocalSelectionsLimitExceeded(
679696
selectionSet: SelectionSet,
@@ -683,6 +700,7 @@ export class NonLocalSelectionsMetadata {
683700
supergraphSchema: Schema,
684701
inconsistentAbstractTypesRuntimes: Set<string>,
685702
overrideConditions: Map<string, boolean>,
703+
isInitialSubgraphConstrained: boolean,
686704
): boolean {
687705
// Compute whether the selection set is non-local, and if so, add its
688706
// selections to the count. Any of the following causes the selection set to
@@ -709,12 +727,16 @@ export class NonLocalSelectionsMetadata {
709727

710728
const oldCount = state.count;
711729
if (selection.selectionSet) {
730+
const isInitialSubgraphConstrainedAfterElement =
731+
isInitialSubgraphConstrained
732+
&& selection.kind === 'FragmentSelection';
712733
const nextVertices = this.estimateNextVerticesForSelection(
713734
element,
714735
parentVertices,
715736
state,
716737
supergraphSchema,
717738
overrideConditions,
739+
isInitialSubgraphConstrainedAfterElement,
718740
);
719741
if (this.checkNonLocalSelectionsLimitExceeded(
720742
selection.selectionSet,
@@ -724,6 +746,7 @@ export class NonLocalSelectionsMetadata {
724746
supergraphSchema,
725747
inconsistentAbstractTypesRuntimes,
726748
overrideConditions,
749+
isInitialSubgraphConstrainedAfterElement,
727750
)) {
728751
return true;
729752
}
@@ -822,13 +845,20 @@ export class NonLocalSelectionsMetadata {
822845
* selection for a set of parent vertices (including indirect options), this
823846
* function can be used to estimate an upper bound on the next vertices after
824847
* taking the selection (also with indirect options).
848+
*
849+
* To support mutations, we allow indicating the initial subgraph will be
850+
* constrained after taking the element, in which case indirect options will
851+
* be ignored (and caching will be skipped). This is to ensure that top-level
852+
* mutation fields are not executed on a different subgraph than the initial
853+
* one during query planning.
825854
*/
826855
private estimateNextVerticesForSelection(
827856
element: OperationElement,
828857
parentVertices: NextVerticesInfo,
829858
state: NonLocalSelectionsState,
830859
supergraphSchema: Schema,
831860
overrideConditions: Map<string, boolean>,
861+
isInitialSubgraphConstrainedAfterElement: boolean,
832862
): NextVerticesInfo {
833863
const selectionKey = element.kind === 'Field'
834864
? element.definition.name
@@ -837,6 +867,28 @@ export class NonLocalSelectionsMetadata {
837867
// For empty type condition, the vertices don't change.
838868
return parentVertices;
839869
}
870+
if (isInitialSubgraphConstrainedAfterElement) {
871+
// When the initial subgraph is constrained, skip caching entirely. Note
872+
// that caching is not skipped when the initial subgraph is constrained
873+
// before this element but not after. Because of that, there may be cache
874+
// entries for remaining vertices that were actually part of a complete
875+
// digraph, but this is only a slight caching inefficiency and doesn't
876+
// affect the computation's result.
877+
assert(
878+
parentVertices.nextVerticesWithIndirectOptions.types.size === 0,
879+
() => 'Initial subgraph was constrained which indicates no indirect'
880+
+ ' options should be taken, but the parent vertices unexpectedly had'
881+
+ ' a complete digraph which indicates indirect options were taken'
882+
+ ' upstream in the path.',
883+
);
884+
return this.estimateNextVerticesForSelectionWithoutCaching(
885+
element,
886+
parentVertices.nextVerticesWithIndirectOptions.remainingVertices,
887+
supergraphSchema,
888+
overrideConditions,
889+
true,
890+
);
891+
}
840892
let cache = state.nextVerticesCache.get(selectionKey);
841893
if (!cache) {
842894
cache = {
@@ -866,6 +918,7 @@ export class NonLocalSelectionsMetadata {
866918
indirectOptions.sameTypeOptions,
867919
supergraphSchema,
868920
overrideConditions,
921+
false,
869922
);
870923
cache.typesToNextVertices.set(typeName, cacheEntry);
871924
}
@@ -879,6 +932,7 @@ export class NonLocalSelectionsMetadata {
879932
[vertex],
880933
supergraphSchema,
881934
overrideConditions,
935+
false,
882936
);
883937
cache.remainingVerticesToNextVertices.set(vertex, cacheEntry);
884938
}
@@ -922,16 +976,23 @@ export class NonLocalSelectionsMetadata {
922976
* (We do account for override conditions, which are relatively
923977
* straightforward.)
924978
*
925-
* Since we're iterating through next vertices in the process, for efficiency
926-
* sake we also compute whether there are any reachable cross-subgraph edges
927-
* from the next vertices (without indirect options). This method assumes that
928-
* inline fragments have type conditions.
979+
* Since we're iterating through next vertices in the process, for
980+
* efficiency's sake we also compute whether there are any reachable
981+
* cross-subgraph edges from the next vertices (without indirect options).
982+
* This method assumes that inline fragments have type conditions.
983+
*
984+
* To support mutations, we allow indicating the initial subgraph will be
985+
* constrained after taking the element, in which case indirect options will
986+
* be ignored. This is to ensure that top-level mutation fields are not
987+
* executed on a different subgraph than the initial one during query
988+
* planning.
929989
*/
930990
private estimateNextVerticesForSelectionWithoutCaching(
931991
element: OperationElement,
932992
parentVertices: Iterable<Vertex>,
933993
supergraphSchema: Schema,
934994
overrideConditions: Map<string, boolean>,
995+
isInitialSubgraphConstrainedAfterElement: boolean,
935996
): NextVerticesInfo {
936997
const nextVertices = new Set<Vertex>();
937998
switch (element.kind) {
@@ -955,7 +1016,7 @@ export class NonLocalSelectionsMetadata {
9551016
}
9561017
};
9571018
for (const vertex of parentVertices) {
958-
// As an upper bound for efficiency sake, we consider both
1019+
// As an upper bound for efficiency's sake, we consider both
9591020
// non-type-exploded and type-exploded options.
9601021
processHeadVertex(vertex);
9611022
const downcasts = this.verticesToObjectTypeDowncasts.get(vertex);
@@ -1041,16 +1102,33 @@ export class NonLocalSelectionsMetadata {
10411102
assertUnreachable(element);
10421103
}
10431104

1044-
return this.estimateVerticesWithIndirectOptions(nextVertices);
1105+
return this.estimateVerticesWithIndirectOptions(
1106+
nextVertices,
1107+
isInitialSubgraphConstrainedAfterElement,
1108+
);
10451109
}
10461110

10471111
/**
1048-
* Estimate the indirect options for the given next vertices, and add them to
1049-
* the given vertices. As an upper bound for efficiency's sake, we assume we
1050-
* can take any indirect option (i.e. ignore any edge conditions).
1112+
* Estimate the indirect options for the given next vertices, and return the
1113+
* given next vertices along with `nextVerticesWithIndirectOptions` which
1114+
* contains these direct and indirect options. As an upper bound for
1115+
* efficiency's sake, we assume we can take any indirect option (i.e. ignore
1116+
* any edge conditions).
1117+
*
1118+
* Since we're iterating through next vertices in the process, for
1119+
* efficiency's sake we also compute whether there are any reachable
1120+
* cross-subgraph edges from the next vertices (without indirect options).
1121+
*
1122+
* To support mutations, we allow ignoring indirect options, as we don't want
1123+
* top-level mutation fields to be executed on a different subgraph than the
1124+
* initial one. In that case, `nextVerticesWithIndirectOptions` will not have
1125+
* any `types`, and the given vertices will be added to `remainingVertices`
1126+
* (despite them potentially being part of the complete digraph for their
1127+
* type). This is fine, as caching logic accounts for this accordingly.
10511128
*/
10521129
private estimateVerticesWithIndirectOptions(
10531130
nextVertices: Set<Vertex>,
1131+
ignoreIndirectOptions: boolean,
10541132
): NextVerticesInfo {
10551133
const nextVerticesInfo: NextVerticesInfo = {
10561134
nextVertices,
@@ -1063,7 +1141,16 @@ export class NonLocalSelectionsMetadata {
10631141
for (const nextVertex of nextVertices) {
10641142
nextVerticesInfo.nextVerticesHaveReachableCrossSubgraphEdges ||=
10651143
nextVertex.hasReachableCrossSubgraphEdges;
1066-
1144+
1145+
// As noted above, we don't want top-level mutation fields to be executed
1146+
// on a different subgraph than the initial one, so we support ignoring
1147+
// indirect options here.
1148+
if (ignoreIndirectOptions) {
1149+
nextVerticesInfo.nextVerticesWithIndirectOptions.remainingVertices
1150+
.add(nextVertex);
1151+
continue;
1152+
}
1153+
10671154
const typeName = nextVertex.type.name
10681155
const optionsMetadata = this.typesToIndirectOptions.get(typeName);
10691156
if (optionsMetadata) {
@@ -1085,7 +1172,7 @@ export class NonLocalSelectionsMetadata {
10851172
continue;
10861173
}
10871174
}
1088-
// We need to add the remaining vertex, and if its our first time seeing
1175+
// We need to add the remaining vertex, and if it's our first time seeing
10891176
// it, we also add any of its interface object options.
10901177
if (
10911178
!nextVerticesInfo.nextVerticesWithIndirectOptions.remainingVertices

0 commit comments

Comments
 (0)