Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(query-graph): allow multiple best path options per subgraph #3177

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions composition-js/src/__tests__/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2444,6 +2444,47 @@ describe('composition', () => {
});
});

it('fed-393', () => {
const subgraphA = {
typeDefs: gql`
type I1 @interfaceObject @key(fields: "id") {
id: ID!
f1: Int!
}

type I2 @interfaceObject @key(fields: "id") {
id: ID!
f2: Int!
}
`,
name: 'subgraphA',
};

const subgraphB = {
typeDefs: gql`
type Query {
AFromB: A
}

interface I1 @key(fields: "id") {
id: ID!
}

interface I2 @key(fields: "id") {
id: ID!
}

type A implements I1 & I2 @key(fields: "id") {
id: ID!
}
`,
name: 'subgraphB',
};

const result = composeAsFed2Subgraphs([subgraphA, subgraphB]);
expect(result.errors).toBeUndefined();
});

it('handles renamed federation directives', () => {
const subgraphA = {
typeDefs: gql`
Expand Down
24 changes: 19 additions & 5 deletions query-graphs-js/src/graphPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1431,7 +1431,9 @@
// For each source, we store the best path we find for that source with the score, or `null` if we can
// decide that we should try going to that source (typically because we can prove that this create an
// inefficient detour for which a more direct path exists and will be found).
const bestPathBySource = new Map<string, [GraphPath<TTrigger, V, TNullEdge>, number] | null>();
// Note: There may be multiple "best" paths if the tail implements multiple interfaces and target
// subgraph also has multiple @interfaceObject implementing them.
const bestPathBySource = new Map<string, [GraphPath<TTrigger, V, TNullEdge>, number, GraphPath<TTrigger, V, TNullEdge>[]] | null>();
const deadEndClosures: UnadvanceableClosure[] = [];
const toTry: GraphPath<TTrigger, V, TNullEdge>[] = [ path ];
while (toTry.length > 0) {
Expand Down Expand Up @@ -1503,7 +1505,7 @@

if (prevForSource
&& (prevForSource[0].size < toAdvance.size + 1
|| (prevForSource[0].size == toAdvance.size + 1 && prevForSource[1] <= 1)
|| (prevForSource[0].size == toAdvance.size + 1 && prevForSource[1] <= 1 && prevForSource[0].tail == edge.tail)
)
) {
// We've already found another path that gets us to the same subgraph than the edge we're
Expand All @@ -1515,9 +1517,17 @@
// during composition validation where all costs are 0 to mean "we don't care about costs".
// Meaning effectively that for validation, as soon as we have a path to a subgraph, we ignore
// other options even if they may be "faster".
// Note: The "same size" rule only applies if both paths are reaching the same node, in which
// case it doesn't matter which path is chosen. On the other hand, if paths are tied but
// reaching different nodes, both should be kept. This can happen when the source subgraph
// type implements multiple interfaces and the target subgraph has multiple @interfaceObject
// implementing those interfaces.
debug.groupEnd(() => `Ignored: a better (shorter) path to the same subgraph already added`);
continue;
}
// New path has the same size as the previous best and they are tied.
// (See the note above about the "same size" rule).
const is_tied = prevForSource && (prevForSource[0].size == toAdvance.size + 1 && prevForSource[1] <= 1 && prevForSource[0].tail != edge.tail);

if (isConditionExcluded(edge.conditions, excludedConditions)) {
debug.groupEnd(`Ignored: edge condition is excluded`);
Expand All @@ -1542,7 +1552,7 @@
// We _can_ get to `target.source` with that edge. But if we had already found another path to
// the same subgraph, we want to replace it by this one only if either 1) it is shorter or 2) if
// it's of equal size, only if the condition cost are lower than the previous one.
if (prevForSource && prevForSource[0].size === toAdvance.size + 1 && prevForSource[1] <= conditionResolution.cost) {
if (prevForSource && prevForSource[0].size === toAdvance.size + 1 && prevForSource[1] <= conditionResolution.cost && !is_tied) {
debug.groupEnd('Ignored: a better (less costly) path to the same subgraph already added');
continue;
}
Expand Down Expand Up @@ -1620,7 +1630,11 @@

const updatedPath = toAdvance.add(convertTransitionWithCondition(edge.transition, context), edge, conditionResolution);
debug.log(() => `Using edge, advance path: ${updatedPath}`);
bestPathBySource.set(target.source, [updatedPath, conditionResolution.cost]);
if (is_tied) {
prevForSource![2].push(updatedPath);

Check warning on line 1634 in query-graphs-js/src/graphPath.ts

View check run for this annotation

Codecov / codecov/patch

query-graphs-js/src/graphPath.ts#L1634

Added line #L1634 was not covered by tests
} else {
bestPathBySource.set(target.source, [updatedPath, conditionResolution.cost, [updatedPath]]);
}
// It can be necessary to "chain" keys, because different subgraphs may have different keys exposed, and so we when we took
// a key, we want to check if there is new key we can now take that take us to other subgraphs. For other 'non-collecting'
// edges ('QueryResolution' and 'SubgraphEnteringTransition') however, chaining never give us additional value.
Expand Down Expand Up @@ -1652,7 +1666,7 @@
debug.groupEnd();
}
return {
paths: mapValues(bestPathBySource).filter(p => p !== null).map(b => b![0]),
paths: mapValues(bestPathBySource).filter(p => p !== null).flatMap(b => b![2]),
deadEnds: new UnadvanceableClosures(deadEndClosures) as TDeadEnds
}
}
Expand Down