diff --git a/.changeset/red-geckos-bathe.md b/.changeset/red-geckos-bathe.md new file mode 100644 index 000000000..4e2de2d85 --- /dev/null +++ b/.changeset/red-geckos-bathe.md @@ -0,0 +1,5 @@ +--- +"@apollo/composition": patch +--- + +Improves the performance of composition's satisfiability validation for heavily-connected supergraphs by using a priority queue instead of a stack. diff --git a/composition-js/package.json b/composition-js/package.json index 38e3f43c4..d6e43fdcd 100644 --- a/composition-js/package.json +++ b/composition-js/package.json @@ -28,7 +28,8 @@ }, "dependencies": { "@apollo/federation-internals": "2.10.2", - "@apollo/query-graphs": "2.10.2" + "@apollo/query-graphs": "2.10.2", + "fastpriorityqueue": "^0.7.5" }, "peerDependencies": { "graphql": "^16.5.0" diff --git a/composition-js/src/validate.ts b/composition-js/src/validate.ts index 1cb477e5a..c34e7d6f2 100644 --- a/composition-js/src/validate.ts +++ b/composition-js/src/validate.ts @@ -61,6 +61,7 @@ import { isUnadvanceableClosures, } from "@apollo/query-graphs"; import { CompositionHint, HINTS } from "./hints"; +import FastPriorityQueue from "fastpriorityqueue"; import { ASTNode, GraphQLError, print } from "graphql"; const debug = newDebugLogger('validation'); @@ -684,8 +685,19 @@ interface VertexVisit { class ValidationTraversal { private readonly conditionResolver: ConditionResolver; - // The stack contains all states that aren't terminal. - private readonly stack: ValidationState[] = []; + // The queue contains all states that aren't terminal. We use a priority queue + // where states with less possible subgraph paths are processed earlier. This + // is because supergraphs with high connectivity can lead to states with many + // possible subgraph paths for a supergraph path, and repeatedly processing + // such states can cause an explosion in possible subgraph paths in the worst + // case. To avoid this explosion, we prioritize supergraph paths with fewer + // possible subgraph paths, and this helps us reach more types using smaller + // states in practice. Loop detection then prevents us from having to consider + // the larger states to begin with. + private readonly queue: FastPriorityQueue = + new FastPriorityQueue((s1, s2) => + s1.subgraphPathInfos.length < s2.subgraphPathInfos.length + ); // For each vertex in the supergraph, records if we've already visited that vertex and in which subgraphs we were. // For a vertex, we may have multiple "sets of subgraphs", hence the double-array. @@ -707,7 +719,7 @@ class ValidationTraversal { queryGraph: federatedQueryGraph, withCaching: true, }); - supergraphAPI.rootKinds().forEach((kind) => this.stack.push(ValidationState.initial({ + supergraphAPI.rootKinds().forEach((kind) => this.queue.add(ValidationState.initial({ supergraphAPI, kind, federatedQueryGraph, @@ -725,14 +737,14 @@ class ValidationTraversal { errors: GraphQLError[], hints: CompositionHint[], } { - while (this.stack.length > 0) { - this.handleState(this.stack.pop()!); + while (!this.queue.isEmpty()) { + this.handleState(this.queue.poll()!); } return { errors: this.validationErrors, hints: this.validationHints }; } private handleState(state: ValidationState) { - debug.group(() => `Validation: ${this.stack.length + 1} open states. Validating ${state}`); + debug.group(() => `Validation: ${this.queue.size + 1} open states. Validating ${state}`); const vertex = state.supergraphPath.tail; const currentVertexVisit: VertexVisit = { @@ -796,10 +808,10 @@ class ValidationTraversal { } // The check for `isTerminal` is not strictly necessary as if we add a terminal - // state to the stack this method, `handleState`, will do nothing later. But it's + // state to the queue this method, `handleState`, will do nothing later. But it's // worth checking it now and save some memory/cycles. if (newState && !newState.supergraphPath.isTerminal()) { - this.stack.push(newState); + this.queue.add(newState); debug.groupEnd(() => `Reached new state ${newState}`); } else { debug.groupEnd(`Reached terminal vertex/cycle`); diff --git a/package-lock.json b/package-lock.json index 1b7142917..373a045bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,8 @@ "license": "Elastic-2.0", "dependencies": { "@apollo/federation-internals": "2.10.2", - "@apollo/query-graphs": "2.10.2" + "@apollo/query-graphs": "2.10.2", + "fastpriorityqueue": "^0.7.5" }, "engines": { "node": ">=14.15.0" @@ -8028,6 +8029,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fastpriorityqueue": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/fastpriorityqueue/-/fastpriorityqueue-0.7.5.tgz", + "integrity": "sha512-3Pa0n9gwy8yIbEsT3m2j/E9DXgWvvjfiZjjqcJ+AdNKTAlVMIuFYrYG5Y3RHEM8O6cwv9hOpOWY/NaMfywoQVA==", + "license": "Apache-2.0" + }, "node_modules/fastq": { "version": "1.15.0", "dev": true,