From 3471648f9d848fa7bd0360f6cc2b658b3c43a3cc Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:18:33 +0300 Subject: [PATCH 01/24] feat: schedule deeply nested fetch-trees optimally scheduleFetches is a dependency-aware scheduler that emits nested Sequence/Parallel trees, collapsing independent chains onto their own branches instead of synchronizing them at wave barriers. This scheduler tries to break the top level DAG into weakly connected components. For each component it picks between two strategies of scheduling: - the waves tree, that barriers all ready roots per step, - the inlined tree, that pulls each root's exclusively-reachable descendants into that root's branch. This scheduler implements a strategy that is guarnteed to be not the worst combination condering all possible makespans of fetches. --- .../add_missing_nested_dependencies_test.go | 142 +--- .../postprocess/create_parallel_nodes_test.go | 182 ++-- .../order_sequence_by_dependencies.go | 9 +- .../order_sequence_by_dependencies_test.go | 330 ++++---- v2/pkg/engine/postprocess/postprocess.go | 17 + v2/pkg/engine/postprocess/schedule_fetches.go | 506 ++++++++++++ .../postprocess/schedule_fetches_test.go | 776 ++++++++++++++++++ v2/pkg/engine/postprocess/util_test.go | 68 ++ 8 files changed, 1658 insertions(+), 372 deletions(-) create mode 100644 v2/pkg/engine/postprocess/schedule_fetches.go create mode 100644 v2/pkg/engine/postprocess/schedule_fetches_test.go create mode 100644 v2/pkg/engine/postprocess/util_test.go diff --git a/v2/pkg/engine/postprocess/add_missing_nested_dependencies_test.go b/v2/pkg/engine/postprocess/add_missing_nested_dependencies_test.go index e24eaef73c..755805d895 100644 --- a/v2/pkg/engine/postprocess/add_missing_nested_dependencies_test.go +++ b/v2/pkg/engine/postprocess/add_missing_nested_dependencies_test.go @@ -4,138 +4,28 @@ import ( "testing" "github.com/stretchr/testify/require" - - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" ) func TestAddMissingNestedDependencies_ProcessFetchTree(t *testing.T) { t.Run("add missing dependencies to nested fetches on same merge path", func(t *testing.T) { - input := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: "a", - PostProcessing: resolve.PostProcessingConfiguration{ - MergePath: []string{"a"}, - }, - }, - FetchDependencies: resolve.FetchDependencies{ - FetchID: 0, - }, - }), - resolve.Single(&resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: "b", - PostProcessing: resolve.PostProcessingConfiguration{ - MergePath: []string{"b"}, - }, - }, - FetchDependencies: resolve.FetchDependencies{ - FetchID: 1, - }, - }), - resolve.SingleWithPath(&resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: "c", - }, - FetchDependencies: resolve.FetchDependencies{ - FetchID: 2, - }, - }, "a", resolve.ObjectPath("a")), - resolve.SingleWithPath(&resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: "d", - }, - FetchDependencies: resolve.FetchDependencies{ - FetchID: 3, - }, - }, "b.c", resolve.ObjectPath("b"), resolve.ObjectPath("c")), - resolve.SingleWithPath(&resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: "x", - }, - FetchDependencies: resolve.FetchDependencies{ - FetchID: 4, - DependsOnFetchIDs: []int{0}, - }, - }, "a", resolve.ObjectPath("a")), - resolve.Single(&resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: "y", - PostProcessing: resolve.PostProcessingConfiguration{ - MergePath: []string{"y"}, - }, - }, - FetchDependencies: resolve.FetchDependencies{ - FetchID: 5, - }, - }), - ) - - expected := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: "a", - PostProcessing: resolve.PostProcessingConfiguration{ - MergePath: []string{"a"}, - }, - }, - FetchDependencies: resolve.FetchDependencies{ - FetchID: 0, - }, - }), - resolve.Single(&resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: "b", - PostProcessing: resolve.PostProcessingConfiguration{ - MergePath: []string{"b"}, - }, - }, - FetchDependencies: resolve.FetchDependencies{ - FetchID: 1, - }, - }), - resolve.SingleWithPath(&resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: "c", - }, - FetchDependencies: resolve.FetchDependencies{ - FetchID: 2, - DependsOnFetchIDs: []int{0}, - }, - }, "a", resolve.ObjectPath("a")), - resolve.SingleWithPath(&resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: "d", - }, - FetchDependencies: resolve.FetchDependencies{ - FetchID: 3, - DependsOnFetchIDs: []int{1}, - }, - }, "b.c", resolve.ObjectPath("b"), resolve.ObjectPath("c")), - resolve.SingleWithPath(&resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: "x", - }, - FetchDependencies: resolve.FetchDependencies{ - FetchID: 4, - DependsOnFetchIDs: []int{0}, - }, - }, "a", resolve.ObjectPath("a")), - resolve.Single(&resolve.SingleFetch{ - FetchConfiguration: resolve.FetchConfiguration{ - Input: "y", - PostProcessing: resolve.PostProcessingConfiguration{ - MergePath: []string{"y"}, - }, - }, - FetchDependencies: resolve.FetchDependencies{ - FetchID: 5, - }, - }), - ) - processor := &addMissingNestedDependencies{} + input := seq( + sf(0, provides("a")), + sf(1, provides("b")), + sf(2, at("a")), + sf(3, at("b.c")), + sf(4, at("a"), deps(0)), + sf(5, provides("y")), + ) processor.ProcessFetchTree(input) + expected := seq( + sf(0, provides("a")), + sf(1, provides("b")), + sf(2, at("a"), deps(0)), + sf(3, at("b.c"), deps(1)), + sf(4, at("a"), deps(0)), + sf(5, provides("y")), + ) require.Equal(t, expected, input) }) } diff --git a/v2/pkg/engine/postprocess/create_parallel_nodes_test.go b/v2/pkg/engine/postprocess/create_parallel_nodes_test.go index 6fb909649f..6ed9991dc8 100644 --- a/v2/pkg/engine/postprocess/create_parallel_nodes_test.go +++ b/v2/pkg/engine/postprocess/create_parallel_nodes_test.go @@ -4,156 +4,154 @@ import ( "testing" "github.com/stretchr/testify/require" - - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" ) func TestCreateParallelNodes_ProcessFetchTree(t *testing.T) { t.Run("root with 2 dependent children and one 3rd child", func(t *testing.T) { processor := &createParallelNodes{} - input := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 0}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 1, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 2, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 3, DependsOnFetchIDs: []int{1}}}), + input := seq( + sf(0), + sf(1, deps(0)), + sf(2, deps(0)), + sf(3, deps(1)), ) processor.ProcessFetchTree(input) - expected := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 0}}), - resolve.Parallel( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 1, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 2, DependsOnFetchIDs: []int{0}}}), + expected := seq( + sf(0), + par( + sf(1, deps(0)), + sf(2, deps(0)), ), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 3, DependsOnFetchIDs: []int{1}}}), + sf(3, deps(1)), ) require.Equal(t, expected, input) }) t.Run("root with 2 dependent children and one 3rd child variant", func(t *testing.T) { processor := &createParallelNodes{} - input := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 0}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 1, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 2, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 3, DependsOnFetchIDs: []int{2}}}), + input := seq( + sf(0), + sf(1, deps(0)), + sf(2, deps(0)), + sf(3, deps(2)), ) processor.ProcessFetchTree(input) - expected := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 0}}), - resolve.Parallel( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 1, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 2, DependsOnFetchIDs: []int{0}}}), + expected := seq( + sf(0), + par( + sf(1, deps(0)), + sf(2, deps(0)), ), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 3, DependsOnFetchIDs: []int{2}}}), + sf(3, deps(2)), ) require.Equal(t, expected, input) }) t.Run("root with 2 dependent children and one 3rd child variant 2", func(t *testing.T) { processor := &createParallelNodes{} - input := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 0}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 1, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 2, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 3, DependsOnFetchIDs: []int{1, 2}}}), + input := seq( + sf(0), + sf(1, deps(0)), + sf(2, deps(0)), + sf(3, deps(1, 2)), ) processor.ProcessFetchTree(input) - expected := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 0}}), - resolve.Parallel( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 1, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 2, DependsOnFetchIDs: []int{0}}}), + expected := seq( + sf(0), + par( + sf(1, deps(0)), + sf(2, deps(0)), ), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 3, DependsOnFetchIDs: []int{1, 2}}}), + sf(3, deps(1, 2)), ) require.Equal(t, expected, input) }) t.Run("2 parallels depending on each other", func(t *testing.T) { processor := &createParallelNodes{} - input := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 0}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 1, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 2, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 3, DependsOnFetchIDs: []int{1, 2}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 4, DependsOnFetchIDs: []int{1, 2}}}), + input := seq( + sf(0), + sf(1, deps(0)), + sf(2, deps(0)), + sf(3, deps(1, 2)), + sf(4, deps(1, 2)), ) processor.ProcessFetchTree(input) - expected := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 0}}), - resolve.Parallel( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 1, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 2, DependsOnFetchIDs: []int{0}}}), + expected := seq( + sf(0), + par( + sf(1, deps(0)), + sf(2, deps(0)), ), - resolve.Parallel( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 3, DependsOnFetchIDs: []int{1, 2}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 4, DependsOnFetchIDs: []int{1, 2}}}), + par( + sf(3, deps(1, 2)), + sf(4, deps(1, 2)), ), ) require.Equal(t, expected, input) }) t.Run("2 parallels depending on each other mixed dependencies", func(t *testing.T) { processor := &createParallelNodes{} - input := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 0}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 1, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 2, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 3, DependsOnFetchIDs: []int{1}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 4, DependsOnFetchIDs: []int{2}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 5, DependsOnFetchIDs: []int{4}}}), + input := seq( + sf(0), + sf(1, deps(0)), + sf(2, deps(0)), + sf(3, deps(1)), + sf(4, deps(2)), + sf(5, deps(4)), ) processor.ProcessFetchTree(input) - expected := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 0}}), - resolve.Parallel( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 1, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 2, DependsOnFetchIDs: []int{0}}}), + expected := seq( + sf(0), + par( + sf(1, deps(0)), + sf(2, deps(0)), ), - resolve.Parallel( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 3, DependsOnFetchIDs: []int{1}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 4, DependsOnFetchIDs: []int{2}}}), + par( + sf(3, deps(1)), + sf(4, deps(2)), ), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 5, DependsOnFetchIDs: []int{4}}}), + sf(5, deps(4)), ) require.Equal(t, expected, input) }) t.Run("2 parallels with single in the middle", func(t *testing.T) { processor := &createParallelNodes{} - input := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 0}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 1, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 2, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 3, DependsOnFetchIDs: []int{1, 2}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 4, DependsOnFetchIDs: []int{1, 3}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 5, DependsOnFetchIDs: []int{2, 3}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 6, DependsOnFetchIDs: []int{4, 5}}}), + input := seq( + sf(0), + sf(1, deps(0)), + sf(2, deps(0)), + sf(3, deps(1, 2)), + sf(4, deps(1, 3)), + sf(5, deps(2, 3)), + sf(6, deps(4, 5)), ) processor.ProcessFetchTree(input) - expected := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 0}}), - resolve.Parallel( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 1, DependsOnFetchIDs: []int{0}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 2, DependsOnFetchIDs: []int{0}}}), + expected := seq( + sf(0), + par( + sf(1, deps(0)), + sf(2, deps(0)), ), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 3, DependsOnFetchIDs: []int{1, 2}}}), - resolve.Parallel( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 4, DependsOnFetchIDs: []int{1, 3}}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 5, DependsOnFetchIDs: []int{2, 3}}}), + sf(3, deps(1, 2)), + par( + sf(4, deps(1, 3)), + sf(5, deps(2, 3)), ), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 6, DependsOnFetchIDs: []int{4, 5}}}), + sf(6, deps(4, 5)), ) require.Equal(t, expected, input) }) t.Run("3 fetches in parallel without dependencies", func(t *testing.T) { processor := &createParallelNodes{} - input := resolve.Sequence( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 0}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 1}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 2}}), + input := seq( + sf(0), + sf(1), + sf(2), ) processor.ProcessFetchTree(input) - expected := resolve.Sequence( - resolve.Parallel( - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 0}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 1}}), - resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: 2}}), + expected := seq( + par( + sf(0), + sf(1), + sf(2), ), ) require.Equal(t, expected, input) diff --git a/v2/pkg/engine/postprocess/order_sequence_by_dependencies.go b/v2/pkg/engine/postprocess/order_sequence_by_dependencies.go index 45f247eb24..06adb6e44e 100644 --- a/v2/pkg/engine/postprocess/order_sequence_by_dependencies.go +++ b/v2/pkg/engine/postprocess/order_sequence_by_dependencies.go @@ -61,15 +61,8 @@ func (o *orderSequenceByDependencies) nodeDependsOn(node, root *resolve.FetchTre result = append(result, o.nodeDependsOn(child, root)...) } } - index := make(map[int]struct{}, len(result)) - for _, id := range result { - index[id] = struct{}{} - } - result = make([]int, 0, len(index)) - for id := range index { - result = append(result, id) - } slices.Sort(result) + result = slices.Compact(result) return result } diff --git a/v2/pkg/engine/postprocess/order_sequence_by_dependencies_test.go b/v2/pkg/engine/postprocess/order_sequence_by_dependencies_test.go index 10a85b8b43..173afe3add 100644 --- a/v2/pkg/engine/postprocess/order_sequence_by_dependencies_test.go +++ b/v2/pkg/engine/postprocess/order_sequence_by_dependencies_test.go @@ -1,169 +1,207 @@ package postprocess import ( - "encoding/json" "testing" "github.com/stretchr/testify/require" - - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" ) -func sequenceToDeps(seq *resolve.FetchTreeNode) []resolve.FetchDependencies { - result := make([]resolve.FetchDependencies, len(seq.ChildNodes)) - for i, node := range seq.ChildNodes { - result[i] = node.Item.Fetch.(*resolve.SingleFetch).FetchDependencies - } - return result -} - -func depsToSequence(deps []resolve.FetchDependencies) *resolve.FetchTreeNode { - result := &resolve.FetchTreeNode{ - ChildNodes: make([]*resolve.FetchTreeNode, len(deps)), - } - for i, dep := range deps { - result.ChildNodes[i] = &resolve.FetchTreeNode{ - Kind: resolve.FetchTreeNodeKindSingle, - Item: &resolve.FetchItem{ - Fetch: &resolve.SingleFetch{FetchDependencies: dep}, - }, - } - } - return result -} - -func prettyPrint(input any) string { - out, _ := json.MarshalIndent(input, "", " ") - return string(out) -} - -func TestOrderSquenceByDependencies_ProcessFetchTree(t *testing.T) { - processor := &orderSequenceByDependencies{} +func TestOrderSequenceByDependencies_ProcessFetchTree(t *testing.T) { t.Run("no dependencies", func(t *testing.T) { - input := []resolve.FetchDependencies{ - {FetchID: 2}, - {FetchID: 0}, - {FetchID: 1}, - } - expected := []resolve.FetchDependencies{ - {FetchID: 0}, - {FetchID: 1}, - {FetchID: 2}, - } - seq := depsToSequence(input) - processor.ProcessFetchTree(seq) - require.Equal(t, prettyPrint(expected), prettyPrint(sequenceToDeps(seq))) + processor := &orderSequenceByDependencies{} + input := seq( + sf(2), + sf(0), + sf(1), + ) + processor.ProcessFetchTree(input) + expected := seq( + sf(0), + sf(1), + sf(2), + ) + require.Equal(t, expected, input) }) t.Run("serial dependencies", func(t *testing.T) { - input := []resolve.FetchDependencies{ - {FetchID: 0}, - {FetchID: 2, DependsOnFetchIDs: []int{1}}, - {FetchID: 1, DependsOnFetchIDs: []int{0}}, - } - expected := []resolve.FetchDependencies{ - {FetchID: 0}, - {FetchID: 1, DependsOnFetchIDs: []int{0}}, - {FetchID: 2, DependsOnFetchIDs: []int{1}}, - } - seq := depsToSequence(input) - processor.ProcessFetchTree(seq) - require.Equal(t, prettyPrint(expected), prettyPrint(sequenceToDeps(seq))) + processor := &orderSequenceByDependencies{} + input := seq( + sf(0), + sf(2, deps(1)), + sf(1, deps(0)), + ) + processor.ProcessFetchTree(input) + expected := seq( + sf(0), + sf(1, deps(0)), + sf(2, deps(1)), + ) + require.Equal(t, expected, input) }) t.Run("serial + requires dependencies", func(t *testing.T) { - input := []resolve.FetchDependencies{ - {FetchID: 0}, - {FetchID: 1, DependsOnFetchIDs: []int{0, 2}}, - {FetchID: 2, DependsOnFetchIDs: []int{0}}, - } - expected := []resolve.FetchDependencies{ - {FetchID: 0}, - {FetchID: 2, DependsOnFetchIDs: []int{0}}, - {FetchID: 1, DependsOnFetchIDs: []int{0, 2}}, - } - seq := depsToSequence(input) - processor.ProcessFetchTree(seq) - require.Equal(t, prettyPrint(expected), prettyPrint(sequenceToDeps(seq))) + processor := &orderSequenceByDependencies{} + input := seq( + sf(0), + sf(1, deps(0, 2)), + sf(2, deps(0)), + ) + processor.ProcessFetchTree(input) + expected := seq( + sf(0), + sf(2, deps(0)), + sf(1, deps(0, 2)), + ) + require.Equal(t, expected, input) }) t.Run("more dependencies", func(t *testing.T) { - input := []resolve.FetchDependencies{ - {FetchID: 4, DependsOnFetchIDs: []int{3}}, - {FetchID: 0, DependsOnFetchIDs: []int{}}, - {FetchID: 2, DependsOnFetchIDs: []int{1}}, - {FetchID: 3, DependsOnFetchIDs: []int{5, 1}}, - {FetchID: 1, DependsOnFetchIDs: []int{0}}, - {FetchID: 5, DependsOnFetchIDs: []int{0}}, - } - expected := []resolve.FetchDependencies{ - {FetchID: 0, DependsOnFetchIDs: []int{}}, - {FetchID: 1, DependsOnFetchIDs: []int{0}}, - {FetchID: 5, DependsOnFetchIDs: []int{0}}, - {FetchID: 2, DependsOnFetchIDs: []int{1}}, - {FetchID: 3, DependsOnFetchIDs: []int{5, 1}}, - {FetchID: 4, DependsOnFetchIDs: []int{3}}, - } - seq := depsToSequence(input) - processor.ProcessFetchTree(seq) - require.Equal(t, prettyPrint(expected), prettyPrint(sequenceToDeps(seq))) + processor := &orderSequenceByDependencies{} + input := seq( + sf(4, deps(3)), + sf(0), + sf(2, deps(1)), + sf(3, deps(5, 1)), + sf(1, deps(0)), + sf(5, deps(0)), + ) + processor.ProcessFetchTree(input) + expected := seq( + sf(0), + sf(1, deps(0)), + sf(5, deps(0)), + sf(2, deps(1)), + sf(3, deps(5, 1)), + sf(4, deps(3)), + ) + require.Equal(t, expected, input) }) t.Run("double dependencies", func(t *testing.T) { - input := []resolve.FetchDependencies{ - {FetchID: 0, DependsOnFetchIDs: []int{}}, - {FetchID: 1, DependsOnFetchIDs: []int{0}}, - {FetchID: 2, DependsOnFetchIDs: []int{0, 5}}, - {FetchID: 3, DependsOnFetchIDs: []int{0, 1}}, - {FetchID: 4, DependsOnFetchIDs: []int{2}}, - {FetchID: 5, DependsOnFetchIDs: []int{0}}, - } - expected := []resolve.FetchDependencies{ - {FetchID: 0, DependsOnFetchIDs: []int{}}, - {FetchID: 1, DependsOnFetchIDs: []int{0}}, - {FetchID: 5, DependsOnFetchIDs: []int{0}}, - {FetchID: 2, DependsOnFetchIDs: []int{0, 5}}, - {FetchID: 3, DependsOnFetchIDs: []int{0, 1}}, - {FetchID: 4, DependsOnFetchIDs: []int{2}}, - } - seq := depsToSequence(input) - processor.ProcessFetchTree(seq) - require.Equal(t, prettyPrint(expected), prettyPrint(sequenceToDeps(seq))) + processor := &orderSequenceByDependencies{} + input := seq( + sf(0), + sf(1, deps(0)), + sf(2, deps(0, 5)), + sf(3, deps(0, 1)), + sf(4, deps(2)), + sf(5, deps(0)), + ) + processor.ProcessFetchTree(input) + expected := seq( + sf(0), + sf(1, deps(0)), + sf(5, deps(0)), + sf(2, deps(0, 5)), + sf(3, deps(0, 1)), + sf(4, deps(2)), + ) + require.Equal(t, expected, input) }) t.Run("double dependencies variant", func(t *testing.T) { - input := []resolve.FetchDependencies{ - {FetchID: 0, DependsOnFetchIDs: []int{}}, - {FetchID: 2, DependsOnFetchIDs: []int{0, 1}}, - {FetchID: 1, DependsOnFetchIDs: []int{0}}, - {FetchID: 3, DependsOnFetchIDs: []int{2}}, - {FetchID: 5, DependsOnFetchIDs: []int{4}}, - {FetchID: 4, DependsOnFetchIDs: []int{2, 3}}, - } - expected := []resolve.FetchDependencies{ - {FetchID: 0, DependsOnFetchIDs: []int{}}, - {FetchID: 1, DependsOnFetchIDs: []int{0}}, - {FetchID: 2, DependsOnFetchIDs: []int{0, 1}}, - {FetchID: 3, DependsOnFetchIDs: []int{2}}, - {FetchID: 4, DependsOnFetchIDs: []int{2, 3}}, - {FetchID: 5, DependsOnFetchIDs: []int{4}}, - } - seq := depsToSequence(input) - processor.ProcessFetchTree(seq) - require.Equal(t, prettyPrint(expected), prettyPrint(sequenceToDeps(seq))) + processor := &orderSequenceByDependencies{} + input := seq( + sf(0), + sf(2, deps(0, 1)), + sf(1, deps(0)), + sf(3, deps(2)), + sf(5, deps(4)), + sf(4, deps(2, 3)), + ) + processor.ProcessFetchTree(input) + expected := seq( + sf(0), + sf(1, deps(0)), + sf(2, deps(0, 1)), + sf(3, deps(2)), + sf(4, deps(2, 3)), + sf(5, deps(4)), + ) + require.Equal(t, expected, input) }) t.Run("nested requires", func(t *testing.T) { - input := []resolve.FetchDependencies{ - {FetchID: 0, DependsOnFetchIDs: []int{}}, - {FetchID: 3, DependsOnFetchIDs: []int{0, 2}}, - {FetchID: 1, DependsOnFetchIDs: []int{0}}, - {FetchID: 2, DependsOnFetchIDs: []int{0}}, - {FetchID: 4, DependsOnFetchIDs: []int{0, 1}}, - } - expected := []resolve.FetchDependencies{ - {FetchID: 0, DependsOnFetchIDs: []int{}}, - {FetchID: 1, DependsOnFetchIDs: []int{0}}, - {FetchID: 2, DependsOnFetchIDs: []int{0}}, - {FetchID: 3, DependsOnFetchIDs: []int{0, 2}}, - {FetchID: 4, DependsOnFetchIDs: []int{0, 1}}, - } - seq := depsToSequence(input) - processor.ProcessFetchTree(seq) - require.Equal(t, prettyPrint(expected), prettyPrint(sequenceToDeps(seq))) + processor := &orderSequenceByDependencies{} + input := seq( + sf(0), + sf(3, deps(0, 2)), + sf(1, deps(0)), + sf(2, deps(0)), + sf(4, deps(0, 1)), + ) + processor.ProcessFetchTree(input) + expected := seq( + sf(0), + sf(1, deps(0)), + sf(2, deps(0)), + sf(3, deps(0, 2)), + sf(4, deps(0, 1)), + ) + require.Equal(t, expected, input) + }) + + t.Run("dependent with fetch ID 0 must come after its dependency", func(t *testing.T) { + processor := &orderSequenceByDependencies{} + input := seq( + sf(0, deps(3)), + sf(3, deps(1, 2)), + sf(1, deps(5)), + sf(2, deps(5)), + sf(5), + ) + processor.ProcessFetchTree(input) + expected := seq( + sf(5), + sf(1, deps(5)), + sf(2, deps(5)), + sf(3, deps(1, 2)), + sf(0, deps(3)), + ) + require.Equal(t, expected, input) + }) + t.Run("equal transitive dependencies tie-break by fetch ID (diamond)", func(t *testing.T) { + processor := &orderSequenceByDependencies{} + input := seq( + sf(7, deps(4, 5)), + sf(6, deps(3, 4, 5)), + sf(3), + sf(4, deps(3)), + sf(5, deps(3)), + ) + processor.ProcessFetchTree(input) + expected := seq( + sf(3), + sf(4, deps(3)), + sf(5, deps(3)), + sf(6, deps(3, 4, 5)), + sf(7, deps(4, 5)), + ) + require.Equal(t, expected, input) + }) + t.Run("duplicate direct dependency IDs tie-break by fetch ID", func(t *testing.T) { + processor := &orderSequenceByDependencies{} + input := seq( + sf(3, deps(1)), + sf(2, deps(1, 1)), + sf(1), + ) + processor.ProcessFetchTree(input) + expected := seq( + sf(1), + sf(2, deps(1, 1)), + sf(3, deps(1)), + ) + require.Equal(t, expected, input) }) } + +// func TestOrderSequenceByDependencies_DependencyCycle(t *testing.T) { +// processor := &orderSequenceByDependencies{} +// processor.ProcessFetchTree(seq( +// sf(1, deps(2)), +// sf(2, deps(1)), +// )) +// } +// +// func TestOrderSequenceByDependencies_SelfDependency(t *testing.T) { +// processor := &orderSequenceByDependencies{} +// processor.ProcessFetchTree(seq( +// sf(0), +// sf(1, deps(1)), +// )) +// } diff --git a/v2/pkg/engine/postprocess/postprocess.go b/v2/pkg/engine/postprocess/postprocess.go index b6d7b91a8b..056f32c803 100644 --- a/v2/pkg/engine/postprocess/postprocess.go +++ b/v2/pkg/engine/postprocess/postprocess.go @@ -38,6 +38,7 @@ type FetchTreeProcessors struct { createConcreteSingleFetchTypes *createConcreteSingleFetchTypes orderSequenceByDependencies *orderSequenceByDependencies createParallelNodes *createParallelNodes + scheduleFetches *scheduleFetches } // processFlatFetchTree runs the stages that operate on the still-flat fetch tree @@ -58,6 +59,10 @@ func (p *FetchTreeProcessors) processFlatFetchTree(response *resolve.GraphQLResp // organizeFetchTree organizes the fetch tree by ordering sequence nodes by dependencies and creating parallel nodes. // after this step fetches have tree structure of serial and parallel nodes. func (p *FetchTreeProcessors) organizeFetchTree(fetches *resolve.FetchTreeNode) { + if !p.scheduleFetches.disable { + p.scheduleFetches.ProcessFetchTree(fetches) + return + } p.orderSequenceByDependencies.ProcessFetchTree(fetches) p.createParallelNodes.ProcessFetchTree(fetches) } @@ -92,6 +97,15 @@ type processorOptions struct { disableBuildDeferTree bool disableCollectAuthorizationCoordinates bool enableMultiFetch bool + scheduleFetches bool +} + +// WithScheduleFetches replaces the orderSequenceByDependencies and +// createParallelNodes pair with the nested schedule-tree scheduler. +func WithScheduleFetches() ProcessorOption { + return func(o *processorOptions) { + o.scheduleFetches = true + } } type ProcessorOption func(*processorOptions) @@ -216,6 +230,9 @@ func NewProcessor(options ...ProcessorOption) *Processor { createParallelNodes: &createParallelNodes{ disable: opts.disableCreateParallelNodes, }, + scheduleFetches: &scheduleFetches{ + disable: !opts.scheduleFetches, + }, }, responseTreeProcessors: &ResponseTreeProcessors{ mergeFields: &mergeFields{ diff --git a/v2/pkg/engine/postprocess/schedule_fetches.go b/v2/pkg/engine/postprocess/schedule_fetches.go new file mode 100644 index 0000000000..ea9a3fb262 --- /dev/null +++ b/v2/pkg/engine/postprocess/schedule_fetches.go @@ -0,0 +1,506 @@ +package postprocess + +import ( + "fmt" + "math" + "slices" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +// scheduleFetches is a dependency-aware scheduler that emits nested Sequence/Parallel trees, +// collapsing independent chains onto their own branches instead of synchronizing them at wave +// barriers. +// +// This scheduler tries to break the top level DAG into weakly connected components. +// For each component it picks between two strategies of scheduling: +// - the waves tree, that barriers all ready roots per step, +// - the inlined tree, that pulls each root's exclusively-reachable descendants into that root's branch. +// +// The inlined tree wins only when a per-fetch predecessor-containment proof shows it can +// never be slower than the component's waves tree, no matter how long each fetch takes. +// Since components never wait on each other, mixing winners is always safe. +// +// On any scheduler or validator error the processor falls back to the legacy wave pipeline. +type scheduleFetches struct { + disable bool +} + +func (b *scheduleFetches) ProcessFetchTree(root *resolve.FetchTreeNode) { + if b.disable || root == nil || root.Kind != resolve.FetchTreeNodeKindSequence { + return + } + if err := b.buildSchedule(root); err != nil { + (&orderSequenceByDependencies{}).ProcessFetchTree(root) + (&createParallelNodes{}).ProcessFetchTree(root) + } +} + +func (b *scheduleFetches) buildSchedule(root *resolve.FetchTreeNode) error { + dag, err := newFetchDAG(root.ChildNodes) + if err != nil { + return err + } + tree, err := buildScheduleTree(root.ChildNodes, dag) + if err != nil { + return err + } + if tree == nil { + root.ChildNodes = nil + return nil + } + // Replace only the scheduling-related fields: the root may carry a + // subscription Trigger or NormalizedQuery that must survive rescheduling. + root.Kind = tree.Kind + root.Item = tree.Item + root.ChildNodes = tree.ChildNodes + return nil +} + +type fetchDAG struct { + nodes map[int]*resolve.FetchTreeNode + parents map[int]map[int]struct{} + children map[int]map[int]struct{} +} + +func newFetchDAG(nodes []*resolve.FetchTreeNode) (*fetchDAG, error) { + dag := &fetchDAG{ + nodes: make(map[int]*resolve.FetchTreeNode, len(nodes)), + parents: make(map[int]map[int]struct{}, len(nodes)), + children: make(map[int]map[int]struct{}, len(nodes)), + } + for _, node := range nodes { + if node == nil || node.Item == nil || node.Item.Fetch == nil { + return nil, fmt.Errorf("non-single node in flat fetch sequence") + } + id := node.Item.Fetch.Dependencies().FetchID + if _, exists := dag.nodes[id]; exists { + return nil, fmt.Errorf("duplicate fetch id %d", id) + } + dag.nodes[id] = node + dag.parents[id] = map[int]struct{}{} + dag.children[id] = map[int]struct{}{} + } + for id, node := range dag.nodes { + for _, dep := range node.Item.Fetch.Dependencies().DependsOnFetchIDs { + if _, exists := dag.nodes[dep]; !exists { + continue // dependency satisfied outside this tree + } + if id == dep { + return nil, fmt.Errorf("self-dependent id %d", id) + } + dag.parents[id][dep] = struct{}{} + dag.children[dep][id] = struct{}{} + } + } + return dag, nil +} + +func buildScheduleTree(roots []*resolve.FetchTreeNode, dag *fetchDAG) (*resolve.FetchTreeNode, error) { + ids := make([]int, 0, len(roots)) + for _, root := range roots { + if root == nil || root.Item == nil || root.Item.Fetch == nil { + continue + } + ids = append(ids, root.Item.Fetch.Dependencies().FetchID) + } + // Pick the best strategy on the top level for weakly connected trees. + components := weaklyConnectedComponents(sortedUnique(ids), dag) + winners := make([]*resolve.FetchTreeNode, 0, len(components)) + for _, component := range components { + waves, err := schedule(component, dag, false) + if err != nil { + return nil, err + } + inlined, err := schedule(component, dag, true) + if err != nil { + return nil, err + } + if dominates(inlined, waves) { + winners = append(winners, inlined) + } else { + winners = append(winners, waves) + } + } + winner := parallelOf(winners) + if err := validateSchedule(winner, dag); err != nil { + return nil, err + } + return winner, nil +} + +// schedule builds a fetch tree for the DAG restricted to set. +// Dependencies on fetches outside set are treated as already satisfied. +// Weakly connected components run in Parallel; +// within a component the ready roots run in Parallel followed by the remainder in Sequence. +// When inlined, each root pulls the descendants reachable only through it into its branch. +func schedule(set []int, dag *fetchDAG, inline bool) (*resolve.FetchTreeNode, error) { + sortedSet := sortedUnique(set) + switch len(sortedSet) { + case 0: + return nil, nil + case 1: + return dag.nodes[sortedSet[0]], nil + } + components := weaklyConnectedComponents(sortedSet, dag) + if len(components) > 1 { + branches := make([]*resolve.FetchTreeNode, 0, len(components)) + for _, component := range components { + child, err := schedule(component, dag, inline) + if err != nil { + return nil, err + } + branches = append(branches, child) + } + return parallelOf(branches), nil + } + // Find the ready fetches: + // Roots have no parents in inSet; their dependencies outside inSet complete + // before this subtree starts, so they are ready to run as the first wave. + inSet := asMap(sortedSet) + roots := make([]int, 0, len(sortedSet)) + for _, id := range sortedSet { + if !hasParentIn(dag, id, inSet) { + roots = append(roots, id) + } + } + if len(roots) == 0 { + return nil, fmt.Errorf("cycle detected in fetch dependency graph") + } + scheduled := asMap(roots) + branches := make([]*resolve.FetchTreeNode, 0, len(roots)) + for _, root := range roots { + branch := dag.nodes[root] + if inline { + exclusive := exclusiveDescendants(root, dag, inSet, scheduled) + if len(exclusive) > 0 { + subtree, err := schedule(exclusive, dag, inline) + if err != nil { + return nil, err + } + branch = sequenceOf([]*resolve.FetchTreeNode{branch, subtree}) + } + } + branches = append(branches, branch) + } + rest := make([]int, 0, len(sortedSet)-len(scheduled)) + for _, id := range sortedSet { + if _, ok := scheduled[id]; !ok { + rest = append(rest, id) + } + } + restTree, err := schedule(rest, dag, inline) + if err != nil { + return nil, err + } + return sequenceOf([]*resolve.FetchTreeNode{parallelOf(branches), restTree}), nil +} + +func hasParentIn(dag *fetchDAG, id int, set map[int]struct{}) bool { + for parent := range dag.parents[id] { + if _, ok := set[parent]; ok { + return true + } + } + return false +} + +// exclusiveDescendants returns the descendants within inSet that are reachable only through root: +// every member's parent in inSet is root or another member. +// Such fetches cannot start before root finishes, +// inlining them into root's branch never delays them and frees them from waiting on sibling roots. +// Members are recorded in claimed, which keeps them out of the caller's rest set. +func exclusiveDescendants(root int, dag *fetchDAG, inSet, claimed map[int]struct{}) []int { + member := map[int]struct{}{root: {}} + out := make([]int, 0, len(dag.children[root])) + queue := asSortedSlice(dag.children[root]) + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + if _, ok := member[id]; ok { + continue + } + if _, ok := inSet[id]; !ok { + continue + } + if _, ok := claimed[id]; ok { + continue + } + exclusive := true + for parent := range dag.parents[id] { + if _, in := inSet[parent]; !in { + continue // satisfied by an enclosing recursion step + } + if _, ok := member[parent]; !ok { + exclusive = false + break + } + } + if !exclusive { + continue // revisited via the queue if its parents join later + } + member[id] = struct{}{} + claimed[id] = struct{}{} + out = append(out, id) + queue = append(queue, asSortedSlice(dag.children[id])...) + } + return out +} + +// dominates checks whether tree a is never slower than tree b, no matter how long each fetch takes. +// +// The test: every fetch's predecessor set in a must be a subset of its predecessor set in b. +// The fetches on any critical path of a are pairwise ordered in a, hence also in b, so they form +// a chain in b and b's makespan is at least that chain's weight — a's makespan. +// +// The test is also exact, not just sufficient: given any extra predecessor pair in a, +// there are fetch durations for which a is strictly slower. +func dominates(a, b *resolve.FetchTreeNode) bool { + predA, predB := treePredecessors(a), treePredecessors(b) + if len(predA) != len(predB) { + return false + } + for id, pa := range predA { + pb, exists := predB[id] + if !exists { + return false + } + for waitID := range pa { + if _, ok := pb[waitID]; !ok { + return false + } + } + } + return true +} + +// treePredecessors returns, per fetch, the set of fetches the tree guarantees +// to have completed before that fetch starts. +func treePredecessors(root *resolve.FetchTreeNode) map[int]map[int]struct{} { + preds := map[int]map[int]struct{}{} + var walk func(node *resolve.FetchTreeNode, before map[int]struct{}) []int + walk = func(node *resolve.FetchTreeNode, before map[int]struct{}) []int { + if node == nil { + return nil + } + switch node.Kind { + case resolve.FetchTreeNodeKindSingle: + id := node.Item.Fetch.Dependencies().FetchID + set := make(map[int]struct{}, len(before)) + for k := range before { + set[k] = struct{}{} + } + preds[id] = set + return []int{id} + case resolve.FetchTreeNodeKindParallel: + var ids []int + for _, child := range node.ChildNodes { + ids = append(ids, walk(child, before)...) + } + return ids + case resolve.FetchTreeNodeKindSequence: + acc := make(map[int]struct{}, len(before)) + for k := range before { + acc[k] = struct{}{} + } + var ids []int + for _, child := range node.ChildNodes { + childIDs := walk(child, acc) + for _, id := range childIDs { + acc[id] = struct{}{} + } + ids = append(ids, childIDs...) + } + return ids + default: + return nil + } + } + walk(root, map[int]struct{}{}) + return preds +} + +// validateSchedule walks the tree once, checking that every fetch's declared +// dependencies are sequenced strictly before it, and that the tree contains +// every DAG fetch exactly once (a schedule must never lose or duplicate work). +func validateSchedule(root *resolve.FetchTreeNode, dag *fetchDAG) error { + seen := make(map[int]int, len(dag.nodes)) + var walk func(node *resolve.FetchTreeNode, before map[int]struct{}) ([]int, error) + walk = func(node *resolve.FetchTreeNode, before map[int]struct{}) ([]int, error) { + if node == nil { + return nil, nil + } + switch node.Kind { + case resolve.FetchTreeNodeKindSingle: + id := node.Item.Fetch.Dependencies().FetchID + seen[id]++ + for _, dep := range node.Item.Fetch.Dependencies().DependsOnFetchIDs { + if _, known := dag.nodes[dep]; !known { + continue + } + if _, ok := before[dep]; !ok { + return nil, fmt.Errorf("fetch %d depends on fetch %d before it is available", id, dep) + } + } + return []int{id}, nil + case resolve.FetchTreeNodeKindParallel: + var ids []int + for _, child := range node.ChildNodes { + childIDs, err := walk(child, before) + if err != nil { + return nil, err + } + ids = append(ids, childIDs...) + } + return ids, nil + case resolve.FetchTreeNodeKindSequence: + available := make(map[int]struct{}, len(before)) + for k := range before { + available[k] = struct{}{} + } + var ids []int + for _, child := range node.ChildNodes { + childIDs, err := walk(child, available) + if err != nil { + return nil, err + } + for _, id := range childIDs { + available[id] = struct{}{} + } + ids = append(ids, childIDs...) + } + return ids, nil + default: + return nil, fmt.Errorf("unexpected node kind %q in schedule", node.Kind) + } + } + if _, err := walk(root, map[int]struct{}{}); err != nil { + return err + } + for id, count := range seen { + if count > 1 { + return fmt.Errorf("fetch %d scheduled %d times", id, count) + } + } + for id := range dag.nodes { + if seen[id] == 0 { + return fmt.Errorf("fetch %d missing from schedule", id) + } + } + return nil +} + +// weaklyConnectedComponents returns a slice of weakly connected components containing +// nodes which is an ordered set. +func weaklyConnectedComponents(nodes []int, dag *fetchDAG) [][]int { + allowed := asMap(nodes) + seen := map[int]struct{}{} + components := make([][]int, 0) + for _, id := range nodes { + if _, ok := seen[id]; ok { + continue + } + queue := []int{id} + seen[id] = struct{}{} + component := make([]int, 0) + for len(queue) != 0 { + current := queue[0] + queue = queue[1:] + component = append(component, current) + for _, neighbors := range []map[int]struct{}{dag.parents[current], dag.children[current]} { + for neighbor := range neighbors { + if _, ok := allowed[neighbor]; !ok { + continue + } + if _, ok := seen[neighbor]; ok { + continue + } + seen[neighbor] = struct{}{} + queue = append(queue, neighbor) + } + } + } + slices.Sort(component) + components = append(components, component) + } + return components +} + +// sequenceOf and parallelOf normalize child lists into fresh slices: nils +// dropped, same-kind children spliced inline, singleton unwrapped. They never +// write into the input slice. +func sequenceOf(children []*resolve.FetchTreeNode) *resolve.FetchTreeNode { + return combineOf(resolve.FetchTreeNodeKindSequence, children) +} + +func parallelOf(children []*resolve.FetchTreeNode) *resolve.FetchTreeNode { + return combineOf(resolve.FetchTreeNodeKindParallel, children) +} + +func combineOf(kind resolve.FetchTreeNodeKind, children []*resolve.FetchTreeNode) *resolve.FetchTreeNode { + out := make([]*resolve.FetchTreeNode, 0, len(children)) + for _, child := range children { + switch { + case child == nil: + case child.Kind == kind: + out = append(out, child.ChildNodes...) + default: + out = append(out, child) + } + } + switch len(out) { + case 0: + return nil + case 1: + return out[0] + } + if kind == resolve.FetchTreeNodeKindParallel { + slices.SortFunc(out, func(a, b *resolve.FetchTreeNode) int { + return minReachableFetchID(a) - minReachableFetchID(b) + }) + return resolve.Parallel(out...) + } + return resolve.Sequence(out...) +} + +func minReachableFetchID(node *resolve.FetchTreeNode) int { + minID := math.MaxInt + if node == nil { + return minID + } + if node.Kind == resolve.FetchTreeNodeKindSingle { + return node.Item.Fetch.Dependencies().FetchID + } + for _, child := range node.ChildNodes { + if childMin := minReachableFetchID(child); childMin < minID { + minID = childMin + } + } + return minID +} + +func sortedUnique(ids []int) []int { + if len(ids) == 0 { + return nil + } + out := append([]int{}, ids...) + slices.Sort(out) + return slices.Compact(out) +} + +func asSortedSlice(set map[int]struct{}) []int { + out := make([]int, 0, len(set)) + for id := range set { + out = append(out, id) + } + slices.Sort(out) + return out +} + +func asMap(ids []int) map[int]struct{} { + set := make(map[int]struct{}, len(ids)) + for _, id := range ids { + set[id] = struct{}{} + } + return set +} diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go new file mode 100644 index 0000000000..079aad01cb --- /dev/null +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -0,0 +1,776 @@ +package postprocess + +import ( + "math" + "math/rand" + "slices" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +func TestScheduleFetches_Scenarios(t *testing.T) { + t.Parallel() + type scenario struct { + name string + input []*resolve.FetchTreeNode + inlined *resolve.FetchTreeNode + waves *resolve.FetchTreeNode + want *resolve.FetchTreeNode // the winner + wantError string + } + + scenarios := []scenario{ + { + name: "independent components baseline", + input: nodes(sf(0), sf(1), sf(2, deps(0))), + want: par(seq(sf(0), sf(2, deps(0))), sf(1)), + }, + { + name: "single chain", + input: nodes(sf(0), sf(1, deps(0)), sf(2, deps(1)), sf(3, deps(2))), + want: seq(sf(0), sf(1, deps(0)), sf(2, deps(1)), sf(3, deps(2))), + }, + { + name: "diamond join", + input: nodes(sf(0), sf(1, deps(0)), sf(2, deps(0)), sf(3, deps(1, 2))), + want: seq(sf(0), par(sf(1, deps(0)), sf(2, deps(0))), sf(3, deps(1, 2))), + }, + { + name: "two chains joining", + input: nodes(sf(0), sf(1), sf(2, deps(0)), sf(3, deps(1)), sf(4, deps(2, 3))), + want: seq( + par( + seq(sf(0), sf(2, deps(0))), + seq(sf(1), sf(3, deps(1))), + ), + sf(4, deps(2, 3)), + ), + waves: seq( + par(sf(0), sf(1)), + par(sf(2, deps(0)), sf(3, deps(1))), + sf(4, deps(2, 3)), + ), + }, + { + name: "wide fan out", + input: nodes(sf(0), sf(1, deps(0)), sf(2, deps(0)), sf(3, deps(0)), sf(4, deps(0))), + want: seq(sf(0), par(sf(1, deps(0)), sf(2, deps(0)), sf(3, deps(0)), sf(4, deps(0)))), + }, + { + name: "wide fan in", + input: nodes(sf(0), sf(1), sf(2), sf(3), sf(4, deps(0, 1, 2, 3))), + want: seq(par(sf(0), sf(1), sf(2), sf(3)), sf(4, deps(0, 1, 2, 3))), + }, + { + name: "independent diamonds", + input: nodes( + sf(0), sf(1, deps(0)), sf(2, deps(0)), sf(3, deps(1, 2)), + sf(4), sf(5, deps(4)), sf(6, deps(4)), sf(7, deps(5, 6)), + ), + want: par( + seq(sf(0), par(sf(1, deps(0)), sf(2, deps(0))), sf(3, deps(1, 2))), + seq(sf(4), par(sf(5, deps(4)), sf(6, deps(4))), sf(7, deps(5, 6))), + ), + }, + { + // / 1 > 2 \ / 6 > 7 \ + // 0 -> -> 5 -> -> 10 + // \ 3 > 4 / \ 8 > 9 / + name: "sequenced diamonds with chain arms", + input: nodes( + sf(0), sf(1, deps(0)), sf(2, deps(1)), sf(3, deps(0)), sf(4, deps(3)), sf(5, deps(2, 4)), + sf(6, deps(5)), sf(7, deps(6)), sf(8, deps(5)), sf(9, deps(8)), sf(10, deps(7, 9)), + ), + want: seq( + sf(0), + par( + seq(sf(1, deps(0)), sf(2, deps(1))), + seq(sf(3, deps(0)), sf(4, deps(3))), + ), + sf(5, deps(2, 4)), + par( + seq(sf(6, deps(5)), sf(7, deps(6))), + seq(sf(8, deps(5)), sf(9, deps(8))), + ), + sf(10, deps(7, 9)), + ), + waves: seq( + sf(0), + par(sf(1, deps(0)), sf(3, deps(0))), + par(sf(2, deps(1)), sf(4, deps(3))), + sf(5, deps(2, 4)), + par(sf(6, deps(5)), sf(8, deps(5))), + par(sf(7, deps(6)), sf(9, deps(8))), + sf(10, deps(7, 9)), + ), + }, + { + name: "requires chain", + input: nodes(sf(0), sf(1, deps(0))), + want: seq(sf(0), sf(1, deps(0))), + }, + { + name: "batch entity component with independent root", + input: nodes(sf(0), bf(1, 0), sf(2)), + want: par(seq(sf(0), bf(1, 0)), sf(2)), + }, + { + name: "nested entity chain", + input: nodes(sf(0), ef(1, 0), ef(2, 1)), + want: seq(sf(0), ef(1, 0), ef(2, 1)), + }, + { + name: "interface expansion", + input: nodes(sf(0), sf(1), sf(2)), + want: par(sf(0), sf(1), sf(2)), + }, + { + name: "provides skips fetch", + input: nodes(sf(0)), + want: sf(0), + }, + { + name: "sequential mutation", + input: nodes(sf(0), sf(1, deps(0)), sf(2, deps(0, 1))), + want: seq(sf(0), sf(1, deps(0)), sf(2, deps(0, 1))), + }, + { + name: "single fetch", + input: nodes(sf(0)), + want: sf(0), + }, + { + name: "cycle", + input: nodes(sf(0, deps(1)), sf(1, deps(0))), + wantError: "cycle detected in fetch dependency graph", + }, + { + name: "composite key fan in", + input: nodes(sf(0), sf(1, deps(0)), sf(2, deps(0, 1))), + want: seq(sf(0), sf(1, deps(0)), sf(2, deps(0, 1))), + }, + { + name: "asymmetric chain merge with leaf", + input: nodes(sf(0), sf(1, deps(0)), sf(2, deps(0)), sf(3, deps(1, 2)), sf(4, deps(2))), + want: seq( + sf(0), + par(sf(1, deps(0)), sf(2, deps(0))), + par(sf(3, deps(1, 2)), sf(4, deps(2))), + ), + inlined: seq( + sf(0), + par( + sf(1, deps(0)), + seq(sf(2, deps(0)), sf(4, deps(2))), + ), + sf(3, deps(1, 2)), + ), + }, + { + name: "deep multi parent fan in", + input: nodes(sf(0), sf(1), sf(2), sf(3, deps(0, 1, 2)), sf(4, deps(3)), sf(5, deps(4))), + want: seq(par(sf(0), sf(1), sf(2)), sf(3, deps(0, 1, 2)), sf(4, deps(3)), sf(5, deps(4))), + }, + { + name: "non inlined n shape", + input: nodes(sf(0), sf(1), sf(2, deps(0)), sf(3, deps(0, 1)), sf(4, deps(1))), + want: seq( + par(sf(0), sf(1)), + par(sf(2, deps(0)), sf(3, deps(0, 1)), sf(4, deps(1))), + ), + inlined: seq( + par( + seq(sf(0), sf(2, deps(0))), + seq(sf(1), sf(4, deps(1))), + ), + sf(3, deps(0, 1)), + ), + }, + { + name: "independent root with shared join", + input: nodes(sf(0), sf(1), sf(2, deps(0, 1)), sf(3)), + want: par( + seq(par(sf(0), sf(1)), sf(2, deps(0, 1))), + sf(3), + ), + }, + { + name: "independent leaf alongside chain", + input: nodes(sf(0), sf(1, deps(0)), sf(2, deps(0, 1)), sf(3, deps(0))), + want: seq( + sf(0), + par( + seq(sf(1, deps(0)), sf(2, deps(0, 1))), + sf(3, deps(0)), + ), + ), + }, + { + name: "incomparable dominance fallback", + input: nodes(sf(0), sf(1), sf(2, deps(1)), sf(3, deps(0, 2)), sf(4, deps(0, 1))), + want: seq( + par(sf(0), sf(1)), + par( + seq(sf(2, deps(1)), sf(3, deps(0, 2))), + sf(4, deps(0, 1)), + ), + ), + inlined: seq( + par(sf(0), seq(sf(1), sf(2, deps(1)))), + par(sf(3, deps(0, 2)), sf(4, deps(0, 1))), + ), + }, + { + name: "chain off a shared join with a generation-skipping edge", + input: nodes(sf(0), sf(1), sf(2, deps(0, 1)), sf(3, deps(2)), sf(4, deps(3, 0))), + want: seq( + par(sf(0), sf(1)), + sf(2, deps(0, 1)), + sf(3, deps(2)), + sf(4, deps(3, 0)), + ), + }, + { + name: "weak components with different shapes have the mixed winner strategies", + input: nodes( + sf(0), sf(1), sf(2, deps(0)), sf(3, deps(0, 1)), sf(4, deps(1)), + sf(5), sf(6), sf(7, deps(5)), sf(8, deps(6)), sf(9, deps(7, 8)), + ), + // 2 weak components: the N component (0..4) keeps its waves tree, + // the chains component (5..9) wins with its inlined tree. + // The mixed winner dominates the all-waves tree. + // 2 ->3 ->4 5->7\ + // ^ / ^ / ->9 + // |/ |/ 6->8/ + // 0 1 + want: par( + seq( + par(sf(0), sf(1)), + par(sf(2, deps(0)), sf(3, deps(0, 1)), sf(4, deps(1))), + ), + seq( + par( + seq(sf(5), sf(7, deps(5))), + seq(sf(6), sf(8, deps(6))), + ), + sf(9, deps(7, 8)), + ), + ), + waves: par( + seq( + par(sf(0), sf(1)), + par(sf(2, deps(0)), sf(3, deps(0, 1)), sf(4, deps(1))), + ), + seq( + par(sf(5), sf(6)), + par(sf(7, deps(5)), sf(8, deps(6))), + sf(9, deps(7, 8)), + ), + ), + inlined: par( + seq( + par( + seq(sf(0), sf(2, deps(0))), + seq(sf(1), sf(4, deps(1))), + ), + sf(3, deps(0, 1)), + ), + seq( + par( + seq(sf(5), sf(7, deps(5))), + seq(sf(6), sf(8, deps(6))), + ), + sf(9, deps(7, 8)), + ), + ), + }, + { + name: "wide fan-out with deeply nested user entity chains", + input: nodes( + sf(0), sf(1, deps(0)), sf(3, deps(0)), sf(5, deps(0)), sf(7, deps(0)), sf(9, deps(0)), + sf(10, deps(0)), sf(14, deps(0)), sf(15, deps(14)), sf(16, deps(14)), sf(17, deps(0)), + sf(18, deps(17)), sf(19, deps(17)), sf(32, deps(0)), sf(33, deps(32)), sf(34, deps(32)), + sf(35, deps(0)), sf(36, deps(35)), sf(37, deps(35)), sf(44, deps(0)), sf(45, deps(44)), + sf(46, deps(44)), sf(47, deps(0)), sf(48, deps(47)), sf(49, deps(47)), sf(56, deps(0)), + sf(57, deps(56)), sf(58, deps(56)), sf(59, deps(0)), sf(60, deps(59)), sf(61, deps(59)), + sf(62, deps(0)), sf(63, deps(62)), sf(64, deps(62)), sf(68, deps(0)), sf(69, deps(68)), + sf(82, deps(68)), sf(83, deps(82)), sf(84, deps(82)), sf(85, deps(68)), sf(86, deps(85)), + sf(87, deps(85)), + ), + want: seq( + sf(0), + par( + sf(1, deps(0)), + sf(3, deps(0)), + sf(5, deps(0)), + sf(7, deps(0)), + sf(9, deps(0)), + sf(10, deps(0)), + seq(sf(14, deps(0)), par(sf(15, deps(14)), sf(16, deps(14)))), + seq(sf(17, deps(0)), par(sf(18, deps(17)), sf(19, deps(17)))), + seq(sf(32, deps(0)), par(sf(33, deps(32)), sf(34, deps(32)))), + seq(sf(35, deps(0)), par(sf(36, deps(35)), sf(37, deps(35)))), + seq(sf(44, deps(0)), par(sf(45, deps(44)), sf(46, deps(44)))), + seq(sf(47, deps(0)), par(sf(48, deps(47)), sf(49, deps(47)))), + seq(sf(56, deps(0)), par(sf(57, deps(56)), sf(58, deps(56)))), + seq(sf(59, deps(0)), par(sf(60, deps(59)), sf(61, deps(59)))), + seq(sf(62, deps(0)), par(sf(63, deps(62)), sf(64, deps(62)))), + seq( + sf(68, deps(0)), + par( + sf(69, deps(68)), + seq( + sf(82, deps(68)), + par(sf(83, deps(82)), sf(84, deps(82)))), + seq( + sf(85, deps(68)), + par(sf(86, deps(85)), sf(87, deps(85)))), + ), + ), + ), + ), + // The legacy wave pipeline: + // seq( + // 0, + // par(1, 3, 5, 7, 9, 10, 14, 17, 32, 35, 44, 47, 56, 59, 62, 68), + // par(15, 16, 18, 19, 33, 34, 36, 37, 45, 46, 48, 49, 57, 58, 60, 61, 63, 64, 69, 82, 85), + // par(83, 84, 86, 87), + // ) + // so fetch 83 waits on all 21 second-wave fetches instead of just 0, 68, 69, 82. + }, + { + name: "mixed depth entity chains", + input: nodes( + sf(0), sf(1, deps(0)), sf(3, deps(0)), sf(5, deps(0)), sf(7, deps(0)), sf(9, deps(0)), + sf(10, deps(0)), sf(14, deps(0)), sf(15, deps(14)), sf(16, deps(14)), sf(17, deps(0)), + sf(18, deps(17)), sf(29, deps(0)), sf(30, deps(29)), sf(31, deps(29)), sf(32, deps(0)), + sf(33, deps(32)), sf(39, deps(0)), sf(40, deps(39)), sf(41, deps(39)), sf(42, deps(0)), + sf(43, deps(42)), sf(49, deps(0)), sf(50, deps(49)), sf(51, deps(49)), sf(52, deps(0)), + sf(53, deps(52)), sf(54, deps(0)), sf(55, deps(54)), sf(59, deps(0)), sf(71, deps(59)), + sf(72, deps(71)), sf(73, deps(71)), sf(74, deps(59)), sf(75, deps(74)), + ), + want: seq( + sf(0), + par( + sf(1, deps(0)), + sf(3, deps(0)), + sf(5, deps(0)), + sf(7, deps(0)), + sf(9, deps(0)), + sf(10, deps(0)), + seq(sf(14, deps(0)), + par(sf(15, deps(14)), + sf(16, deps(14)))), + seq(sf(17, deps(0)), sf(18, deps(17))), + seq(sf(29, deps(0)), par(sf(30, deps(29)), sf(31, deps(29)))), + seq(sf(32, deps(0)), sf(33, deps(32))), + seq(sf(39, deps(0)), par(sf(40, deps(39)), sf(41, deps(39)))), + seq(sf(42, deps(0)), sf(43, deps(42))), + seq(sf(49, deps(0)), par(sf(50, deps(49)), sf(51, deps(49)))), + seq(sf(52, deps(0)), sf(53, deps(52))), + seq(sf(54, deps(0)), sf(55, deps(54))), + seq( + sf(59, deps(0)), + par( + seq( + sf(71, deps(59)), + par( + sf(72, deps(71)), + sf(73, deps(71)), + ), + ), + seq(sf(74, deps(59)), sf(75, deps(74))), + ), + ), + ), + ), + // The legacy wave pipeline: + // seq( + // 0, + // par(1, 3, 5, 7, 9, 10, 14, 17, 29, 32, 39, 42, 49, 52, 54, 59), + // par(15, 16, 18, 30, 31, 33, 40, 41, 43, 50, 51, 53, 55, 71, 74), + // par(72, 73, 75), + // ) + // so fetch 72 waits on all 15 second-wave fetches instead of just 0, 59, 71. + }, + } + + for i, tc := range scenarios { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dag, err := newFetchDAG(tc.input) + require.NoError(t, err) + ids := dag.sortedIDs() + + actualInlined, inlinedErr := schedule(ids, dag, true) + actualWaves, wavesErr := schedule(ids, dag, false) + actualWinner, winnerErr := buildScheduleTree(tc.input, dag) + if tc.wantError != "" { + require.EqualError(t, inlinedErr, tc.wantError) + require.EqualError(t, wavesErr, tc.wantError) + require.EqualError(t, winnerErr, tc.wantError) + return + } + + require.NoError(t, inlinedErr) + require.NoError(t, wavesErr) + require.NoError(t, winnerErr) + require.NoError(t, validateSchedule(actualInlined, dag)) + require.NoError(t, validateSchedule(actualWaves, dag)) + require.NoError(t, validateSchedule(actualWinner, dag)) + + expectedInlined, expectedWaves := tc.inlined, tc.waves + if expectedInlined == nil { + expectedInlined = tc.want + } + if expectedWaves == nil { + expectedWaves = tc.want + } + require.Equal(t, expectedInlined, actualInlined) + require.Equal(t, expectedWaves, actualWaves) + require.Equal(t, tc.want, actualWinner) + + // Beyond the pinned shapes, the trees must satisfy the scheduler + // invariants under randomized duration profiles. + assertScheduleProperties(t, actualInlined, actualWaves, actualWinner, durationProfiles(tc.input, 50, int64(9000+i))) + }) + } +} + +func TestWithScheduleFetches_OptionWiring(t *testing.T) { + t.Parallel() + // Verifies that WithScheduleFetches switches organizeFetchTree from the + // legacy wave scheduler to the schedule-tree scheduler. + input := func() *resolve.FetchTreeNode { + return seq( + sf(0), + sf(1), + sf(2, deps(0)), + sf(3, deps(1)), + sf(4, deps(2, 3))) + } + wantWaves := seq( + par(sf(0), sf(1)), + par(sf(2, deps(0)), + sf(3, deps(1))), + sf(4, deps(2, 3))) + wantScheduled := seq( + par(seq(sf(0), sf(2, deps(0))), + seq(sf(1), sf(3, deps(1)))), + sf(4, deps(2, 3))) + + waves := input() + NewProcessor().fetchTreeProcessors.organizeFetchTree(waves) + require.Equal(t, wantWaves, waves) + + scheduled := input() + NewProcessor(WithScheduleFetches()).fetchTreeProcessors.organizeFetchTree(scheduled) + require.Equal(t, wantScheduled, scheduled) +} + +func TestScheduleFetches_Validator(t *testing.T) { + t.Run("response-path nesting without a FetchID edge is valid", func(t *testing.T) { + y := sf(0, at("user")) + x := sf(1, at("user.details"), provides("user.details")) + tree := par(x, y) + dag, err := newFetchDAG(nodes(x, y)) + require.NoError(t, err) + require.NoError(t, validateSchedule(tree, dag)) + }) + + t.Run("explicit FetchID edge between parallel siblings is invalid", func(t *testing.T) { + y := sf(0) + x := sf(1, deps(0)) + tree := par(x, y) + dag, err := newFetchDAG(nodes(x, y)) + require.NoError(t, err) + require.EqualError(t, validateSchedule(tree, dag), "fetch 1 depends on fetch 0 before it is available") + }) + + t.Run("self-dependency is invalid", func(t *testing.T) { + y := sf(0) + x := sf(1, deps(1)) + _, err := newFetchDAG(nodes(x, y)) + require.EqualError(t, err, "self-dependent id 1") + }) + + // Conservation: a schedule must never lose or duplicate a fetch. The + // property tests lean on these rejections for their completeness checks. + t.Run("schedule missing a fetch is invalid", func(t *testing.T) { + input := nodes(sf(0), sf(1, deps(0)), sf(2, deps(0))) + dag, err := newFetchDAG(input) + require.NoError(t, err) + missing := seq(input[0], input[1]) + require.EqualError(t, validateSchedule(missing, dag), "fetch 2 missing from schedule") + }) + + t.Run("schedule duplicating a fetch is invalid", func(t *testing.T) { + input := nodes(sf(0), sf(1, deps(0)), sf(2, deps(0))) + dag, err := newFetchDAG(input) + require.NoError(t, err) + duplicated := seq(input[0], par(input[1], input[2]), input[1]) + require.EqualError(t, validateSchedule(duplicated, dag), "fetch 1 scheduled 2 times") + }) +} + +func TestScheduleFetches_ProcessorFallsBackOnError(t *testing.T) { + t.Parallel() + // on any scheduler/validator error the processor degrades to the LEGACY wave pipeline. + build := func() *resolve.FetchTreeNode { + return seq(sf(7), sf(7, deps(7))) + } + + legacy := build() + (&orderSequenceByDependencies{}).ProcessFetchTree(legacy) + (&createParallelNodes{}).ProcessFetchTree(legacy) + + scheduled := build() + require.NotPanics(t, func() { + (&scheduleFetches{}).ProcessFetchTree(scheduled) + }) + require.Equal(t, legacy, scheduled) +} + +// TestScheduleFetches_BigPlan pins the schedule for a realistic 13-fetch plan +func TestScheduleFetches_BigPlan(t *testing.T) { + // Fetch IDs and order preserve the plan-generator's emission order. + input := func() []*resolve.FetchTreeNode { + return nodes( + sf(0), + sf(5), + sf(1, at("users"), deps(0)), + sf(6, at("topProducts"), deps(5)), + sf(11, at("topProducts"), deps(5)), + sf(2, at("users.@.reviews.@.product"), deps(1)), + sf(3, at("users.@.reviews.@.product.reviews.@.author"), deps(1)), + sf(4, at("users.@.reviews.@.product.reviews.@.author.reviews.@.product"), deps(1)), + sf(7, at("topProducts.@.reviews.@.author"), deps(6)), + sf(8, at("topProducts.@.reviews.@.author.reviews.@.product"), deps(6)), + sf(9, at("users.@.reviews.@.product"), deps(1, 2)), + sf(10, at("users.@.reviews.@.product.reviews.@.author.reviews.@.product"), deps(1, 4)), + sf(12, at("topProducts.@.reviews.@.author.reviews.@.product"), deps(6, 8)), + ) + } + + expected := par( + seq( + sf(0), + sf(1, at("users"), deps(0)), + par( + seq( + sf(2, at("users.@.reviews.@.product"), deps(1)), + sf(9, at("users.@.reviews.@.product"), deps(1, 2)), + ), + sf(3, at("users.@.reviews.@.product.reviews.@.author"), deps(1)), + seq( + sf(4, at("users.@.reviews.@.product.reviews.@.author.reviews.@.product"), deps(1)), + sf(10, at("users.@.reviews.@.product.reviews.@.author.reviews.@.product"), deps(1, 4)), + ), + ), + ), + seq( + sf(5), + par( + seq( + sf(6, at("topProducts"), deps(5)), + par( + sf(7, at("topProducts.@.reviews.@.author"), deps(6)), + seq( + sf(8, at("topProducts.@.reviews.@.author.reviews.@.product"), deps(6)), + sf(12, at("topProducts.@.reviews.@.author.reviews.@.product"), deps(6, 8)), + ), + ), + ), + sf(11, at("topProducts"), deps(5)), + ), + ), + ) + + t.Run("scheduler produces the tree without errors", func(t *testing.T) { + fetches := input() + dag, err := newFetchDAG(fetches) + require.NoError(t, err) + tree, err := buildScheduleTree(fetches, dag) + require.NoError(t, err) + require.NoError(t, validateSchedule(tree, dag)) + require.Equal(t, expected, tree) + }) + + t.Run("scheduler does not fall back to legacy waves", func(t *testing.T) { + root := seq(input()...) + (&scheduleFetches{}).ProcessFetchTree(root) + require.Equal(t, expected, root) + }) +} + +func (d *fetchDAG) sortedIDs() []int { + ids := make([]int, 0, len(d.nodes)) + for id := range d.nodes { + ids = append(ids, id) + } + slices.Sort(ids) + return ids +} + +// hasCycle runs Kahn's algorithm over the full DAG. +func (d *fetchDAG) hasCycle() bool { + indegree := make(map[int]int, len(d.nodes)) + queue := make([]int, 0, len(d.nodes)) + for id, parents := range d.parents { + indegree[id] = len(parents) + if len(parents) == 0 { + queue = append(queue, id) + } + } + scheduled := 0 + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + scheduled++ + for child := range d.children[id] { + if indegree[child]--; indegree[child] == 0 { + queue = append(queue, child) + } + } + } + return scheduled != len(d.nodes) +} + +func TestScheduleFetchesPropertiesRandom(t *testing.T) { + t.Parallel() + rng := rand.New(rand.NewSource(42)) + for n := 3; n <= 15; n++ { + for i := range 50 { + input := randomDAG(n, 1.5, rng) + checkScheduleProperties(t, input, durationProfiles(input, 50, int64(n*1000+i))) + } + } +} + +func TestScheduleFetchesPropertiesSmoke(t *testing.T) { + t.Parallel() + rng := rand.New(rand.NewSource(99)) + for _, n := range []int{50, 500} { + input := randomDAG(n, 1.5, rng) + checkScheduleProperties(t, input, durationProfiles(input, 50, int64(n))) + } +} + +func checkScheduleProperties(t *testing.T, input []*resolve.FetchTreeNode, profiles []map[int]int) { + t.Helper() + dag, err := newFetchDAG(input) + require.NoError(t, err) + ids := dag.sortedIDs() + inlined, inlinedErr := schedule(ids, dag, true) + waves, wavesErr := schedule(ids, dag, false) + winner, winnerErr := buildScheduleTree(input, dag) + if len(ids) > 0 && dag.hasCycle() { + require.EqualError(t, inlinedErr, "cycle detected in fetch dependency graph") + require.EqualError(t, wavesErr, "cycle detected in fetch dependency graph") + require.EqualError(t, winnerErr, "cycle detected in fetch dependency graph") + return + } + require.NoErrorf(t, inlinedErr, "input=%v", dependencyList(input)) + require.NoErrorf(t, wavesErr, "input=%v", dependencyList(input)) + require.NoErrorf(t, winnerErr, "input=%v", dependencyList(input)) + // validateSchedule also enforces conservation: every fetch must be scheduled exactly once: + require.NoError(t, validateSchedule(inlined, dag)) + require.NoError(t, validateSchedule(waves, dag)) + require.NoError(t, validateSchedule(winner, dag)) + + assertScheduleProperties(t, inlined, waves, winner, profiles) +} + +// assertScheduleProperties checks the invariants relating the three trees: +// the winner always dominates the waves tree, +// when the inlined tree dominates the waves tree, the winner is exactly the inlined tree. +func assertScheduleProperties(t *testing.T, inlined, waves, winner *resolve.FetchTreeNode, profiles []map[int]int) { + t.Helper() + require.True(t, dominates(winner, waves)) + if dominates(inlined, waves) { + require.Equal(t, inlined, winner) + } + require.LessOrEqual(t, uniformMakespan(winner), uniformMakespan(waves)) + for _, durations := range profiles { + require.LessOrEqual(t, weightedMakespan(winner, durations), weightedMakespan(waves, durations)) + } +} + +func dependencyList(input []*resolve.FetchTreeNode) []resolve.FetchDependencies { + out := make([]resolve.FetchDependencies, 0, len(input)) + for _, node := range input { + out = append(out, *node.Item.Fetch.Dependencies()) + } + return out +} + +func randomDAG(n int, averageDegree float64, rng *rand.Rand) []*resolve.FetchTreeNode { + depLists := make([][]int, n) + p := averageDegree / math.Max(1, float64(n-1)) + for from := range n { + for to := from + 1; to < n; to++ { + if rng.Float64() < p { + depLists[to] = append(depLists[to], from) + } + } + } + out := make([]*resolve.FetchTreeNode, n) + for i := range out { + out[i] = sf(i, deps(depLists[i]...)) + } + return out +} + +func durationProfiles(input []*resolve.FetchTreeNode, count int, seed int64) []map[int]int { + rng := rand.New(rand.NewSource(seed)) + profiles := make([]map[int]int, count) + for i := range profiles { + profile := make(map[int]int, len(input)) + for _, node := range input { + id := node.Item.Fetch.Dependencies().FetchID + profile[id] = max(int(math.Round(math.Exp(rng.Float64()*math.Log(1000)))), 1) + } + profiles[i] = profile + } + return profiles +} + +// uniformMakespan is the tree makespan with every fetch cost 1. +func uniformMakespan(node *resolve.FetchTreeNode) int { + durations := map[int]int{} + for id := range treePredecessors(node) { + durations[id] = 1 + } + return weightedMakespan(node, durations) +} + +// weightedMakespan is the critical-path weight of the tree under durations: +// sequences add, parallels take the maximum. +func weightedMakespan(node *resolve.FetchTreeNode, durations map[int]int) int { + if node == nil { + return 0 + } + switch node.Kind { + case resolve.FetchTreeNodeKindSingle: + return durations[node.Item.Fetch.Dependencies().FetchID] + case resolve.FetchTreeNodeKindSequence: + sum := 0 + for _, child := range node.ChildNodes { + sum += weightedMakespan(child, durations) + } + return sum + case resolve.FetchTreeNodeKindParallel: + max := 0 + for _, child := range node.ChildNodes { + if m := weightedMakespan(child, durations); m > max { + max = m + } + } + return max + default: + return 0 + } +} diff --git a/v2/pkg/engine/postprocess/util_test.go b/v2/pkg/engine/postprocess/util_test.go new file mode 100644 index 0000000000..cf99ef8dd2 --- /dev/null +++ b/v2/pkg/engine/postprocess/util_test.go @@ -0,0 +1,68 @@ +package postprocess + +import ( + "strings" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +// Various test helpers to reduce clutter in tests. + +func nodes(items ...*resolve.FetchTreeNode) []*resolve.FetchTreeNode { + return items +} + +// sfOpt configures optional fields of a single-fetch node built by sf. +type sfOpt func(node *resolve.FetchTreeNode) + +// deps sets the fetch IDs the fetch depends on. +func deps(ids ...int) sfOpt { + return func(node *resolve.FetchTreeNode) { + node.Item.Fetch.(*resolve.SingleFetch).FetchDependencies.DependsOnFetchIDs = ids + } +} + +// at nests the fetch at responsePath, e.g. "a.b" becomes ObjectPath("a"), ObjectPath("b"). +func at(responsePath string) sfOpt { + return func(node *resolve.FetchTreeNode) { + segments := strings.Split(responsePath, ".") + path := make([]resolve.FetchItemPathElement, len(segments)) + for i, segment := range segments { + path[i] = resolve.ObjectPath(segment) + } + node.Item.FetchPath = path + node.Item.ResponsePath = responsePath + node.Item.ResponsePathElements = segments + } +} + +// provides sets the merge path the fetch provides in the response. +func provides(mergePath ...string) sfOpt { + return func(node *resolve.FetchTreeNode) { + node.Item.Fetch.(*resolve.SingleFetch).PostProcessing.MergePath = mergePath + } +} + +func sf(id int, opts ...sfOpt) *resolve.FetchTreeNode { + node := resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: id}}) + for _, opt := range opts { + opt(node) + } + return node +} + +func ef(id int, deps ...int) *resolve.FetchTreeNode { + return resolve.Single(&resolve.EntityFetch{FetchDependencies: resolve.FetchDependencies{FetchID: id, DependsOnFetchIDs: deps}}) +} + +func bf(id int, deps ...int) *resolve.FetchTreeNode { + return resolve.Single(&resolve.BatchEntityFetch{FetchDependencies: resolve.FetchDependencies{FetchID: id, DependsOnFetchIDs: deps}}) +} + +func seq(children ...*resolve.FetchTreeNode) *resolve.FetchTreeNode { + return resolve.Sequence(children...) +} + +func par(children ...*resolve.FetchTreeNode) *resolve.FetchTreeNode { + return resolve.Parallel(children...) +} From 06fea5e75b8e0dd619dbd9fbc2d4a61b4e90ca72 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:43:25 +0300 Subject: [PATCH 02/24] small fixes --- v2/pkg/engine/postprocess/schedule_fetches.go | 13 ++++++++----- v2/pkg/engine/postprocess/schedule_fetches_test.go | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/v2/pkg/engine/postprocess/schedule_fetches.go b/v2/pkg/engine/postprocess/schedule_fetches.go index ea9a3fb262..8628e09b76 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches.go +++ b/v2/pkg/engine/postprocess/schedule_fetches.go @@ -1,6 +1,7 @@ package postprocess import ( + "cmp" "fmt" "math" "slices" @@ -333,13 +334,16 @@ func validateSchedule(root *resolve.FetchTreeNode, dag *fetchDAG) error { switch node.Kind { case resolve.FetchTreeNodeKindSingle: id := node.Item.Fetch.Dependencies().FetchID + if _, ok := dag.nodes[id]; !ok { + return nil, fmt.Errorf("fetch %d not found in dag", id) + } seen[id]++ for _, dep := range node.Item.Fetch.Dependencies().DependsOnFetchIDs { if _, known := dag.nodes[dep]; !known { continue } if _, ok := before[dep]; !ok { - return nil, fmt.Errorf("fetch %d depends on fetch %d before it is available", id, dep) + return nil, fmt.Errorf("fetch %d is scheduled before its dependency %d completes", id, dep) } } return []int{id}, nil @@ -426,9 +430,8 @@ func weaklyConnectedComponents(nodes []int, dag *fetchDAG) [][]int { return components } -// sequenceOf and parallelOf normalize child lists into fresh slices: nils -// dropped, same-kind children spliced inline, singleton unwrapped. They never -// write into the input slice. +// sequenceOf and parallelOf normalize child lists into fresh slices: +// nils dropped, same-kind children spliced inline, singleton unwrapped. func sequenceOf(children []*resolve.FetchTreeNode) *resolve.FetchTreeNode { return combineOf(resolve.FetchTreeNodeKindSequence, children) } @@ -456,7 +459,7 @@ func combineOf(kind resolve.FetchTreeNodeKind, children []*resolve.FetchTreeNode } if kind == resolve.FetchTreeNodeKindParallel { slices.SortFunc(out, func(a, b *resolve.FetchTreeNode) int { - return minReachableFetchID(a) - minReachableFetchID(b) + return cmp.Compare(minReachableFetchID(a), minReachableFetchID(b)) }) return resolve.Parallel(out...) } diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index 079aad01cb..c87544ad4e 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -487,7 +487,7 @@ func TestScheduleFetches_Validator(t *testing.T) { tree := par(x, y) dag, err := newFetchDAG(nodes(x, y)) require.NoError(t, err) - require.EqualError(t, validateSchedule(tree, dag), "fetch 1 depends on fetch 0 before it is available") + require.EqualError(t, validateSchedule(tree, dag), "fetch 1 is scheduled before its dependency 0 completes") }) t.Run("self-dependency is invalid", func(t *testing.T) { From 803dad1218f588823a00b242517b43efe1916649 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:02:10 +0300 Subject: [PATCH 03/24] rename vars --- v2/pkg/engine/postprocess/schedule_fetches.go | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/v2/pkg/engine/postprocess/schedule_fetches.go b/v2/pkg/engine/postprocess/schedule_fetches.go index 8628e09b76..19700a305e 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches.go +++ b/v2/pkg/engine/postprocess/schedule_fetches.go @@ -208,23 +208,23 @@ func hasParentIn(dag *fetchDAG, id int, set map[int]struct{}) bool { // exclusiveDescendants returns the descendants within inSet that are reachable only through root: // every member's parent in inSet is root or another member. -// Such fetches cannot start before root finishes, -// inlining them into root's branch never delays them and frees them from waiting on sibling roots. -// Members are recorded in claimed, which keeps them out of the caller's rest set. -func exclusiveDescendants(root int, dag *fetchDAG, inSet, claimed map[int]struct{}) []int { +// Such fetches cannot start before root finishes, inlining them into root's branch never delays +// them and frees them from waiting on sibling roots. +// Fetches returned are also recorded in scheduled. +func exclusiveDescendants(root int, dag *fetchDAG, inSet, scheduled map[int]struct{}) []int { member := map[int]struct{}{root: {}} out := make([]int, 0, len(dag.children[root])) - queue := asSortedSlice(dag.children[root]) + queue := asSlice(dag.children[root]) for len(queue) > 0 { id := queue[0] queue = queue[1:] - if _, ok := member[id]; ok { + if _, ok := inSet[id]; !ok { continue } - if _, ok := inSet[id]; !ok { + if _, ok := member[id]; ok { continue } - if _, ok := claimed[id]; ok { + if _, ok := scheduled[id]; ok { continue } exclusive := true @@ -241,9 +241,9 @@ func exclusiveDescendants(root int, dag *fetchDAG, inSet, claimed map[int]struct continue // revisited via the queue if its parents join later } member[id] = struct{}{} - claimed[id] = struct{}{} + scheduled[id] = struct{}{} out = append(out, id) - queue = append(queue, asSortedSlice(dag.children[id])...) + queue = append(queue, asSlice(dag.children[id])...) } return out } @@ -491,12 +491,11 @@ func sortedUnique(ids []int) []int { return slices.Compact(out) } -func asSortedSlice(set map[int]struct{}) []int { +func asSlice(set map[int]struct{}) []int { out := make([]int, 0, len(set)) for id := range set { out = append(out, id) } - slices.Sort(out) return out } From 6f59fd406d8e4ec529c35f6161456bf5a952d8bd Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:49:09 +0300 Subject: [PATCH 04/24] color all exclusives at once to reduce complexity --- v2/pkg/engine/postprocess/schedule_fetches.go | 114 +++++++++--------- 1 file changed, 58 insertions(+), 56 deletions(-) diff --git a/v2/pkg/engine/postprocess/schedule_fetches.go b/v2/pkg/engine/postprocess/schedule_fetches.go index 19700a305e..327f80ce64 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches.go +++ b/v2/pkg/engine/postprocess/schedule_fetches.go @@ -155,7 +155,7 @@ func schedule(set []int, dag *fetchDAG, inline bool) (*resolve.FetchTreeNode, er } return parallelOf(branches), nil } - // Find the ready fetches: + // Find the ready fetches. // Roots have no parents in inSet; their dependencies outside inSet complete // before this subtree starts, so they are ready to run as the first wave. inSet := asMap(sortedSet) @@ -168,22 +168,35 @@ func schedule(set []int, dag *fetchDAG, inline bool) (*resolve.FetchTreeNode, er if len(roots) == 0 { return nil, fmt.Errorf("cycle detected in fetch dependency graph") } + + // A fetch reachable from exactly one root cannot start before that root finishes, + // so inlining it into the root's branch never delays it and frees it from waiting on sibling roots. scheduled := asMap(roots) + // exclusive maps each root to the fetches reachable only from that root. + exclusive := map[int][]int{} + if inline { + for id, root := range colorExclusive(roots, dag, inSet) { + if root == sharedByRoots { + // Fetch stays in the caller's rest set. + continue + } + exclusive[root] = append(exclusive[root], id) + scheduled[id] = struct{}{} + } + } branches := make([]*resolve.FetchTreeNode, 0, len(roots)) for _, root := range roots { branch := dag.nodes[root] - if inline { - exclusive := exclusiveDescendants(root, dag, inSet, scheduled) - if len(exclusive) > 0 { - subtree, err := schedule(exclusive, dag, inline) - if err != nil { - return nil, err - } - branch = sequenceOf([]*resolve.FetchTreeNode{branch, subtree}) + if members := exclusive[root]; len(members) > 0 { + subtree, err := schedule(members, dag, inline) + if err != nil { + return nil, err } + branch = sequenceOf([]*resolve.FetchTreeNode{branch, subtree}) } branches = append(branches, branch) } + rest := make([]int, 0, len(sortedSet)-len(scheduled)) for _, id := range sortedSet { if _, ok := scheduled[id]; !ok { @@ -206,58 +219,55 @@ func hasParentIn(dag *fetchDAG, id int, set map[int]struct{}) bool { return false } -// exclusiveDescendants returns the descendants within inSet that are reachable only through root: -// every member's parent in inSet is root or another member. -// Such fetches cannot start before root finishes, inlining them into root's branch never delays -// them and frees them from waiting on sibling roots. -// Fetches returned are also recorded in scheduled. -func exclusiveDescendants(root int, dag *fetchDAG, inSet, scheduled map[int]struct{}) []int { - member := map[int]struct{}{root: {}} - out := make([]int, 0, len(dag.children[root])) - queue := asSlice(dag.children[root]) +// sharedByRoots marks a fetch reachable from more than one root. Fetch IDs are non-negative. +const sharedByRoots = -1 + +// colorExclusive labels every fetch of the component reachable from a root with that root's id, +// or with sharedByRoots when several roots reach it. +// It returns the mapping from fetch ID to the single root it's reachable from, or sharedByRoots. +func colorExclusive(roots []int, dag *fetchDAG, inSet map[int]struct{}) map[int]int { + type item struct { + id, color int + } + queue := make([]item, 0, len(inSet)) + for _, root := range roots { + for child := range dag.children[root] { + queue = append(queue, item{child, root}) + } + } + color := make(map[int]int, len(inSet)) for len(queue) > 0 { - id := queue[0] + next := queue[0] queue = queue[1:] - if _, ok := inSet[id]; !ok { - continue - } - if _, ok := member[id]; ok { + if _, ok := inSet[next.id]; !ok { + // A shared dependant of an enclosing component; its "rest" set runs after this subtree. continue } - if _, ok := scheduled[id]; ok { + current, seen := color[next.id] + if seen && (current == next.color || current == sharedByRoots) { continue } - exclusive := true - for parent := range dag.parents[id] { - if _, in := inSet[parent]; !in { - continue // satisfied by an enclosing recursion step - } - if _, ok := member[parent]; !ok { - exclusive = false - break - } + if seen { + next.color = sharedByRoots } - if !exclusive { - continue // revisited via the queue if its parents join later + color[next.id] = next.color + for child := range dag.children[next.id] { + queue = append(queue, item{child, next.color}) } - member[id] = struct{}{} - scheduled[id] = struct{}{} - out = append(out, id) - queue = append(queue, asSlice(dag.children[id])...) } - return out + return color } -// dominates checks whether tree a is never slower than tree b, no matter how long each fetch takes. +// dominates checks whether treeA is never slower than treeB, no matter how long each fetch takes. // -// The test: every fetch's predecessor set in a must be a subset of its predecessor set in b. -// The fetches on any critical path of a are pairwise ordered in a, hence also in b, so they form -// a chain in b and b's makespan is at least that chain's weight — a's makespan. +// The test: every fetch's predecessor set in A must be a subset of its predecessor set in B. +// The fetches on any critical path of A are pairwise ordered in A, hence also in B, +// so they form a chain in B and B's makespan is at least that chain's weight — a's makespan. // -// The test is also exact, not just sufficient: given any extra predecessor pair in a, -// there are fetch durations for which a is strictly slower. -func dominates(a, b *resolve.FetchTreeNode) bool { - predA, predB := treePredecessors(a), treePredecessors(b) +// The test is also exact, not just sufficient: given any extra predecessor pair in A, +// there are fetch durations for which A is strictly slower. +func dominates(treeA, treeB *resolve.FetchTreeNode) bool { + predA, predB := treePredecessors(treeA), treePredecessors(treeB) if len(predA) != len(predB) { return false } @@ -491,14 +501,6 @@ func sortedUnique(ids []int) []int { return slices.Compact(out) } -func asSlice(set map[int]struct{}) []int { - out := make([]int, 0, len(set)) - for id := range set { - out = append(out, id) - } - return out -} - func asMap(ids []int) map[int]struct{} { set := make(map[int]struct{}, len(ids)) for _, id := range ids { From 3f4dbad54753bc844915f4d7545e4dff9c753e82 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:12:16 +0300 Subject: [PATCH 05/24] add 2 tests with end joins to showoff inlining --- .../postprocess/schedule_fetches_test.go | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index c87544ad4e..669cba769b 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -396,6 +396,114 @@ func TestScheduleFetches_Scenarios(t *testing.T) { // ) // so fetch 72 waits on all 15 second-wave fetches instead of just 0, 59, 71. }, + { + name: "inlining wins on exclusive chain beside a late join", + input: nodes( + sf(0), + sf(1, deps(0)), + sf(2, deps(0)), + sf(3, deps(2)), + sf(4, deps(2)), + sf(5, deps(0, 1, 2, 3, 4)), + sf(6, deps(0, 5)), + ), + want: seq( + sf(0), + par( + sf(1, deps(0)), + seq( + sf(2, deps(0)), + par(sf(3, deps(2)), sf(4, deps(2))), + ), + ), + sf(5, deps(0, 1, 2, 3, 4)), + sf(6, deps(0, 5)), + ), + waves: seq( + sf(0), + par(sf(1, deps(0)), sf(2, deps(0))), + par(sf(3, deps(2)), sf(4, deps(2))), + sf(5, deps(0, 1, 2, 3, 4)), + sf(6, deps(0, 5)), + ), + // The legacy wave pipeline: + // seq( + // 0, + // par(1, 2), + // par(3, 4), + // 5, + // 6, + // ) + }, + { + name: "inlining wins on independent chains gathered by ending joins", + input: nodes( + sf(0), + sf(1, deps(0)), sf(2, deps(0)), sf(3, deps(0)), sf(4, deps(0)), + sf(5, deps(4)), sf(6, deps(4)), sf(7, deps(4)), + sf(12, deps(0)), + sf(15, deps(2)), + sf(16, deps(15)), sf(17, deps(15)), + sf(18, deps(0)), + sf(19, deps(18)), sf(20, deps(18)), + sf(21, deps(4)), + sf(24, deps(15)), + sf(25, deps(18)), + sf(26, deps(0, 2, 3, 5, 6, 12, 15, 16, 17, 18, 19, 20, 21, 24, 25)), + sf(27, deps(0, 4, 4, 5, 5, 6, 6, 7, 7, 21, 21)), + sf(28, deps(0, 1, 18, 26, 27)), + ), + want: seq( + sf(0), + par( + sf(1, deps(0)), + seq( + sf(2, deps(0)), + sf(15, deps(2)), + par(sf(16, deps(15)), sf(17, deps(15)), sf(24, deps(15))), + ), + sf(3, deps(0)), + seq( + sf(4, deps(0)), + par(sf(5, deps(4)), sf(6, deps(4)), sf(7, deps(4)), sf(21, deps(4))), + sf(27, deps(0, 4, 4, 5, 5, 6, 6, 7, 7, 21, 21)), + ), + sf(12, deps(0)), + seq( + sf(18, deps(0)), + par(sf(19, deps(18)), sf(20, deps(18)), sf(25, deps(18))), + ), + ), + sf(26, deps(0, 2, 3, 5, 6, 12, 15, 16, 17, 18, 19, 20, 21, 24, 25)), + sf(28, deps(0, 1, 18, 26, 27)), + ), + waves: seq( + sf(0), + par( + sf(1, deps(0)), sf(2, deps(0)), sf(3, deps(0)), + sf(4, deps(0)), sf(12, deps(0)), sf(18, deps(0)), + ), + par( + sf(5, deps(4)), sf(6, deps(4)), sf(7, deps(4)), sf(15, deps(2)), + sf(19, deps(18)), sf(20, deps(18)), sf(21, deps(4)), sf(25, deps(18)), + ), + par( + sf(16, deps(15)), sf(17, deps(15)), sf(24, deps(15)), + sf(27, deps(0, 4, 4, 5, 5, 6, 6, 7, 7, 21, 21)), + ), + sf(26, deps(0, 2, 3, 5, 6, 12, 15, 16, 17, 18, 19, 20, 21, 24, 25)), + sf(28, deps(0, 1, 18, 26, 27)), + ), + // The legacy wave pipeline: + // seq( + // 0, + // par(1, 2, 3, 4, 12, 18), + // par(5, 6, 7, 15, 19, 20, 21, 25), + // par(16, 17, 24, 27), + // 26, + // 28, + // ) + }, } for i, tc := range scenarios { From ddbb767461636b7c22cee9f358876e064bec5f66 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:21:07 +0300 Subject: [PATCH 06/24] comments --- v2/pkg/engine/postprocess/schedule_fetches_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index 669cba769b..f027126733 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -16,10 +16,10 @@ func TestScheduleFetches_Scenarios(t *testing.T) { type scenario struct { name string input []*resolve.FetchTreeNode - inlined *resolve.FetchTreeNode - waves *resolve.FetchTreeNode - want *resolve.FetchTreeNode // the winner wantError string + want *resolve.FetchTreeNode // the winner + inlined *resolve.FetchTreeNode // specify when it's not equal to winner + waves *resolve.FetchTreeNode // specify when it's not equal to winner } scenarios := []scenario{ From d0d3ad234899826e8957b7dd94853dc8a20537cb Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:54:57 +0300 Subject: [PATCH 07/24] enable by default # Conflicts: # v2/pkg/engine/postprocess/postprocess.go --- .../graphql_datasource_defer_test.go | 4 +- ...ource_federation_entity_interfaces_test.go | 12 +++--- .../graphql_datasource_federation_test.go | 8 ++-- .../datasourcetesting/datasourcetesting.go | 7 +++- v2/pkg/engine/postprocess/postprocess.go | 11 ++--- v2/pkg/engine/postprocess/postprocess_test.go | 2 + .../resolve_input_templates_test.go | 17 +++++++- v2/pkg/engine/postprocess/schedule_fetches.go | 6 +++ .../postprocess/schedule_fetches_test.go | 41 +++++++++++++++---- 9 files changed, 80 insertions(+), 28 deletions(-) diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go index 5d0c2e084c..a6aeb9adb9 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_defer_test.go @@ -168,7 +168,7 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }, }, planConfiguration, - WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields(), postprocess.DisableExtractDeferFetches()), + WithPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields(), postprocess.DisableExtractDeferFetches()), WithDefer(), WithCalculateFieldDependencies(), ) @@ -746,7 +746,7 @@ func TestGraphQLDataSourceDefer(t *testing.T) { }, }, planConfiguration, - WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields(), postprocess.DisableExtractDeferFetches()), + WithPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields(), postprocess.DisableExtractDeferFetches()), WithDefer(), WithCalculateFieldDependencies(), ) diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_entity_interfaces_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_entity_interfaces_test.go index ad3df31e65..ec566660e1 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_entity_interfaces_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_entity_interfaces_test.go @@ -4254,7 +4254,7 @@ func TestGraphQLDataSourceFederationEntityInterfaces(t *testing.T) { }, }, planConfiguration, - WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates()), + WithPostProcessor(postprocess.DisableResolveInputTemplates()), )) }) @@ -4554,7 +4554,7 @@ func TestGraphQLDataSourceFederationEntityInterfaces(t *testing.T) { }, }, planConfiguration, - WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates()), + WithPostProcessor(postprocess.DisableResolveInputTemplates()), )) }) @@ -4924,7 +4924,7 @@ func TestGraphQLDataSourceFederationEntityInterfaces(t *testing.T) { }, }, planConfiguration, - WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates()), + WithPostProcessor(postprocess.DisableResolveInputTemplates()), // WithPrintPlan(), )) }) @@ -5126,7 +5126,7 @@ func TestGraphQLDataSourceFederationEntityInterfaces(t *testing.T) { }, }, planConfiguration, - WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates()), + WithPostProcessor(postprocess.DisableResolveInputTemplates()), // WithPrintPlan(), )) }) @@ -5199,7 +5199,7 @@ func TestGraphQLDataSourceFederationEntityInterfaces(t *testing.T) { }, }, planConfiguration, - WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates()), + WithPostProcessor(postprocess.DisableResolveInputTemplates()), // WithPrintPlan(), )) }) @@ -5307,7 +5307,7 @@ func TestGraphQLDataSourceFederationEntityInterfaces(t *testing.T) { }, }, planConfiguration, - WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates()), + WithPostProcessor(postprocess.DisableResolveInputTemplates()), // WithPrintPlan(), )) }) diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go index 1651f45feb..a302a402a0 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_federation_test.go @@ -1775,7 +1775,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) { planConfiguration, WithFieldInfo(), // default post-processor options, but with authorization coordinate collection enabled - WithDefaultCustomPostProcessor( + WithPostProcessor( postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableCreateParallelNodes(), @@ -16439,7 +16439,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) { "Query", expectedPlans, planConfiguration, - WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableOrderSequenceByDependencies(), postprocess.DisableMergeFields()), + WithPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableOrderSequenceByDependencies(), postprocess.DisableMergeFields()), ) }) @@ -16573,7 +16573,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) { "Query", expectedPlan, planConfiguration, - WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableOrderSequenceByDependencies(), postprocess.DisableMergeFields()), + WithPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableOrderSequenceByDependencies(), postprocess.DisableMergeFields()), ) }) @@ -16706,7 +16706,7 @@ func TestGraphQLDataSourceFederation(t *testing.T) { "Query", expectedPlan, planConfiguration, - WithDefaultCustomPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableOrderSequenceByDependencies(), postprocess.DisableMergeFields()), + WithPostProcessor(postprocess.DisableResolveInputTemplates(), postprocess.DisableCreateConcreteSingleFetchTypes(), postprocess.DisableOrderSequenceByDependencies(), postprocess.DisableMergeFields()), ) }) }) diff --git a/v2/pkg/engine/datasourcetesting/datasourcetesting.go b/v2/pkg/engine/datasourcetesting/datasourcetesting.go index 08147d7d02..b1fed049e1 100644 --- a/v2/pkg/engine/datasourcetesting/datasourcetesting.go +++ b/v2/pkg/engine/datasourcetesting/datasourcetesting.go @@ -57,13 +57,16 @@ func WithDefaultPostProcessor() func(*testOptions) { postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields(), postprocess.DisableCollectAuthorizationCoordinates(), + // plan fixtures pin flat dependency-ordered trees, not schedule trees + postprocess.DisableScheduleFetches(), ) } } -func WithDefaultCustomPostProcessor(options ...postprocess.ProcessorOption) func(*testOptions) { - // TODO: rename to WithPostProcessor +func WithPostProcessor(options ...postprocess.ProcessorOption) func(*testOptions) { return func(o *testOptions) { + // plan fixtures pin flat dependency-ordered trees, not schedule trees + options = append(options, postprocess.DisableScheduleFetches()) o.postProcessor = postprocess.NewProcessor(options...) } } diff --git a/v2/pkg/engine/postprocess/postprocess.go b/v2/pkg/engine/postprocess/postprocess.go index 056f32c803..54e5a32fbd 100644 --- a/v2/pkg/engine/postprocess/postprocess.go +++ b/v2/pkg/engine/postprocess/postprocess.go @@ -98,13 +98,14 @@ type processorOptions struct { disableCollectAuthorizationCoordinates bool enableMultiFetch bool scheduleFetches bool + disableScheduleFetches bool } -// WithScheduleFetches replaces the orderSequenceByDependencies and -// createParallelNodes pair with the nested schedule-tree scheduler. -func WithScheduleFetches() ProcessorOption { +// DisableScheduleFetches replaces the nested schedule-tree scheduler with the +// legacy orderSequenceByDependencies and createParallelNodes pair. +func DisableScheduleFetches() ProcessorOption { return func(o *processorOptions) { - o.scheduleFetches = true + o.disableScheduleFetches = true } } @@ -231,7 +232,7 @@ func NewProcessor(options ...ProcessorOption) *Processor { disable: opts.disableCreateParallelNodes, }, scheduleFetches: &scheduleFetches{ - disable: !opts.scheduleFetches, + disable: opts.disableScheduleFetches, }, }, responseTreeProcessors: &ResponseTreeProcessors{ diff --git a/v2/pkg/engine/postprocess/postprocess_test.go b/v2/pkg/engine/postprocess/postprocess_test.go index 6395a2262f..22ecfdb7bf 100644 --- a/v2/pkg/engine/postprocess/postprocess_test.go +++ b/v2/pkg/engine/postprocess/postprocess_test.go @@ -364,6 +364,7 @@ func TestProcess_ExtractFetches(t *testing.T) { DisableMergeFields(), DisableCreateParallelNodes(), DisableAddMissingNestedDependencies(), + DisableScheduleFetches(), ) for _, c := range cases { @@ -730,6 +731,7 @@ func TestProcess_ExtractServiceNames(t *testing.T) { DisableCreateParallelNodes(), DisableAddMissingNestedDependencies(), CollectDataSourceInfo(), + DisableScheduleFetches(), ) for _, c := range cases { diff --git a/v2/pkg/engine/postprocess/resolve_input_templates_test.go b/v2/pkg/engine/postprocess/resolve_input_templates_test.go index 4c0f5973dd..e828cdbc54 100644 --- a/v2/pkg/engine/postprocess/resolve_input_templates_test.go +++ b/v2/pkg/engine/postprocess/resolve_input_templates_test.go @@ -342,7 +342,14 @@ func TestDataSourceInput_Process(t *testing.T) { }, } - processor := NewProcessor(DisableMergeFields(), DisableDeduplicateSingleFetches(), DisableCreateConcreteSingleFetchTypes(), DisableCreateParallelNodes(), DisableAddMissingNestedDependencies()) + processor := NewProcessor( + DisableMergeFields(), + DisableDeduplicateSingleFetches(), + DisableCreateConcreteSingleFetchTypes(), + DisableCreateParallelNodes(), + DisableAddMissingNestedDependencies(), + DisableScheduleFetches(), + ) processor.Process(pre) assert.Equal(t, expected, pre) @@ -454,7 +461,13 @@ func TestDataSourceInput_ProcessTrigger(t *testing.T) { }, } - processor := NewProcessor(DisableMergeFields(), DisableDeduplicateSingleFetches(), DisableCreateConcreteSingleFetchTypes(), DisableAddMissingNestedDependencies()) + processor := NewProcessor( + DisableMergeFields(), + DisableDeduplicateSingleFetches(), + DisableCreateConcreteSingleFetchTypes(), + DisableAddMissingNestedDependencies(), + DisableScheduleFetches(), + ) processor.Process(pre) if !assert.Equal(t, expected, pre) { diff --git a/v2/pkg/engine/postprocess/schedule_fetches.go b/v2/pkg/engine/postprocess/schedule_fetches.go index 327f80ce64..1d0665e171 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches.go +++ b/v2/pkg/engine/postprocess/schedule_fetches.go @@ -50,6 +50,12 @@ func (b *scheduleFetches) buildSchedule(root *resolve.FetchTreeNode) error { root.ChildNodes = nil return nil } + // A subscription root must stay a Sequence: the plan printer renders the + // Subscription Primary/Rest wrapper only for Sequence roots, so the tree + // must not collapse into the root when it carries a Trigger. + if root.Trigger != nil && tree.Kind != resolve.FetchTreeNodeKindSequence { + tree = resolve.Sequence(tree) + } // Replace only the scheduling-related fields: the root may carry a // subscription Trigger or NormalizedQuery that must survive rescheduling. root.Kind = tree.Kind diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index f027126733..6e285f0ba8 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -548,10 +548,8 @@ func TestScheduleFetches_Scenarios(t *testing.T) { } } -func TestWithScheduleFetches_OptionWiring(t *testing.T) { +func TestDisableScheduleFetches_OptionWiring(t *testing.T) { t.Parallel() - // Verifies that WithScheduleFetches switches organizeFetchTree from the - // legacy wave scheduler to the schedule-tree scheduler. input := func() *resolve.FetchTreeNode { return seq( sf(0), @@ -570,13 +568,42 @@ func TestWithScheduleFetches_OptionWiring(t *testing.T) { seq(sf(1), sf(3, deps(1)))), sf(4, deps(2, 3))) + scheduled := input() + NewProcessor().fetchTreeProcessors.organizeFetchTree(scheduled) + require.Equal(t, wantScheduled, scheduled) + waves := input() - NewProcessor().fetchTreeProcessors.organizeFetchTree(waves) + NewProcessor(DisableScheduleFetches()).fetchTreeProcessors.organizeFetchTree(waves) require.Equal(t, wantWaves, waves) +} - scheduled := input() - NewProcessor(WithScheduleFetches()).fetchTreeProcessors.organizeFetchTree(scheduled) - require.Equal(t, wantScheduled, scheduled) +func TestScheduleFetches_SubscriptionRootStaysSequence(t *testing.T) { + t.Parallel() + // The plan printer renders the Subscription Primary/Rest wrapper only for Sequence roots: + // the scheduled tree must not collapse into the root when it carries a Trigger. + trigger := &resolve.FetchTreeNode{ + Kind: resolve.FetchTreeNodeKindTrigger, + Item: &resolve.FetchItem{Fetch: &resolve.SingleFetch{}}, + } + + single := seq(sf(0)) + single.Trigger = trigger + (&scheduleFetches{}).ProcessFetchTree(single) + require.Equal(t, resolve.FetchTreeNodeKindSequence, single.Kind) + require.Equal(t, trigger, single.Trigger) + require.Equal(t, nodes(sf(0)), single.ChildNodes) + + parallel := seq(sf(0), sf(1)) + parallel.Trigger = trigger + (&scheduleFetches{}).ProcessFetchTree(parallel) + require.Equal(t, resolve.FetchTreeNodeKindSequence, parallel.Kind) + require.Equal(t, trigger, parallel.Trigger) + require.Equal(t, nodes(par(sf(0), sf(1))), parallel.ChildNodes) + + // Without a Trigger the root may collapse into the scheduled tree. + sync := seq(sf(0)) + (&scheduleFetches{}).ProcessFetchTree(sync) + require.Equal(t, sf(0), sync) } func TestScheduleFetches_Validator(t *testing.T) { From 346c1aac4f4ea9f93c5015f0ea350f666d420fd3 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:22:46 +0300 Subject: [PATCH 08/24] make test diffs readable and remove deps from expected trees --- .../postprocess/schedule_fetches_test.go | 328 +++++++++--------- v2/pkg/engine/postprocess/util_test.go | 80 +++++ 2 files changed, 238 insertions(+), 170 deletions(-) diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index 6e285f0ba8..4b53fe65d3 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -26,43 +26,43 @@ func TestScheduleFetches_Scenarios(t *testing.T) { { name: "independent components baseline", input: nodes(sf(0), sf(1), sf(2, deps(0))), - want: par(seq(sf(0), sf(2, deps(0))), sf(1)), + want: par(seq(sf(0), sf(2)), sf(1)), }, { name: "single chain", input: nodes(sf(0), sf(1, deps(0)), sf(2, deps(1)), sf(3, deps(2))), - want: seq(sf(0), sf(1, deps(0)), sf(2, deps(1)), sf(3, deps(2))), + want: seq(sf(0), sf(1), sf(2), sf(3)), }, { name: "diamond join", input: nodes(sf(0), sf(1, deps(0)), sf(2, deps(0)), sf(3, deps(1, 2))), - want: seq(sf(0), par(sf(1, deps(0)), sf(2, deps(0))), sf(3, deps(1, 2))), + want: seq(sf(0), par(sf(1), sf(2)), sf(3)), }, { name: "two chains joining", input: nodes(sf(0), sf(1), sf(2, deps(0)), sf(3, deps(1)), sf(4, deps(2, 3))), want: seq( par( - seq(sf(0), sf(2, deps(0))), - seq(sf(1), sf(3, deps(1))), + seq(sf(0), sf(2)), + seq(sf(1), sf(3)), ), - sf(4, deps(2, 3)), + sf(4), ), waves: seq( par(sf(0), sf(1)), - par(sf(2, deps(0)), sf(3, deps(1))), - sf(4, deps(2, 3)), + par(sf(2), sf(3)), + sf(4), ), }, { name: "wide fan out", input: nodes(sf(0), sf(1, deps(0)), sf(2, deps(0)), sf(3, deps(0)), sf(4, deps(0))), - want: seq(sf(0), par(sf(1, deps(0)), sf(2, deps(0)), sf(3, deps(0)), sf(4, deps(0)))), + want: seq(sf(0), par(sf(1), sf(2), sf(3), sf(4))), }, { name: "wide fan in", input: nodes(sf(0), sf(1), sf(2), sf(3), sf(4, deps(0, 1, 2, 3))), - want: seq(par(sf(0), sf(1), sf(2), sf(3)), sf(4, deps(0, 1, 2, 3))), + want: seq(par(sf(0), sf(1), sf(2), sf(3)), sf(4)), }, { name: "independent diamonds", @@ -71,8 +71,8 @@ func TestScheduleFetches_Scenarios(t *testing.T) { sf(4), sf(5, deps(4)), sf(6, deps(4)), sf(7, deps(5, 6)), ), want: par( - seq(sf(0), par(sf(1, deps(0)), sf(2, deps(0))), sf(3, deps(1, 2))), - seq(sf(4), par(sf(5, deps(4)), sf(6, deps(4))), sf(7, deps(5, 6))), + seq(sf(0), par(sf(1), sf(2)), sf(3)), + seq(sf(4), par(sf(5), sf(6)), sf(7)), ), }, { @@ -87,40 +87,40 @@ func TestScheduleFetches_Scenarios(t *testing.T) { want: seq( sf(0), par( - seq(sf(1, deps(0)), sf(2, deps(1))), - seq(sf(3, deps(0)), sf(4, deps(3))), + seq(sf(1), sf(2)), + seq(sf(3), sf(4)), ), - sf(5, deps(2, 4)), + sf(5), par( - seq(sf(6, deps(5)), sf(7, deps(6))), - seq(sf(8, deps(5)), sf(9, deps(8))), + seq(sf(6), sf(7)), + seq(sf(8), sf(9)), ), - sf(10, deps(7, 9)), + sf(10), ), waves: seq( sf(0), - par(sf(1, deps(0)), sf(3, deps(0))), - par(sf(2, deps(1)), sf(4, deps(3))), - sf(5, deps(2, 4)), - par(sf(6, deps(5)), sf(8, deps(5))), - par(sf(7, deps(6)), sf(9, deps(8))), - sf(10, deps(7, 9)), + par(sf(1), sf(3)), + par(sf(2), sf(4)), + sf(5), + par(sf(6), sf(8)), + par(sf(7), sf(9)), + sf(10), ), }, { name: "requires chain", input: nodes(sf(0), sf(1, deps(0))), - want: seq(sf(0), sf(1, deps(0))), + want: seq(sf(0), sf(1)), }, { name: "batch entity component with independent root", input: nodes(sf(0), bf(1, 0), sf(2)), - want: par(seq(sf(0), bf(1, 0)), sf(2)), + want: par(seq(sf(0), bf(1)), sf(2)), }, { name: "nested entity chain", input: nodes(sf(0), ef(1, 0), ef(2, 1)), - want: seq(sf(0), ef(1, 0), ef(2, 1)), + want: seq(sf(0), ef(1), ef(2)), }, { name: "interface expansion", @@ -135,7 +135,7 @@ func TestScheduleFetches_Scenarios(t *testing.T) { { name: "sequential mutation", input: nodes(sf(0), sf(1, deps(0)), sf(2, deps(0, 1))), - want: seq(sf(0), sf(1, deps(0)), sf(2, deps(0, 1))), + want: seq(sf(0), sf(1), sf(2)), }, { name: "single fetch", @@ -150,50 +150,50 @@ func TestScheduleFetches_Scenarios(t *testing.T) { { name: "composite key fan in", input: nodes(sf(0), sf(1, deps(0)), sf(2, deps(0, 1))), - want: seq(sf(0), sf(1, deps(0)), sf(2, deps(0, 1))), + want: seq(sf(0), sf(1), sf(2)), }, { name: "asymmetric chain merge with leaf", input: nodes(sf(0), sf(1, deps(0)), sf(2, deps(0)), sf(3, deps(1, 2)), sf(4, deps(2))), want: seq( sf(0), - par(sf(1, deps(0)), sf(2, deps(0))), - par(sf(3, deps(1, 2)), sf(4, deps(2))), + par(sf(1), sf(2)), + par(sf(3), sf(4)), ), inlined: seq( sf(0), par( - sf(1, deps(0)), - seq(sf(2, deps(0)), sf(4, deps(2))), + sf(1), + seq(sf(2), sf(4)), ), - sf(3, deps(1, 2)), + sf(3), ), }, { name: "deep multi parent fan in", input: nodes(sf(0), sf(1), sf(2), sf(3, deps(0, 1, 2)), sf(4, deps(3)), sf(5, deps(4))), - want: seq(par(sf(0), sf(1), sf(2)), sf(3, deps(0, 1, 2)), sf(4, deps(3)), sf(5, deps(4))), + want: seq(par(sf(0), sf(1), sf(2)), sf(3), sf(4), sf(5)), }, { name: "non inlined n shape", input: nodes(sf(0), sf(1), sf(2, deps(0)), sf(3, deps(0, 1)), sf(4, deps(1))), want: seq( par(sf(0), sf(1)), - par(sf(2, deps(0)), sf(3, deps(0, 1)), sf(4, deps(1))), + par(sf(2), sf(3), sf(4)), ), inlined: seq( par( - seq(sf(0), sf(2, deps(0))), - seq(sf(1), sf(4, deps(1))), + seq(sf(0), sf(2)), + seq(sf(1), sf(4)), ), - sf(3, deps(0, 1)), + sf(3), ), }, { name: "independent root with shared join", input: nodes(sf(0), sf(1), sf(2, deps(0, 1)), sf(3)), want: par( - seq(par(sf(0), sf(1)), sf(2, deps(0, 1))), + seq(par(sf(0), sf(1)), sf(2)), sf(3), ), }, @@ -203,8 +203,8 @@ func TestScheduleFetches_Scenarios(t *testing.T) { want: seq( sf(0), par( - seq(sf(1, deps(0)), sf(2, deps(0, 1))), - sf(3, deps(0)), + seq(sf(1), sf(2)), + sf(3), ), ), }, @@ -214,13 +214,13 @@ func TestScheduleFetches_Scenarios(t *testing.T) { want: seq( par(sf(0), sf(1)), par( - seq(sf(2, deps(1)), sf(3, deps(0, 2))), - sf(4, deps(0, 1)), + seq(sf(2), sf(3)), + sf(4), ), ), inlined: seq( - par(sf(0), seq(sf(1), sf(2, deps(1)))), - par(sf(3, deps(0, 2)), sf(4, deps(0, 1))), + par(sf(0), seq(sf(1), sf(2))), + par(sf(3), sf(4)), ), }, { @@ -228,9 +228,9 @@ func TestScheduleFetches_Scenarios(t *testing.T) { input: nodes(sf(0), sf(1), sf(2, deps(0, 1)), sf(3, deps(2)), sf(4, deps(3, 0))), want: seq( par(sf(0), sf(1)), - sf(2, deps(0, 1)), - sf(3, deps(2)), - sf(4, deps(3, 0)), + sf(2), + sf(3), + sf(4), ), }, { @@ -249,41 +249,41 @@ func TestScheduleFetches_Scenarios(t *testing.T) { want: par( seq( par(sf(0), sf(1)), - par(sf(2, deps(0)), sf(3, deps(0, 1)), sf(4, deps(1))), + par(sf(2), sf(3), sf(4)), ), seq( par( - seq(sf(5), sf(7, deps(5))), - seq(sf(6), sf(8, deps(6))), + seq(sf(5), sf(7)), + seq(sf(6), sf(8)), ), - sf(9, deps(7, 8)), + sf(9), ), ), waves: par( seq( par(sf(0), sf(1)), - par(sf(2, deps(0)), sf(3, deps(0, 1)), sf(4, deps(1))), + par(sf(2), sf(3), sf(4)), ), seq( par(sf(5), sf(6)), - par(sf(7, deps(5)), sf(8, deps(6))), - sf(9, deps(7, 8)), + par(sf(7), sf(8)), + sf(9), ), ), inlined: par( seq( par( - seq(sf(0), sf(2, deps(0))), - seq(sf(1), sf(4, deps(1))), + seq(sf(0), sf(2)), + seq(sf(1), sf(4)), ), - sf(3, deps(0, 1)), + sf(3), ), seq( par( - seq(sf(5), sf(7, deps(5))), - seq(sf(6), sf(8, deps(6))), + seq(sf(5), sf(7)), + seq(sf(6), sf(8)), ), - sf(9, deps(7, 8)), + sf(9), ), ), }, @@ -303,31 +303,31 @@ func TestScheduleFetches_Scenarios(t *testing.T) { want: seq( sf(0), par( - sf(1, deps(0)), - sf(3, deps(0)), - sf(5, deps(0)), - sf(7, deps(0)), - sf(9, deps(0)), - sf(10, deps(0)), - seq(sf(14, deps(0)), par(sf(15, deps(14)), sf(16, deps(14)))), - seq(sf(17, deps(0)), par(sf(18, deps(17)), sf(19, deps(17)))), - seq(sf(32, deps(0)), par(sf(33, deps(32)), sf(34, deps(32)))), - seq(sf(35, deps(0)), par(sf(36, deps(35)), sf(37, deps(35)))), - seq(sf(44, deps(0)), par(sf(45, deps(44)), sf(46, deps(44)))), - seq(sf(47, deps(0)), par(sf(48, deps(47)), sf(49, deps(47)))), - seq(sf(56, deps(0)), par(sf(57, deps(56)), sf(58, deps(56)))), - seq(sf(59, deps(0)), par(sf(60, deps(59)), sf(61, deps(59)))), - seq(sf(62, deps(0)), par(sf(63, deps(62)), sf(64, deps(62)))), + sf(1), + sf(3), + sf(5), + sf(7), + sf(9), + sf(10), + seq(sf(14), par(sf(15), sf(16))), + seq(sf(17), par(sf(18), sf(19))), + seq(sf(32), par(sf(33), sf(34))), + seq(sf(35), par(sf(36), sf(37))), + seq(sf(44), par(sf(45), sf(46))), + seq(sf(47), par(sf(48), sf(49))), + seq(sf(56), par(sf(57), sf(58))), + seq(sf(59), par(sf(60), sf(61))), + seq(sf(62), par(sf(63), sf(64))), seq( - sf(68, deps(0)), + sf(68), par( - sf(69, deps(68)), + sf(69), seq( - sf(82, deps(68)), - par(sf(83, deps(82)), sf(84, deps(82)))), + sf(82), + par(sf(83), sf(84))), seq( - sf(85, deps(68)), - par(sf(86, deps(85)), sf(87, deps(85)))), + sf(85), + par(sf(86), sf(87))), ), ), ), @@ -355,34 +355,31 @@ func TestScheduleFetches_Scenarios(t *testing.T) { want: seq( sf(0), par( - sf(1, deps(0)), - sf(3, deps(0)), - sf(5, deps(0)), - sf(7, deps(0)), - sf(9, deps(0)), - sf(10, deps(0)), - seq(sf(14, deps(0)), - par(sf(15, deps(14)), - sf(16, deps(14)))), - seq(sf(17, deps(0)), sf(18, deps(17))), - seq(sf(29, deps(0)), par(sf(30, deps(29)), sf(31, deps(29)))), - seq(sf(32, deps(0)), sf(33, deps(32))), - seq(sf(39, deps(0)), par(sf(40, deps(39)), sf(41, deps(39)))), - seq(sf(42, deps(0)), sf(43, deps(42))), - seq(sf(49, deps(0)), par(sf(50, deps(49)), sf(51, deps(49)))), - seq(sf(52, deps(0)), sf(53, deps(52))), - seq(sf(54, deps(0)), sf(55, deps(54))), + sf(1), + sf(3), + sf(5), + sf(7), + sf(9), + sf(10), + seq(sf(14), + par(sf(15), + sf(16))), + seq(sf(17), sf(18)), + seq(sf(29), par(sf(30), sf(31))), + seq(sf(32), sf(33)), + seq(sf(39), par(sf(40), sf(41))), + seq(sf(42), sf(43)), + seq(sf(49), par(sf(50), sf(51))), + seq(sf(52), sf(53)), + seq(sf(54), sf(55)), seq( - sf(59, deps(0)), + sf(59), par( seq( - sf(71, deps(59)), - par( - sf(72, deps(71)), - sf(73, deps(71)), - ), + sf(71), + par(sf(72), sf(73)), ), - seq(sf(74, deps(59)), sf(75, deps(74))), + seq(sf(74), sf(75)), ), ), ), @@ -410,21 +407,21 @@ func TestScheduleFetches_Scenarios(t *testing.T) { want: seq( sf(0), par( - sf(1, deps(0)), + sf(1), seq( - sf(2, deps(0)), - par(sf(3, deps(2)), sf(4, deps(2))), + sf(2), + par(sf(3), sf(4)), ), ), - sf(5, deps(0, 1, 2, 3, 4)), - sf(6, deps(0, 5)), + sf(5), + sf(6), ), waves: seq( sf(0), - par(sf(1, deps(0)), sf(2, deps(0))), - par(sf(3, deps(2)), sf(4, deps(2))), - sf(5, deps(0, 1, 2, 3, 4)), - sf(6, deps(0, 5)), + par(sf(1), sf(2)), + par(sf(3), sf(4)), + sf(5), + sf(6), ), // The legacy wave pipeline: // seq( @@ -456,43 +453,40 @@ func TestScheduleFetches_Scenarios(t *testing.T) { want: seq( sf(0), par( - sf(1, deps(0)), + sf(1), seq( - sf(2, deps(0)), - sf(15, deps(2)), - par(sf(16, deps(15)), sf(17, deps(15)), sf(24, deps(15))), + sf(2), + sf(15), + par(sf(16), sf(17), sf(24)), ), - sf(3, deps(0)), + sf(3), seq( - sf(4, deps(0)), - par(sf(5, deps(4)), sf(6, deps(4)), sf(7, deps(4)), sf(21, deps(4))), - sf(27, deps(0, 4, 4, 5, 5, 6, 6, 7, 7, 21, 21)), + sf(4), + par(sf(5), sf(6), sf(7), sf(21)), + sf(27), ), - sf(12, deps(0)), + sf(12), seq( - sf(18, deps(0)), - par(sf(19, deps(18)), sf(20, deps(18)), sf(25, deps(18))), + sf(18), + par(sf(19), sf(20), sf(25)), ), ), - sf(26, deps(0, 2, 3, 5, 6, 12, 15, 16, 17, 18, 19, 20, 21, 24, 25)), - sf(28, deps(0, 1, 18, 26, 27)), + sf(26), + sf(28), ), waves: seq( sf(0), par( - sf(1, deps(0)), sf(2, deps(0)), sf(3, deps(0)), - sf(4, deps(0)), sf(12, deps(0)), sf(18, deps(0)), + sf(1), sf(2), sf(3), sf(4), sf(12), sf(18), ), par( - sf(5, deps(4)), sf(6, deps(4)), sf(7, deps(4)), sf(15, deps(2)), - sf(19, deps(18)), sf(20, deps(18)), sf(21, deps(4)), sf(25, deps(18)), + sf(5), sf(6), sf(7), sf(15), sf(19), sf(20), sf(21), sf(25), ), par( - sf(16, deps(15)), sf(17, deps(15)), sf(24, deps(15)), - sf(27, deps(0, 4, 4, 5, 5, 6, 6, 7, 7, 21, 21)), + sf(16), sf(17), sf(24), sf(27), ), - sf(26, deps(0, 2, 3, 5, 6, 12, 15, 16, 17, 18, 19, 20, 21, 24, 25)), - sf(28, deps(0, 1, 18, 26, 27)), + sf(26), + sf(28), ), // The legacy wave pipeline: // seq( @@ -530,16 +524,19 @@ func TestScheduleFetches_Scenarios(t *testing.T) { require.NoError(t, validateSchedule(actualWaves, dag)) require.NoError(t, validateSchedule(actualWinner, dag)) - expectedInlined, expectedWaves := tc.inlined, tc.waves + byID := fetchesByID(tc.input) + want := materialize(t, tc.want, byID) + expectedInlined := materialize(t, tc.inlined, byID) + expectedWaves := materialize(t, tc.waves, byID) if expectedInlined == nil { - expectedInlined = tc.want + expectedInlined = want } if expectedWaves == nil { - expectedWaves = tc.want + expectedWaves = want } - require.Equal(t, expectedInlined, actualInlined) - require.Equal(t, expectedWaves, actualWaves) - require.Equal(t, tc.want, actualWinner) + requireEqualTrees(t, expectedInlined, actualInlined) + requireEqualTrees(t, expectedWaves, actualWaves) + requireEqualTrees(t, want, actualWinner) // Beyond the pinned shapes, the trees must satisfy the scheduler // invariants under randomized duration profiles. @@ -570,11 +567,11 @@ func TestDisableScheduleFetches_OptionWiring(t *testing.T) { scheduled := input() NewProcessor().fetchTreeProcessors.organizeFetchTree(scheduled) - require.Equal(t, wantScheduled, scheduled) + requireEqualTrees(t, wantScheduled, scheduled) waves := input() NewProcessor(DisableScheduleFetches()).fetchTreeProcessors.organizeFetchTree(waves) - require.Equal(t, wantWaves, waves) + requireEqualTrees(t, wantWaves, waves) } func TestScheduleFetches_SubscriptionRootStaysSequence(t *testing.T) { @@ -690,39 +687,30 @@ func TestScheduleFetches_BigPlan(t *testing.T) { ) } - expected := par( + expected := materialize(t, par( seq( sf(0), - sf(1, at("users"), deps(0)), + sf(1), par( - seq( - sf(2, at("users.@.reviews.@.product"), deps(1)), - sf(9, at("users.@.reviews.@.product"), deps(1, 2)), - ), - sf(3, at("users.@.reviews.@.product.reviews.@.author"), deps(1)), - seq( - sf(4, at("users.@.reviews.@.product.reviews.@.author.reviews.@.product"), deps(1)), - sf(10, at("users.@.reviews.@.product.reviews.@.author.reviews.@.product"), deps(1, 4)), - ), + seq(sf(2), sf(9)), + sf(3), + seq(sf(4), sf(10)), ), ), seq( sf(5), par( seq( - sf(6, at("topProducts"), deps(5)), + sf(6), par( - sf(7, at("topProducts.@.reviews.@.author"), deps(6)), - seq( - sf(8, at("topProducts.@.reviews.@.author.reviews.@.product"), deps(6)), - sf(12, at("topProducts.@.reviews.@.author.reviews.@.product"), deps(6, 8)), - ), + sf(7), + seq(sf(8), sf(12)), ), ), - sf(11, at("topProducts"), deps(5)), + sf(11), ), ), - ) + ), fetchesByID(input())) t.Run("scheduler produces the tree without errors", func(t *testing.T) { fetches := input() @@ -731,13 +719,13 @@ func TestScheduleFetches_BigPlan(t *testing.T) { tree, err := buildScheduleTree(fetches, dag) require.NoError(t, err) require.NoError(t, validateSchedule(tree, dag)) - require.Equal(t, expected, tree) + requireEqualTrees(t, expected, tree) }) t.Run("scheduler does not fall back to legacy waves", func(t *testing.T) { root := seq(input()...) (&scheduleFetches{}).ProcessFetchTree(root) - require.Equal(t, expected, root) + requireEqualTrees(t, expected, root) }) } @@ -898,13 +886,13 @@ func weightedMakespan(node *resolve.FetchTreeNode, durations map[int]int) int { } return sum case resolve.FetchTreeNodeKindParallel: - max := 0 + maxSpan := 0 for _, child := range node.ChildNodes { - if m := weightedMakespan(child, durations); m > max { - max = m + if m := weightedMakespan(child, durations); m > maxSpan { + maxSpan = m } } - return max + return maxSpan default: return 0 } diff --git a/v2/pkg/engine/postprocess/util_test.go b/v2/pkg/engine/postprocess/util_test.go index cf99ef8dd2..21273ceecd 100644 --- a/v2/pkg/engine/postprocess/util_test.go +++ b/v2/pkg/engine/postprocess/util_test.go @@ -1,7 +1,11 @@ package postprocess import ( + "fmt" "strings" + "testing" + + "github.com/stretchr/testify/require" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" ) @@ -66,3 +70,79 @@ func seq(children ...*resolve.FetchTreeNode) *resolve.FetchTreeNode { func par(children ...*resolve.FetchTreeNode) *resolve.FetchTreeNode { return resolve.Parallel(children...) } + +// fetchesByID indexes flat input fetch nodes by their FetchID. +func fetchesByID(input []*resolve.FetchTreeNode) map[int]*resolve.FetchTreeNode { + byID := make(map[int]*resolve.FetchTreeNode, len(input)) + for _, node := range input { + byID[node.Item.Fetch.Dependencies().FetchID] = node + } + return byID +} + +// materialize returns shape with every leaf replaced by the input node carrying the same fetch ID. +func materialize(t *testing.T, shape *resolve.FetchTreeNode, byID map[int]*resolve.FetchTreeNode) *resolve.FetchTreeNode { + t.Helper() + if shape == nil { + return nil + } + if shape.Kind == resolve.FetchTreeNodeKindSingle { + id := shape.Item.Fetch.Dependencies().FetchID + node, ok := byID[id] + require.Truef(t, ok, "expected tree references fetch %d not present in input", id) + return node + } + children := make([]*resolve.FetchTreeNode, len(shape.ChildNodes)) + for i, child := range shape.ChildNodes { + children[i] = materialize(t, child, byID) + } + return &resolve.FetchTreeNode{Kind: shape.Kind, ChildNodes: children} +} + +// requireEqualTrees compares two fetch trees. The rendered tree is valid Go. +func requireEqualTrees(t *testing.T, expected, actual *resolve.FetchTreeNode) { + t.Helper() + require.Equal(t, renderShape(expected), renderShape(actual)) + require.Equal(t, expected, actual) +} + +func renderShape(node *resolve.FetchTreeNode) string { + var b strings.Builder + writeShape(&b, node, 0) + return b.String() +} + +func writeShape(b *strings.Builder, node *resolve.FetchTreeNode, depth int) { + indent := strings.Repeat("\t", depth) + if node == nil { + b.WriteString(indent + "nil") + return + } + switch node.Kind { + case resolve.FetchTreeNodeKindSingle: + id := node.Item.Fetch.Dependencies().FetchID + switch node.Item.Fetch.(type) { + case *resolve.SingleFetch: + fmt.Fprintf(b, "%ssf(%d)", indent, id) + case *resolve.EntityFetch: + fmt.Fprintf(b, "%sef(%d)", indent, id) + case *resolve.BatchEntityFetch: + fmt.Fprintf(b, "%sbf(%d)", indent, id) + default: + fmt.Fprintf(b, "%sfetch(%d)", indent, id) + } + case resolve.FetchTreeNodeKindSequence, resolve.FetchTreeNodeKindParallel: + name := "seq" + if node.Kind == resolve.FetchTreeNodeKindParallel { + name = "par" + } + b.WriteString(indent + name + "(\n") + for _, child := range node.ChildNodes { + writeShape(b, child, depth+1) + b.WriteString(",\n") + } + b.WriteString(indent + ")") + default: + fmt.Fprintf(b, "%s%s(?)", indent, node.Kind) + } +} From 608ef4b77d9579c48569029b9d91313efd3cf032 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:49:03 +0300 Subject: [PATCH 09/24] add a big test and compact the string representation of trees --- .../postprocess/schedule_fetches_test.go | 330 +++++++++++++++++- v2/pkg/engine/postprocess/util_test.go | 43 ++- 2 files changed, 348 insertions(+), 25 deletions(-) diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index 4b53fe65d3..75712a8ec6 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -193,7 +193,9 @@ func TestScheduleFetches_Scenarios(t *testing.T) { name: "independent root with shared join", input: nodes(sf(0), sf(1), sf(2, deps(0, 1)), sf(3)), want: par( - seq(par(sf(0), sf(1)), sf(2)), + seq( + par(sf(0), sf(1)), + sf(2)), sf(3), ), }, @@ -219,7 +221,9 @@ func TestScheduleFetches_Scenarios(t *testing.T) { ), ), inlined: seq( - par(sf(0), seq(sf(1), sf(2))), + par( + sf(0), + seq(sf(1), sf(2))), par(sf(3), sf(4)), ), }, @@ -362,8 +366,7 @@ func TestScheduleFetches_Scenarios(t *testing.T) { sf(9), sf(10), seq(sf(14), - par(sf(15), - sf(16))), + par(sf(15), sf(16))), seq(sf(17), sf(18)), seq(sf(29), par(sf(30), sf(31))), seq(sf(32), sf(33)), @@ -447,7 +450,7 @@ func TestScheduleFetches_Scenarios(t *testing.T) { sf(24, deps(15)), sf(25, deps(18)), sf(26, deps(0, 2, 3, 5, 6, 12, 15, 16, 17, 18, 19, 20, 21, 24, 25)), - sf(27, deps(0, 4, 4, 5, 5, 6, 6, 7, 7, 21, 21)), + sf(27, deps(0, 4, 5, 6, 7, 21)), sf(28, deps(0, 1, 18, 26, 27)), ), want: seq( @@ -476,15 +479,9 @@ func TestScheduleFetches_Scenarios(t *testing.T) { ), waves: seq( sf(0), - par( - sf(1), sf(2), sf(3), sf(4), sf(12), sf(18), - ), - par( - sf(5), sf(6), sf(7), sf(15), sf(19), sf(20), sf(21), sf(25), - ), - par( - sf(16), sf(17), sf(24), sf(27), - ), + par(sf(1), sf(2), sf(3), sf(4), sf(12), sf(18)), + par(sf(5), sf(6), sf(7), sf(15), sf(19), sf(20), sf(21), sf(25)), + par(sf(16), sf(17), sf(24), sf(27)), sf(26), sf(28), ), @@ -498,6 +495,311 @@ func TestScheduleFetches_Scenarios(t *testing.T) { // 28, // ) }, + { + // 3becc2e0f56d3d513c374e8035d1533cc6795740193a45c0c07def3f1f11eb0b + name: "deeply nested operation with wide dependencies", + input: nodes( + sf(0), + sf(1, deps(0)), + sf(2, deps(0)), + sf(3, deps(0)), + sf(4, deps(0)), + sf(5, deps(4)), + sf(6, deps(0)), + sf(7, deps(0)), + sf(8, deps(7)), + sf(9, deps(0)), + sf(10, deps(9)), + sf(11, deps(9)), + sf(12, deps(9)), + sf(13, deps(9)), + sf(14, deps(9)), + sf(18, deps(0)), + sf(19, deps(0)), + sf(20, deps(19)), + sf(21, deps(19)), + sf(23, deps(19)), + sf(24, deps(19)), + sf(25, deps(19)), + sf(32, deps(0)), + sf(33, deps(0)), + sf(35, deps(0)), + sf(37, deps(35)), + sf(38, deps(35)), + sf(41, deps(0)), + sf(42, deps(0)), + sf(43, deps(0)), + sf(44, deps(0)), + sf(45, deps(44)), + sf(47, deps(0)), + sf(49, deps(0)), + sf(50, deps(4)), + sf(51, deps(6)), + sf(52, deps(7)), + sf(53, deps(9)), + sf(54, deps(9)), + sf(55, deps(9)), + sf(59, deps(18)), + sf(60, deps(18)), + sf(61, deps(18)), + sf(62, deps(0)), + sf(63, deps(19)), + sf(64, deps(19)), + sf(65, deps(19)), + sf(66, deps(19)), + sf(67, deps(19)), + sf(73, deps(6)), + sf(74, deps(6, 73)), + sf(75, deps(32)), + sf(76, deps(0)), + sf(77, deps(76)), + sf(78, deps(0)), + sf(81, deps(6)), + sf(82, deps(35)), + sf(83, deps(35)), + sf(84, deps(35)), + sf(85, deps(35)), + sf(86, deps(35)), + sf(87, deps(35, 37, 38, 83, 84, 85)), + sf(93, deps(42)), + sf(94, deps(0)), + sf(95, deps(43)), + sf(96, deps(44, 45)), + sf(97, deps(0)), + sf(98, deps(0, 97)), + sf(99, deps(0)), + sf(100, deps(99)), + sf(101, deps(43)), + sf(102, deps(0)), + sf(103, deps(0)), + sf(104, deps(49)), + sf(105, deps(9, 11, 12, 13, 14, 54, 55)), + sf(106, deps(18, 60, 61)), + sf(107, deps(19, 21, 64, 65)), + sf(108, deps(19, 20, 25, 63, 66, 67)), + sf(110, deps(76)), + sf(112, deps(76)), + sf(113, deps(76)), + sf(114, deps(78)), + sf(115, deps(78)), + sf(117, deps(35, 84, 85, 86, 87)), + sf(118, deps(93)), + sf(122, deps(95)), + sf(128, deps(103)), + sf(138, deps(9, 10, 11, 12, 53, 105)), + sf(139, deps(19, 21, 63, 64, 107)), + sf(141, deps(0, 76, 77, 86, 110, 112, 113)), + sf(142, deps(0, 78, 114)), + sf(143, deps(0, 78, 115)), + sf(144, deps(0, 78, 115)), + sf(145, deps(0, 8, 35, 37, 82, 117)), + sf(146, deps(0, 8, 14, 94)), + sf(147, deps(0, 86, 99, 100, 110)), + sf(148, deps(147)), + sf(149, deps(0, 13, 14, 54, 55, 102, 103, 110, 128)), + sf(150, deps(19, 20, 23, 24, 25, 63, 108, 139)), + sf(151, deps(147, 148)), + sf(152, deps(19, 150)), + ), + want: seq( + sf(0), + par( + sf(1), + sf(2), + sf(3), + seq( + sf(4), + par(sf(5), sf(50)), + ), + seq( + sf(6), + par( + sf(51), + seq(sf(73), sf(74)), + sf(81), + ), + ), + seq( + par(sf(7), sf(9), sf(35), sf(76), sf(94), sf(99), sf(102), sf(103)), + par( + seq( + par(sf(8), sf(10), sf(11), sf(12), sf(13), sf(14), sf(37), sf(38), sf(53), sf(54), sf(55), + sf(77), sf(82), sf(83), sf(84), sf(85), sf(86), sf(100), sf(110), sf(112), sf(113), sf(128)), + par( + seq(sf(87), sf(117), sf(145)), + seq(sf(105), sf(138)), + sf(141), + sf(146), + seq(sf(147), sf(148), sf(151)), + sf(149), + ), + ), + sf(52), + ), + ), + seq( + sf(18), + par( + sf(59), + seq( + par(sf(60), sf(61)), + sf(106), + ), + ), + ), + seq( + sf(19), + par(sf(20), sf(21), sf(23), sf(24), sf(25), sf(63), sf(64), sf(65), sf(66), sf(67)), + par(sf(107), sf(108)), + sf(139), + sf(150), + sf(152), + ), + seq(sf(32), sf(75)), + sf(33), + sf(41), + seq(sf(42), sf(93), sf(118)), + seq( + sf(43), + par( + seq(sf(95), sf(122)), + sf(101), + ), + ), + seq(sf(44), sf(45), sf(96)), + sf(47), + seq(sf(49), sf(104)), + sf(62), + seq( + sf(78), + par( + seq(sf(114), sf(142)), + seq( + sf(115), + par(sf(143), sf(144)), + ), + ), + ), + seq(sf(97), sf(98)), + ), + ), + inlined: seq( + sf(0), + par( + sf(1), + sf(2), + sf(3), + seq( + sf(4), + par(sf(5), sf(50)), + ), + seq( + sf(6), + par( + sf(51), + seq(sf(73), sf(74)), + sf(81), + ), + ), + seq( + par( + seq( + sf(7), + par(sf(8), sf(52)), + ), + seq( + sf(9), + par(sf(10), sf(11), sf(12), sf(13), sf(14), sf(53), sf(54), sf(55)), + sf(105), + sf(138), + ), + seq( + sf(35), + par( + seq( + par(sf(37), sf(38), sf(83), sf(84), sf(85), sf(86)), + sf(87), + sf(117), + ), + sf(82), + ), + ), + seq( + sf(76), + par(sf(77), sf(110), sf(112), sf(113)), + ), + sf(94), + seq(sf(99), sf(100)), + sf(102), + seq(sf(103), sf(128)), + ), + par( + sf(141), + sf(145), + sf(146), + seq(sf(147), sf(148), sf(151)), + sf(149), + ), + ), + seq( + sf(18), + par( + sf(59), + seq( + par(sf(60), sf(61)), + sf(106), + ), + ), + ), + seq( + sf(19), + par(sf(20), sf(21), sf(23), sf(24), sf(25), sf(63), sf(64), sf(65), sf(66), sf(67)), + par( + seq(sf(107), sf(139)), + sf(108), + ), + sf(150), + sf(152), + ), + seq(sf(32), sf(75)), + sf(33), + sf(41), + seq(sf(42), sf(93), sf(118)), + seq( + sf(43), + par( + seq(sf(95), sf(122)), + sf(101), + ), + ), + seq(sf(44), sf(45), sf(96)), + sf(47), + seq(sf(49), sf(104)), + sf(62), + seq( + sf(78), + par( + seq(sf(114), sf(142)), + seq( + sf(115), + par(sf(143), sf(144)), + ), + ), + ), + seq(sf(97), sf(98)), + ), + ), + // The legacy wave pipeline: + // seq( + // 0, + // par(1, 2, 3, 4, 6, 7, 9, 18, 19, 32, 33, 35, 41, 42, 43, 44, 47, 49, 62, 76, 78, 94, 97, 99, 102, 103), + // par(5, 8, 10, 11, 12, 13, 14, 20, 21, 23, 24, 25, 37, 38, 45, 50, 51, 52, 53, 54, 55, 59, 60, 61, 63, 64, 65, 66, 67, 73, 75, 77, 81, 82, 83, 84, 85, 86, 93, 95, 98, 100, 101, 104, 110, 112, 113, 114, 115, 128), + // par(74, 96, 118, 122, 142, 143, 144, 106, 107, 146, 87, 108, 147, 105, 141, 149), + // par(139, 148, 117, 138), + // par(151, 145, 150), + // 152, + // ) + }, } for i, tc := range scenarios { diff --git a/v2/pkg/engine/postprocess/util_test.go b/v2/pkg/engine/postprocess/util_test.go index 21273ceecd..eeb1dcc86a 100644 --- a/v2/pkg/engine/postprocess/util_test.go +++ b/v2/pkg/engine/postprocess/util_test.go @@ -120,22 +120,17 @@ func writeShape(b *strings.Builder, node *resolve.FetchTreeNode, depth int) { } switch node.Kind { case resolve.FetchTreeNodeKindSingle: - id := node.Item.Fetch.Dependencies().FetchID - switch node.Item.Fetch.(type) { - case *resolve.SingleFetch: - fmt.Fprintf(b, "%ssf(%d)", indent, id) - case *resolve.EntityFetch: - fmt.Fprintf(b, "%sef(%d)", indent, id) - case *resolve.BatchEntityFetch: - fmt.Fprintf(b, "%sbf(%d)", indent, id) - default: - fmt.Fprintf(b, "%sfetch(%d)", indent, id) - } + b.WriteString(indent + leafShape(node)) case resolve.FetchTreeNodeKindSequence, resolve.FetchTreeNodeKindParallel: name := "seq" if node.Kind == resolve.FetchTreeNodeKindParallel { name = "par" } + // A group of only leaves stays on one line. + if leaves := leafShapes(node.ChildNodes); leaves != nil { + b.WriteString(indent + name + "(" + strings.Join(leaves, ", ") + ")") + return + } b.WriteString(indent + name + "(\n") for _, child := range node.ChildNodes { writeShape(b, child, depth+1) @@ -146,3 +141,29 @@ func writeShape(b *strings.Builder, node *resolve.FetchTreeNode, depth int) { fmt.Fprintf(b, "%s%s(?)", indent, node.Kind) } } + +// leafShapes returns the rendered leaves of children, or nil if any child is not a leaf. +func leafShapes(children []*resolve.FetchTreeNode) []string { + leaves := make([]string, len(children)) + for i, child := range children { + if child == nil || child.Kind != resolve.FetchTreeNodeKindSingle { + return nil + } + leaves[i] = leafShape(child) + } + return leaves +} + +func leafShape(node *resolve.FetchTreeNode) string { + id := node.Item.Fetch.Dependencies().FetchID + switch node.Item.Fetch.(type) { + case *resolve.SingleFetch: + return fmt.Sprintf("sf(%d)", id) + case *resolve.EntityFetch: + return fmt.Sprintf("ef(%d)", id) + case *resolve.BatchEntityFetch: + return fmt.Sprintf("bf(%d)", id) + default: + return fmt.Sprintf("fetch(%d)", id) + } +} From 007e1a1e972d4f64465c0eca5b2d627ccefe9187 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:07:36 +0300 Subject: [PATCH 10/24] one more query --- .../postprocess/schedule_fetches_test.go | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index 75712a8ec6..69b120b7fa 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -495,6 +495,50 @@ func TestScheduleFetches_Scenarios(t *testing.T) { // 28, // ) }, + { + // ca50f52a-4345-42ff-aa79-5897f5a001be-4d832881-45b4-443e-9121-5c610bb07b2c-10761258400747712775 + name: "dense shallow joins", + input: nodes( + sf(0), + sf(1, deps(0)), + sf(2, deps(0)), + sf(3, deps(0)), + sf(4, deps(0)), + sf(7, deps(0)), + sf(8, deps(0)), + sf(9, deps(0)), + sf(10, deps(0)), + sf(11, deps(0)), + sf(15, deps(0, 10, 17, 19, 21)), + sf(17, deps(0)), + sf(19, deps(0)), + sf(21, deps(0)), + sf(24, deps(0, 11)), + ), + want: seq( + sf(0), + par( + sf(1), + sf(2), + sf(3), + sf(4), + sf(7), + sf(8), + sf(9), + seq( + par(sf(10), sf(17), sf(19), sf(21)), + sf(15), + ), + seq(sf(11), sf(24)), + ), + ), + // The legacy wave pipeline: + // seq( + // 0, + // par(1, 2, 3, 4, 7, 8, 9, 10, 11, 17, 19, 21), + // par(24, 15), + // ) + }, { // 3becc2e0f56d3d513c374e8035d1533cc6795740193a45c0c07def3f1f11eb0b name: "deeply nested operation with wide dependencies", From 53898d1aee3cd5845f848c0ccca8e5d10e342446 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:13:47 +0300 Subject: [PATCH 11/24] add mutation test --- .../postprocess/schedule_fetches_test.go | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index 69b120b7fa..f918adde5e 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -533,12 +533,45 @@ func TestScheduleFetches_Scenarios(t *testing.T) { ), ), // The legacy wave pipeline: - // seq( + // seq( // 0, // par(1, 2, 3, 4, 7, 8, 9, 10, 11, 17, 19, 21), // par(24, 15), // ) }, + { + // 4041a8d2-4558-4c01-88f5-70140491bdc4-e20d3503-ccc8-4a89-b143-e4e79a994d05-12743902971020326245 + name: "dense serial mutation chain", + input: nodes( + sf(0), + sf(1, deps(0)), + sf(2, deps(0, 1)), + sf(3, deps(0, 1, 2)), + sf(4, deps(0, 1, 2, 3)), + sf(5, deps(0, 1, 2, 3, 4)), + sf(6, deps(0, 1, 2, 3, 4, 5)), + sf(7, deps(0, 1, 2, 3, 4, 5, 6)), + sf(8, deps(0, 1, 2, 3, 4, 5, 6, 7)), + sf(9, deps(0, 1, 2, 3, 4, 5, 6, 7, 8)), + sf(10, deps(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)), + sf(11, deps(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)), + sf(12, deps(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)), + sf(13, deps(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)), + sf(14, deps(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13)), + sf(15, deps(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14)), + sf(16, deps(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15)), + sf(17, deps(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)), + sf(18, deps(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17)), + sf(19, deps(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18)), + sf(20, deps(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19)), + sf(21, deps(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)), + sf(22, deps(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21)), + sf(23, deps(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22)), + ), + want: seq(sf(0), sf(1), sf(2), sf(3), sf(4), sf(5), sf(6), sf(7), sf(8), sf(9), sf(10), sf(11), sf(12), sf(13), sf(14), sf(15), sf(16), sf(17), sf(18), sf(19), sf(20), sf(21), sf(22), sf(23)), + // The legacy wave pipeline: + // seq(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23) + }, { // 3becc2e0f56d3d513c374e8035d1533cc6795740193a45c0c07def3f1f11eb0b name: "deeply nested operation with wide dependencies", From e2ebcd47a0ba73c2773272f1a1c03c6b7dd1ef2d Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:06:56 +0300 Subject: [PATCH 12/24] fix 1st batch of comments --- .../add_missing_nested_dependencies_test.go | 24 +-- .../postprocess/create_parallel_nodes_test.go | 96 ++++++------ .../order_sequence_by_dependencies_test.go | 147 ++++++++---------- v2/pkg/engine/postprocess/postprocess.go | 18 ++- v2/pkg/engine/postprocess/schedule_fetches.go | 12 +- .../postprocess/schedule_fetches_test.go | 57 +++---- v2/pkg/engine/postprocess/util_test.go | 27 ++-- 7 files changed, 183 insertions(+), 198 deletions(-) diff --git a/v2/pkg/engine/postprocess/add_missing_nested_dependencies_test.go b/v2/pkg/engine/postprocess/add_missing_nested_dependencies_test.go index 755805d895..4324492065 100644 --- a/v2/pkg/engine/postprocess/add_missing_nested_dependencies_test.go +++ b/v2/pkg/engine/postprocess/add_missing_nested_dependencies_test.go @@ -10,21 +10,21 @@ func TestAddMissingNestedDependencies_ProcessFetchTree(t *testing.T) { t.Run("add missing dependencies to nested fetches on same merge path", func(t *testing.T) { processor := &addMissingNestedDependencies{} input := seq( - sf(0, provides("a")), - sf(1, provides("b")), - sf(2, at("a")), - sf(3, at("b.c")), - sf(4, at("a"), deps(0)), - sf(5, provides("y")), + sf(0, mergePath("a")), + sf(1, mergePath("b")), + sf(2, responsePath("a")), + sf(3, responsePath("b.c")), + sf(4, responsePath("a"), dependsOn(0)), + sf(5, mergePath("y")), ) processor.ProcessFetchTree(input) expected := seq( - sf(0, provides("a")), - sf(1, provides("b")), - sf(2, at("a"), deps(0)), - sf(3, at("b.c"), deps(1)), - sf(4, at("a"), deps(0)), - sf(5, provides("y")), + sf(0, mergePath("a")), + sf(1, mergePath("b")), + sf(2, responsePath("a"), dependsOn(0)), + sf(3, responsePath("b.c"), dependsOn(1)), + sf(4, responsePath("a"), dependsOn(0)), + sf(5, mergePath("y")), ) require.Equal(t, expected, input) }) diff --git a/v2/pkg/engine/postprocess/create_parallel_nodes_test.go b/v2/pkg/engine/postprocess/create_parallel_nodes_test.go index 6ed9991dc8..cdb559f08f 100644 --- a/v2/pkg/engine/postprocess/create_parallel_nodes_test.go +++ b/v2/pkg/engine/postprocess/create_parallel_nodes_test.go @@ -11,18 +11,18 @@ func TestCreateParallelNodes_ProcessFetchTree(t *testing.T) { processor := &createParallelNodes{} input := seq( sf(0), - sf(1, deps(0)), - sf(2, deps(0)), - sf(3, deps(1)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0)), + sf(3, dependsOn(1)), ) processor.ProcessFetchTree(input) expected := seq( sf(0), par( - sf(1, deps(0)), - sf(2, deps(0)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0)), ), - sf(3, deps(1)), + sf(3, dependsOn(1)), ) require.Equal(t, expected, input) }) @@ -30,18 +30,18 @@ func TestCreateParallelNodes_ProcessFetchTree(t *testing.T) { processor := &createParallelNodes{} input := seq( sf(0), - sf(1, deps(0)), - sf(2, deps(0)), - sf(3, deps(2)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0)), + sf(3, dependsOn(2)), ) processor.ProcessFetchTree(input) expected := seq( sf(0), par( - sf(1, deps(0)), - sf(2, deps(0)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0)), ), - sf(3, deps(2)), + sf(3, dependsOn(2)), ) require.Equal(t, expected, input) }) @@ -49,18 +49,18 @@ func TestCreateParallelNodes_ProcessFetchTree(t *testing.T) { processor := &createParallelNodes{} input := seq( sf(0), - sf(1, deps(0)), - sf(2, deps(0)), - sf(3, deps(1, 2)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0)), + sf(3, dependsOn(1, 2)), ) processor.ProcessFetchTree(input) expected := seq( sf(0), par( - sf(1, deps(0)), - sf(2, deps(0)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0)), ), - sf(3, deps(1, 2)), + sf(3, dependsOn(1, 2)), ) require.Equal(t, expected, input) }) @@ -68,21 +68,21 @@ func TestCreateParallelNodes_ProcessFetchTree(t *testing.T) { processor := &createParallelNodes{} input := seq( sf(0), - sf(1, deps(0)), - sf(2, deps(0)), - sf(3, deps(1, 2)), - sf(4, deps(1, 2)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0)), + sf(3, dependsOn(1, 2)), + sf(4, dependsOn(1, 2)), ) processor.ProcessFetchTree(input) expected := seq( sf(0), par( - sf(1, deps(0)), - sf(2, deps(0)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0)), ), par( - sf(3, deps(1, 2)), - sf(4, deps(1, 2)), + sf(3, dependsOn(1, 2)), + sf(4, dependsOn(1, 2)), ), ) require.Equal(t, expected, input) @@ -91,24 +91,24 @@ func TestCreateParallelNodes_ProcessFetchTree(t *testing.T) { processor := &createParallelNodes{} input := seq( sf(0), - sf(1, deps(0)), - sf(2, deps(0)), - sf(3, deps(1)), - sf(4, deps(2)), - sf(5, deps(4)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0)), + sf(3, dependsOn(1)), + sf(4, dependsOn(2)), + sf(5, dependsOn(4)), ) processor.ProcessFetchTree(input) expected := seq( sf(0), par( - sf(1, deps(0)), - sf(2, deps(0)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0)), ), par( - sf(3, deps(1)), - sf(4, deps(2)), + sf(3, dependsOn(1)), + sf(4, dependsOn(2)), ), - sf(5, deps(4)), + sf(5, dependsOn(4)), ) require.Equal(t, expected, input) }) @@ -116,26 +116,26 @@ func TestCreateParallelNodes_ProcessFetchTree(t *testing.T) { processor := &createParallelNodes{} input := seq( sf(0), - sf(1, deps(0)), - sf(2, deps(0)), - sf(3, deps(1, 2)), - sf(4, deps(1, 3)), - sf(5, deps(2, 3)), - sf(6, deps(4, 5)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0)), + sf(3, dependsOn(1, 2)), + sf(4, dependsOn(1, 3)), + sf(5, dependsOn(2, 3)), + sf(6, dependsOn(4, 5)), ) processor.ProcessFetchTree(input) expected := seq( sf(0), par( - sf(1, deps(0)), - sf(2, deps(0)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0)), ), - sf(3, deps(1, 2)), + sf(3, dependsOn(1, 2)), par( - sf(4, deps(1, 3)), - sf(5, deps(2, 3)), + sf(4, dependsOn(1, 3)), + sf(5, dependsOn(2, 3)), ), - sf(6, deps(4, 5)), + sf(6, dependsOn(4, 5)), ) require.Equal(t, expected, input) }) diff --git a/v2/pkg/engine/postprocess/order_sequence_by_dependencies_test.go b/v2/pkg/engine/postprocess/order_sequence_by_dependencies_test.go index 173afe3add..e04596a6b3 100644 --- a/v2/pkg/engine/postprocess/order_sequence_by_dependencies_test.go +++ b/v2/pkg/engine/postprocess/order_sequence_by_dependencies_test.go @@ -26,14 +26,14 @@ func TestOrderSequenceByDependencies_ProcessFetchTree(t *testing.T) { processor := &orderSequenceByDependencies{} input := seq( sf(0), - sf(2, deps(1)), - sf(1, deps(0)), + sf(2, dependsOn(1)), + sf(1, dependsOn(0)), ) processor.ProcessFetchTree(input) expected := seq( sf(0), - sf(1, deps(0)), - sf(2, deps(1)), + sf(1, dependsOn(0)), + sf(2, dependsOn(1)), ) require.Equal(t, expected, input) }) @@ -41,35 +41,35 @@ func TestOrderSequenceByDependencies_ProcessFetchTree(t *testing.T) { processor := &orderSequenceByDependencies{} input := seq( sf(0), - sf(1, deps(0, 2)), - sf(2, deps(0)), + sf(1, dependsOn(0, 2)), + sf(2, dependsOn(0)), ) processor.ProcessFetchTree(input) expected := seq( sf(0), - sf(2, deps(0)), - sf(1, deps(0, 2)), + sf(2, dependsOn(0)), + sf(1, dependsOn(0, 2)), ) require.Equal(t, expected, input) }) t.Run("more dependencies", func(t *testing.T) { processor := &orderSequenceByDependencies{} input := seq( - sf(4, deps(3)), + sf(4, dependsOn(3)), sf(0), - sf(2, deps(1)), - sf(3, deps(5, 1)), - sf(1, deps(0)), - sf(5, deps(0)), + sf(2, dependsOn(1)), + sf(3, dependsOn(5, 1)), + sf(1, dependsOn(0)), + sf(5, dependsOn(0)), ) processor.ProcessFetchTree(input) expected := seq( sf(0), - sf(1, deps(0)), - sf(5, deps(0)), - sf(2, deps(1)), - sf(3, deps(5, 1)), - sf(4, deps(3)), + sf(1, dependsOn(0)), + sf(5, dependsOn(0)), + sf(2, dependsOn(1)), + sf(3, dependsOn(5, 1)), + sf(4, dependsOn(3)), ) require.Equal(t, expected, input) }) @@ -77,20 +77,20 @@ func TestOrderSequenceByDependencies_ProcessFetchTree(t *testing.T) { processor := &orderSequenceByDependencies{} input := seq( sf(0), - sf(1, deps(0)), - sf(2, deps(0, 5)), - sf(3, deps(0, 1)), - sf(4, deps(2)), - sf(5, deps(0)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0, 5)), + sf(3, dependsOn(0, 1)), + sf(4, dependsOn(2)), + sf(5, dependsOn(0)), ) processor.ProcessFetchTree(input) expected := seq( sf(0), - sf(1, deps(0)), - sf(5, deps(0)), - sf(2, deps(0, 5)), - sf(3, deps(0, 1)), - sf(4, deps(2)), + sf(1, dependsOn(0)), + sf(5, dependsOn(0)), + sf(2, dependsOn(0, 5)), + sf(3, dependsOn(0, 1)), + sf(4, dependsOn(2)), ) require.Equal(t, expected, input) }) @@ -98,20 +98,20 @@ func TestOrderSequenceByDependencies_ProcessFetchTree(t *testing.T) { processor := &orderSequenceByDependencies{} input := seq( sf(0), - sf(2, deps(0, 1)), - sf(1, deps(0)), - sf(3, deps(2)), - sf(5, deps(4)), - sf(4, deps(2, 3)), + sf(2, dependsOn(0, 1)), + sf(1, dependsOn(0)), + sf(3, dependsOn(2)), + sf(5, dependsOn(4)), + sf(4, dependsOn(2, 3)), ) processor.ProcessFetchTree(input) expected := seq( sf(0), - sf(1, deps(0)), - sf(2, deps(0, 1)), - sf(3, deps(2)), - sf(4, deps(2, 3)), - sf(5, deps(4)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0, 1)), + sf(3, dependsOn(2)), + sf(4, dependsOn(2, 3)), + sf(5, dependsOn(4)), ) require.Equal(t, expected, input) }) @@ -119,18 +119,18 @@ func TestOrderSequenceByDependencies_ProcessFetchTree(t *testing.T) { processor := &orderSequenceByDependencies{} input := seq( sf(0), - sf(3, deps(0, 2)), - sf(1, deps(0)), - sf(2, deps(0)), - sf(4, deps(0, 1)), + sf(3, dependsOn(0, 2)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0)), + sf(4, dependsOn(0, 1)), ) processor.ProcessFetchTree(input) expected := seq( sf(0), - sf(1, deps(0)), - sf(2, deps(0)), - sf(3, deps(0, 2)), - sf(4, deps(0, 1)), + sf(1, dependsOn(0)), + sf(2, dependsOn(0)), + sf(3, dependsOn(0, 2)), + sf(4, dependsOn(0, 1)), ) require.Equal(t, expected, input) }) @@ -138,70 +138,55 @@ func TestOrderSequenceByDependencies_ProcessFetchTree(t *testing.T) { t.Run("dependent with fetch ID 0 must come after its dependency", func(t *testing.T) { processor := &orderSequenceByDependencies{} input := seq( - sf(0, deps(3)), - sf(3, deps(1, 2)), - sf(1, deps(5)), - sf(2, deps(5)), + sf(0, dependsOn(3)), + sf(3, dependsOn(1, 2)), + sf(1, dependsOn(5)), + sf(2, dependsOn(5)), sf(5), ) processor.ProcessFetchTree(input) expected := seq( sf(5), - sf(1, deps(5)), - sf(2, deps(5)), - sf(3, deps(1, 2)), - sf(0, deps(3)), + sf(1, dependsOn(5)), + sf(2, dependsOn(5)), + sf(3, dependsOn(1, 2)), + sf(0, dependsOn(3)), ) require.Equal(t, expected, input) }) t.Run("equal transitive dependencies tie-break by fetch ID (diamond)", func(t *testing.T) { processor := &orderSequenceByDependencies{} input := seq( - sf(7, deps(4, 5)), - sf(6, deps(3, 4, 5)), + sf(7, dependsOn(4, 5)), + sf(6, dependsOn(3, 4, 5)), sf(3), - sf(4, deps(3)), - sf(5, deps(3)), + sf(4, dependsOn(3)), + sf(5, dependsOn(3)), ) processor.ProcessFetchTree(input) expected := seq( sf(3), - sf(4, deps(3)), - sf(5, deps(3)), - sf(6, deps(3, 4, 5)), - sf(7, deps(4, 5)), + sf(4, dependsOn(3)), + sf(5, dependsOn(3)), + sf(6, dependsOn(3, 4, 5)), + sf(7, dependsOn(4, 5)), ) require.Equal(t, expected, input) }) t.Run("duplicate direct dependency IDs tie-break by fetch ID", func(t *testing.T) { processor := &orderSequenceByDependencies{} input := seq( - sf(3, deps(1)), - sf(2, deps(1, 1)), + sf(3, dependsOn(1)), + sf(2, dependsOn(1, 1)), sf(1), ) processor.ProcessFetchTree(input) expected := seq( sf(1), - sf(2, deps(1, 1)), - sf(3, deps(1)), + sf(2, dependsOn(1, 1)), + sf(3, dependsOn(1)), ) require.Equal(t, expected, input) }) } -// func TestOrderSequenceByDependencies_DependencyCycle(t *testing.T) { -// processor := &orderSequenceByDependencies{} -// processor.ProcessFetchTree(seq( -// sf(1, deps(2)), -// sf(2, deps(1)), -// )) -// } -// -// func TestOrderSequenceByDependencies_SelfDependency(t *testing.T) { -// processor := &orderSequenceByDependencies{} -// processor.ProcessFetchTree(seq( -// sf(0), -// sf(1, deps(1)), -// )) -// } diff --git a/v2/pkg/engine/postprocess/postprocess.go b/v2/pkg/engine/postprocess/postprocess.go index 54e5a32fbd..ff0b2334ac 100644 --- a/v2/pkg/engine/postprocess/postprocess.go +++ b/v2/pkg/engine/postprocess/postprocess.go @@ -56,15 +56,23 @@ func (p *FetchTreeProcessors) processFlatFetchTree(response *resolve.GraphQLResp p.addMissingNestedDependencies.ProcessFetchTree(fetches) } -// organizeFetchTree organizes the fetch tree by ordering sequence nodes by dependencies and creating parallel nodes. -// after this step fetches have tree structure of serial and parallel nodes. +// organizeFetchTree organizes the fetch tree by merging entity fetches first and +// then scheduling fetches in nested Parallel/Sequence trees. If scheduling failed, then +// the fetches are organized as a Sequence of waves (Parallel groups). +// After this step fetches have tree structure of serial and parallel nodes. func (p *FetchTreeProcessors) organizeFetchTree(fetches *resolve.FetchTreeNode) { - if !p.scheduleFetches.disable { - p.scheduleFetches.ProcessFetchTree(fetches) - return + if !p.scheduleFetches.disable && fetches != nil && fetches.Kind == resolve.FetchTreeNodeKindSequence { + if err := p.scheduleFetches.ProcessFetchTree(fetches); err == nil { + return + } } + p.organizeFetchTreeInWaves(fetches) +} + +func (p *FetchTreeProcessors) organizeFetchTreeInWaves(fetches *resolve.FetchTreeNode) { p.orderSequenceByDependencies.ProcessFetchTree(fetches) p.createParallelNodes.ProcessFetchTree(fetches) + } // processOrganizedFetchTree runs the stages that operate on the tree organized in waves. diff --git a/v2/pkg/engine/postprocess/schedule_fetches.go b/v2/pkg/engine/postprocess/schedule_fetches.go index 1d0665e171..2852a2b4d8 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches.go +++ b/v2/pkg/engine/postprocess/schedule_fetches.go @@ -27,17 +27,7 @@ type scheduleFetches struct { disable bool } -func (b *scheduleFetches) ProcessFetchTree(root *resolve.FetchTreeNode) { - if b.disable || root == nil || root.Kind != resolve.FetchTreeNodeKindSequence { - return - } - if err := b.buildSchedule(root); err != nil { - (&orderSequenceByDependencies{}).ProcessFetchTree(root) - (&createParallelNodes{}).ProcessFetchTree(root) - } -} - -func (b *scheduleFetches) buildSchedule(root *resolve.FetchTreeNode) error { +func (b *scheduleFetches) ProcessFetchTree(root *resolve.FetchTreeNode) error { dag, err := newFetchDAG(root.ChildNodes) if err != nil { return err diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index f918adde5e..7a45c06c50 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -21,6 +21,7 @@ func TestScheduleFetches_Scenarios(t *testing.T) { inlined *resolve.FetchTreeNode // specify when it's not equal to winner waves *resolve.FetchTreeNode // specify when it's not equal to winner } + deps := dependsOn scenarios := []scenario{ { @@ -930,19 +931,19 @@ func TestDisableScheduleFetches_OptionWiring(t *testing.T) { return seq( sf(0), sf(1), - sf(2, deps(0)), - sf(3, deps(1)), - sf(4, deps(2, 3))) + sf(2, dependsOn(0)), + sf(3, dependsOn(1)), + sf(4, dependsOn(2, 3))) } wantWaves := seq( par(sf(0), sf(1)), - par(sf(2, deps(0)), - sf(3, deps(1))), - sf(4, deps(2, 3))) + par(sf(2, dependsOn(0)), + sf(3, dependsOn(1))), + sf(4, dependsOn(2, 3))) wantScheduled := seq( - par(seq(sf(0), sf(2, deps(0))), - seq(sf(1), sf(3, deps(1)))), - sf(4, deps(2, 3))) + par(seq(sf(0), sf(2, dependsOn(0))), + seq(sf(1), sf(3, dependsOn(1)))), + sf(4, dependsOn(2, 3))) scheduled := input() NewProcessor().fetchTreeProcessors.organizeFetchTree(scheduled) @@ -984,8 +985,8 @@ func TestScheduleFetches_SubscriptionRootStaysSequence(t *testing.T) { func TestScheduleFetches_Validator(t *testing.T) { t.Run("response-path nesting without a FetchID edge is valid", func(t *testing.T) { - y := sf(0, at("user")) - x := sf(1, at("user.details"), provides("user.details")) + y := sf(0, responsePath("user")) + x := sf(1, responsePath("user.details"), mergePath("user.details")) tree := par(x, y) dag, err := newFetchDAG(nodes(x, y)) require.NoError(t, err) @@ -994,7 +995,7 @@ func TestScheduleFetches_Validator(t *testing.T) { t.Run("explicit FetchID edge between parallel siblings is invalid", func(t *testing.T) { y := sf(0) - x := sf(1, deps(0)) + x := sf(1, dependsOn(0)) tree := par(x, y) dag, err := newFetchDAG(nodes(x, y)) require.NoError(t, err) @@ -1003,7 +1004,7 @@ func TestScheduleFetches_Validator(t *testing.T) { t.Run("self-dependency is invalid", func(t *testing.T) { y := sf(0) - x := sf(1, deps(1)) + x := sf(1, dependsOn(1)) _, err := newFetchDAG(nodes(x, y)) require.EqualError(t, err, "self-dependent id 1") }) @@ -1011,7 +1012,7 @@ func TestScheduleFetches_Validator(t *testing.T) { // Conservation: a schedule must never lose or duplicate a fetch. The // property tests lean on these rejections for their completeness checks. t.Run("schedule missing a fetch is invalid", func(t *testing.T) { - input := nodes(sf(0), sf(1, deps(0)), sf(2, deps(0))) + input := nodes(sf(0), sf(1, dependsOn(0)), sf(2, dependsOn(0))) dag, err := newFetchDAG(input) require.NoError(t, err) missing := seq(input[0], input[1]) @@ -1019,7 +1020,7 @@ func TestScheduleFetches_Validator(t *testing.T) { }) t.Run("schedule duplicating a fetch is invalid", func(t *testing.T) { - input := nodes(sf(0), sf(1, deps(0)), sf(2, deps(0))) + input := nodes(sf(0), sf(1, dependsOn(0)), sf(2, dependsOn(0))) dag, err := newFetchDAG(input) require.NoError(t, err) duplicated := seq(input[0], par(input[1], input[2]), input[1]) @@ -1031,7 +1032,7 @@ func TestScheduleFetches_ProcessorFallsBackOnError(t *testing.T) { t.Parallel() // on any scheduler/validator error the processor degrades to the LEGACY wave pipeline. build := func() *resolve.FetchTreeNode { - return seq(sf(7), sf(7, deps(7))) + return seq(sf(7), sf(7, dependsOn(7))) } legacy := build() @@ -1052,17 +1053,17 @@ func TestScheduleFetches_BigPlan(t *testing.T) { return nodes( sf(0), sf(5), - sf(1, at("users"), deps(0)), - sf(6, at("topProducts"), deps(5)), - sf(11, at("topProducts"), deps(5)), - sf(2, at("users.@.reviews.@.product"), deps(1)), - sf(3, at("users.@.reviews.@.product.reviews.@.author"), deps(1)), - sf(4, at("users.@.reviews.@.product.reviews.@.author.reviews.@.product"), deps(1)), - sf(7, at("topProducts.@.reviews.@.author"), deps(6)), - sf(8, at("topProducts.@.reviews.@.author.reviews.@.product"), deps(6)), - sf(9, at("users.@.reviews.@.product"), deps(1, 2)), - sf(10, at("users.@.reviews.@.product.reviews.@.author.reviews.@.product"), deps(1, 4)), - sf(12, at("topProducts.@.reviews.@.author.reviews.@.product"), deps(6, 8)), + sf(1, responsePath("users"), dependsOn(0)), + sf(6, responsePath("topProducts"), dependsOn(5)), + sf(11, responsePath("topProducts"), dependsOn(5)), + sf(2, responsePath("users.@.reviews.@.product"), dependsOn(1)), + sf(3, responsePath("users.@.reviews.@.product.reviews.@.author"), dependsOn(1)), + sf(4, responsePath("users.@.reviews.@.product.reviews.@.author.reviews.@.product"), dependsOn(1)), + sf(7, responsePath("topProducts.@.reviews.@.author"), dependsOn(6)), + sf(8, responsePath("topProducts.@.reviews.@.author.reviews.@.product"), dependsOn(6)), + sf(9, responsePath("users.@.reviews.@.product"), dependsOn(1, 2)), + sf(10, responsePath("users.@.reviews.@.product.reviews.@.author.reviews.@.product"), dependsOn(1, 4)), + sf(12, responsePath("topProducts.@.reviews.@.author.reviews.@.product"), dependsOn(6, 8)), ) } @@ -1221,7 +1222,7 @@ func randomDAG(n int, averageDegree float64, rng *rand.Rand) []*resolve.FetchTre } out := make([]*resolve.FetchTreeNode, n) for i := range out { - out[i] = sf(i, deps(depLists[i]...)) + out[i] = sf(i, dependsOn(depLists[i]...)) } return out } diff --git a/v2/pkg/engine/postprocess/util_test.go b/v2/pkg/engine/postprocess/util_test.go index eeb1dcc86a..34e645a681 100644 --- a/v2/pkg/engine/postprocess/util_test.go +++ b/v2/pkg/engine/postprocess/util_test.go @@ -16,18 +16,18 @@ func nodes(items ...*resolve.FetchTreeNode) []*resolve.FetchTreeNode { return items } -// sfOpt configures optional fields of a single-fetch node built by sf. -type sfOpt func(node *resolve.FetchTreeNode) +// singleFetchOption configures optional fields of a single-fetch node built by sf. +type singleFetchOption func(node *resolve.FetchTreeNode) -// deps sets the fetch IDs the fetch depends on. -func deps(ids ...int) sfOpt { +// dependsOn sets the fetch IDs the fetch depends on. +func dependsOn(ids ...int) singleFetchOption { return func(node *resolve.FetchTreeNode) { node.Item.Fetch.(*resolve.SingleFetch).FetchDependencies.DependsOnFetchIDs = ids } } -// at nests the fetch at responsePath, e.g. "a.b" becomes ObjectPath("a"), ObjectPath("b"). -func at(responsePath string) sfOpt { +// responsePath nests the fetch responsePath responsePath, e.g. "a.b" becomes ObjectPath("a"), ObjectPath("b"). +func responsePath(responsePath string) singleFetchOption { return func(node *resolve.FetchTreeNode) { segments := strings.Split(responsePath, ".") path := make([]resolve.FetchItemPathElement, len(segments)) @@ -40,14 +40,14 @@ func at(responsePath string) sfOpt { } } -// provides sets the merge path the fetch provides in the response. -func provides(mergePath ...string) sfOpt { +// mergePath sets the merge path the fetch mergePath in the response. +func mergePath(mergePath ...string) singleFetchOption { return func(node *resolve.FetchTreeNode) { node.Item.Fetch.(*resolve.SingleFetch).PostProcessing.MergePath = mergePath } } -func sf(id int, opts ...sfOpt) *resolve.FetchTreeNode { +func sf(id int, opts ...singleFetchOption) *resolve.FetchTreeNode { node := resolve.Single(&resolve.SingleFetch{FetchDependencies: resolve.FetchDependencies{FetchID: id}}) for _, opt := range opts { opt(node) @@ -80,21 +80,22 @@ func fetchesByID(input []*resolve.FetchTreeNode) map[int]*resolve.FetchTreeNode return byID } -// materialize returns shape with every leaf replaced by the input node carrying the same fetch ID. -func materialize(t *testing.T, shape *resolve.FetchTreeNode, byID map[int]*resolve.FetchTreeNode) *resolve.FetchTreeNode { +// materialize returns shape with every leaf node replaced by the input node carrying the same fetch ID. +// It needed to prevent clutter in the expected part of the test as not important. +func materialize(t *testing.T, shape *resolve.FetchTreeNode, input map[int]*resolve.FetchTreeNode) *resolve.FetchTreeNode { t.Helper() if shape == nil { return nil } if shape.Kind == resolve.FetchTreeNodeKindSingle { id := shape.Item.Fetch.Dependencies().FetchID - node, ok := byID[id] + node, ok := input[id] require.Truef(t, ok, "expected tree references fetch %d not present in input", id) return node } children := make([]*resolve.FetchTreeNode, len(shape.ChildNodes)) for i, child := range shape.ChildNodes { - children[i] = materialize(t, child, byID) + children[i] = materialize(t, child, input) } return &resolve.FetchTreeNode{Kind: shape.Kind, ChildNodes: children} } From 7990350ea277a4dd857f3a8ccf3089661c5614b3 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:26:36 +0300 Subject: [PATCH 13/24] fix 2nd --- v2/pkg/engine/postprocess/schedule_fetches.go | 8 +++--- .../postprocess/schedule_fetches_test.go | 27 +------------------ v2/pkg/engine/postprocess/util_test.go | 8 ------ 3 files changed, 5 insertions(+), 38 deletions(-) diff --git a/v2/pkg/engine/postprocess/schedule_fetches.go b/v2/pkg/engine/postprocess/schedule_fetches.go index 2852a2b4d8..f38e9dd31f 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches.go +++ b/v2/pkg/engine/postprocess/schedule_fetches.go @@ -102,7 +102,7 @@ func buildScheduleTree(roots []*resolve.FetchTreeNode, dag *fetchDAG) (*resolve. ids = append(ids, root.Item.Fetch.Dependencies().FetchID) } // Pick the best strategy on the top level for weakly connected trees. - components := weaklyConnectedComponents(sortedUnique(ids), dag) + components := weaklyConnectedComponents(sortedCopy(ids), dag) winners := make([]*resolve.FetchTreeNode, 0, len(components)) for _, component := range components { waves, err := schedule(component, dag, false) @@ -132,7 +132,7 @@ func buildScheduleTree(roots []*resolve.FetchTreeNode, dag *fetchDAG) (*resolve. // within a component the ready roots run in Parallel followed by the remainder in Sequence. // When inlined, each root pulls the descendants reachable only through it into its branch. func schedule(set []int, dag *fetchDAG, inline bool) (*resolve.FetchTreeNode, error) { - sortedSet := sortedUnique(set) + sortedSet := sortedCopy(set) switch len(sortedSet) { case 0: return nil, nil @@ -488,13 +488,13 @@ func minReachableFetchID(node *resolve.FetchTreeNode) int { return minID } -func sortedUnique(ids []int) []int { +func sortedCopy(ids []int) []int { if len(ids) == 0 { return nil } out := append([]int{}, ids...) slices.Sort(out) - return slices.Compact(out) + return out } func asMap(ids []int) map[int]struct{} { diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index 7a45c06c50..9162c10316 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -109,32 +109,7 @@ func TestScheduleFetches_Scenarios(t *testing.T) { ), }, { - name: "requires chain", - input: nodes(sf(0), sf(1, deps(0))), - want: seq(sf(0), sf(1)), - }, - { - name: "batch entity component with independent root", - input: nodes(sf(0), bf(1, 0), sf(2)), - want: par(seq(sf(0), bf(1)), sf(2)), - }, - { - name: "nested entity chain", - input: nodes(sf(0), ef(1, 0), ef(2, 1)), - want: seq(sf(0), ef(1), ef(2)), - }, - { - name: "interface expansion", - input: nodes(sf(0), sf(1), sf(2)), - want: par(sf(0), sf(1), sf(2)), - }, - { - name: "provides skips fetch", - input: nodes(sf(0)), - want: sf(0), - }, - { - name: "sequential mutation", + name: "sequential chain", input: nodes(sf(0), sf(1, deps(0)), sf(2, deps(0, 1))), want: seq(sf(0), sf(1), sf(2)), }, diff --git a/v2/pkg/engine/postprocess/util_test.go b/v2/pkg/engine/postprocess/util_test.go index 34e645a681..98421bbe86 100644 --- a/v2/pkg/engine/postprocess/util_test.go +++ b/v2/pkg/engine/postprocess/util_test.go @@ -55,14 +55,6 @@ func sf(id int, opts ...singleFetchOption) *resolve.FetchTreeNode { return node } -func ef(id int, deps ...int) *resolve.FetchTreeNode { - return resolve.Single(&resolve.EntityFetch{FetchDependencies: resolve.FetchDependencies{FetchID: id, DependsOnFetchIDs: deps}}) -} - -func bf(id int, deps ...int) *resolve.FetchTreeNode { - return resolve.Single(&resolve.BatchEntityFetch{FetchDependencies: resolve.FetchDependencies{FetchID: id, DependsOnFetchIDs: deps}}) -} - func seq(children ...*resolve.FetchTreeNode) *resolve.FetchTreeNode { return resolve.Sequence(children...) } From c8b176692e0a4079816ec2f4db55cfc5dd5db2a3 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:58:07 +0300 Subject: [PATCH 14/24] add a benchmark --- .../postprocess/schedule_fetches_test.go | 240 ++++++++++-------- 1 file changed, 136 insertions(+), 104 deletions(-) diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index 9162c10316..c20c273bdd 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -550,110 +550,8 @@ func TestScheduleFetches_Scenarios(t *testing.T) { }, { // 3becc2e0f56d3d513c374e8035d1533cc6795740193a45c0c07def3f1f11eb0b - name: "deeply nested operation with wide dependencies", - input: nodes( - sf(0), - sf(1, deps(0)), - sf(2, deps(0)), - sf(3, deps(0)), - sf(4, deps(0)), - sf(5, deps(4)), - sf(6, deps(0)), - sf(7, deps(0)), - sf(8, deps(7)), - sf(9, deps(0)), - sf(10, deps(9)), - sf(11, deps(9)), - sf(12, deps(9)), - sf(13, deps(9)), - sf(14, deps(9)), - sf(18, deps(0)), - sf(19, deps(0)), - sf(20, deps(19)), - sf(21, deps(19)), - sf(23, deps(19)), - sf(24, deps(19)), - sf(25, deps(19)), - sf(32, deps(0)), - sf(33, deps(0)), - sf(35, deps(0)), - sf(37, deps(35)), - sf(38, deps(35)), - sf(41, deps(0)), - sf(42, deps(0)), - sf(43, deps(0)), - sf(44, deps(0)), - sf(45, deps(44)), - sf(47, deps(0)), - sf(49, deps(0)), - sf(50, deps(4)), - sf(51, deps(6)), - sf(52, deps(7)), - sf(53, deps(9)), - sf(54, deps(9)), - sf(55, deps(9)), - sf(59, deps(18)), - sf(60, deps(18)), - sf(61, deps(18)), - sf(62, deps(0)), - sf(63, deps(19)), - sf(64, deps(19)), - sf(65, deps(19)), - sf(66, deps(19)), - sf(67, deps(19)), - sf(73, deps(6)), - sf(74, deps(6, 73)), - sf(75, deps(32)), - sf(76, deps(0)), - sf(77, deps(76)), - sf(78, deps(0)), - sf(81, deps(6)), - sf(82, deps(35)), - sf(83, deps(35)), - sf(84, deps(35)), - sf(85, deps(35)), - sf(86, deps(35)), - sf(87, deps(35, 37, 38, 83, 84, 85)), - sf(93, deps(42)), - sf(94, deps(0)), - sf(95, deps(43)), - sf(96, deps(44, 45)), - sf(97, deps(0)), - sf(98, deps(0, 97)), - sf(99, deps(0)), - sf(100, deps(99)), - sf(101, deps(43)), - sf(102, deps(0)), - sf(103, deps(0)), - sf(104, deps(49)), - sf(105, deps(9, 11, 12, 13, 14, 54, 55)), - sf(106, deps(18, 60, 61)), - sf(107, deps(19, 21, 64, 65)), - sf(108, deps(19, 20, 25, 63, 66, 67)), - sf(110, deps(76)), - sf(112, deps(76)), - sf(113, deps(76)), - sf(114, deps(78)), - sf(115, deps(78)), - sf(117, deps(35, 84, 85, 86, 87)), - sf(118, deps(93)), - sf(122, deps(95)), - sf(128, deps(103)), - sf(138, deps(9, 10, 11, 12, 53, 105)), - sf(139, deps(19, 21, 63, 64, 107)), - sf(141, deps(0, 76, 77, 86, 110, 112, 113)), - sf(142, deps(0, 78, 114)), - sf(143, deps(0, 78, 115)), - sf(144, deps(0, 78, 115)), - sf(145, deps(0, 8, 35, 37, 82, 117)), - sf(146, deps(0, 8, 14, 94)), - sf(147, deps(0, 86, 99, 100, 110)), - sf(148, deps(147)), - sf(149, deps(0, 13, 14, 54, 55, 102, 103, 110, 128)), - sf(150, deps(19, 20, 23, 24, 25, 63, 108, 139)), - sf(151, deps(147, 148)), - sf(152, deps(19, 150)), - ), + name: "deeply nested operation with wide dependencies", + input: deeplyNestedWideDependenciesInput(), want: seq( sf(0), par( @@ -1252,3 +1150,137 @@ func weightedMakespan(node *resolve.FetchTreeNode, durations map[int]int) int { return 0 } } + +// deeplyNestedWideDependenciesInput is a real production query. +// Reference: 3becc2e0f56d3d513c374e8035d1533cc6795740193a45c0c07def3f1f11eb0b +func deeplyNestedWideDependenciesInput() []*resolve.FetchTreeNode { + deps := dependsOn + return nodes( + sf(0), + sf(1, deps(0)), + sf(2, deps(0)), + sf(3, deps(0)), + sf(4, deps(0)), + sf(5, deps(4)), + sf(6, deps(0)), + sf(7, deps(0)), + sf(8, deps(7)), + sf(9, deps(0)), + sf(10, deps(9)), + sf(11, deps(9)), + sf(12, deps(9)), + sf(13, deps(9)), + sf(14, deps(9)), + sf(18, deps(0)), + sf(19, deps(0)), + sf(20, deps(19)), + sf(21, deps(19)), + sf(23, deps(19)), + sf(24, deps(19)), + sf(25, deps(19)), + sf(32, deps(0)), + sf(33, deps(0)), + sf(35, deps(0)), + sf(37, deps(35)), + sf(38, deps(35)), + sf(41, deps(0)), + sf(42, deps(0)), + sf(43, deps(0)), + sf(44, deps(0)), + sf(45, deps(44)), + sf(47, deps(0)), + sf(49, deps(0)), + sf(50, deps(4)), + sf(51, deps(6)), + sf(52, deps(7)), + sf(53, deps(9)), + sf(54, deps(9)), + sf(55, deps(9)), + sf(59, deps(18)), + sf(60, deps(18)), + sf(61, deps(18)), + sf(62, deps(0)), + sf(63, deps(19)), + sf(64, deps(19)), + sf(65, deps(19)), + sf(66, deps(19)), + sf(67, deps(19)), + sf(73, deps(6)), + sf(74, deps(6, 73)), + sf(75, deps(32)), + sf(76, deps(0)), + sf(77, deps(76)), + sf(78, deps(0)), + sf(81, deps(6)), + sf(82, deps(35)), + sf(83, deps(35)), + sf(84, deps(35)), + sf(85, deps(35)), + sf(86, deps(35)), + sf(87, deps(35, 37, 38, 83, 84, 85)), + sf(93, deps(42)), + sf(94, deps(0)), + sf(95, deps(43)), + sf(96, deps(44, 45)), + sf(97, deps(0)), + sf(98, deps(0, 97)), + sf(99, deps(0)), + sf(100, deps(99)), + sf(101, deps(43)), + sf(102, deps(0)), + sf(103, deps(0)), + sf(104, deps(49)), + sf(105, deps(9, 11, 12, 13, 14, 54, 55)), + sf(106, deps(18, 60, 61)), + sf(107, deps(19, 21, 64, 65)), + sf(108, deps(19, 20, 25, 63, 66, 67)), + sf(110, deps(76)), + sf(112, deps(76)), + sf(113, deps(76)), + sf(114, deps(78)), + sf(115, deps(78)), + sf(117, deps(35, 84, 85, 86, 87)), + sf(118, deps(93)), + sf(122, deps(95)), + sf(128, deps(103)), + sf(138, deps(9, 10, 11, 12, 53, 105)), + sf(139, deps(19, 21, 63, 64, 107)), + sf(141, deps(0, 76, 77, 86, 110, 112, 113)), + sf(142, deps(0, 78, 114)), + sf(143, deps(0, 78, 115)), + sf(144, deps(0, 78, 115)), + sf(145, deps(0, 8, 35, 37, 82, 117)), + sf(146, deps(0, 8, 14, 94)), + sf(147, deps(0, 86, 99, 100, 110)), + sf(148, deps(147)), + sf(149, deps(0, 13, 14, 54, 55, 102, 103, 110, 128)), + sf(150, deps(19, 20, 23, 24, 25, 63, 108, 139)), + sf(151, deps(147, 148)), + sf(152, deps(19, 150)), + ) +} + +// BenchmarkOrganizeFetchTree runs the dependency-aware scheduler against the legacy wave pipeline. +func BenchmarkOrganizeFetchTree(b *testing.B) { + // Each fetch tree input is ~50 kb / ~400 allocs overhead per iteration below. + b.Run("schedule", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + input := deeplyNestedWideDependenciesInput() + root := resolve.Sequence(input...) + err := (&scheduleFetches{}).ProcessFetchTree(root) + if err != nil { + b.Errorf("error: %v", err) + } + } + }) + b.Run("waves", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + input := deeplyNestedWideDependenciesInput() + root := resolve.Sequence(input...) + (&orderSequenceByDependencies{}).ProcessFetchTree(root) + (&createParallelNodes{}).ProcessFetchTree(root) + } + }) +} From 646559015c24514c3f1301f9f4c5a5aa2ec42492 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:07:21 +0300 Subject: [PATCH 15/24] fix more --- v2/pkg/engine/postprocess/schedule_fetches_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index c20c273bdd..f2b542a6ea 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -758,7 +758,7 @@ func TestScheduleFetches_Scenarios(t *testing.T) { t.Parallel() dag, err := newFetchDAG(tc.input) require.NoError(t, err) - ids := dag.sortedIDs() + ids := sortedIDs(dag) actualInlined, inlinedErr := schedule(ids, dag, true) actualWaves, wavesErr := schedule(ids, dag, false) @@ -982,7 +982,7 @@ func TestScheduleFetches_BigPlan(t *testing.T) { }) } -func (d *fetchDAG) sortedIDs() []int { +func sortedIDs(d *fetchDAG) []int { ids := make([]int, 0, len(d.nodes)) for id := range d.nodes { ids = append(ids, id) @@ -992,7 +992,7 @@ func (d *fetchDAG) sortedIDs() []int { } // hasCycle runs Kahn's algorithm over the full DAG. -func (d *fetchDAG) hasCycle() bool { +func hasCycle(d *fetchDAG) bool { indegree := make(map[int]int, len(d.nodes)) queue := make([]int, 0, len(d.nodes)) for id, parents := range d.parents { @@ -1039,11 +1039,11 @@ func checkScheduleProperties(t *testing.T, input []*resolve.FetchTreeNode, profi t.Helper() dag, err := newFetchDAG(input) require.NoError(t, err) - ids := dag.sortedIDs() + ids := sortedIDs(dag) inlined, inlinedErr := schedule(ids, dag, true) waves, wavesErr := schedule(ids, dag, false) winner, winnerErr := buildScheduleTree(input, dag) - if len(ids) > 0 && dag.hasCycle() { + if len(ids) > 0 && hasCycle(dag) { require.EqualError(t, inlinedErr, "cycle detected in fetch dependency graph") require.EqualError(t, wavesErr, "cycle detected in fetch dependency graph") require.EqualError(t, winnerErr, "cycle detected in fetch dependency graph") From 36ad1dde70b1fc2c5555f1b2a12858c750af0000 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:38:29 +0300 Subject: [PATCH 16/24] fix lint --- v2/pkg/engine/postprocess/schedule_fetches_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index f2b542a6ea..b4f57e4141 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -838,21 +838,24 @@ func TestScheduleFetches_SubscriptionRootStaysSequence(t *testing.T) { single := seq(sf(0)) single.Trigger = trigger - (&scheduleFetches{}).ProcessFetchTree(single) + err := (&scheduleFetches{}).ProcessFetchTree(single) + require.Nil(t, err) require.Equal(t, resolve.FetchTreeNodeKindSequence, single.Kind) require.Equal(t, trigger, single.Trigger) require.Equal(t, nodes(sf(0)), single.ChildNodes) parallel := seq(sf(0), sf(1)) parallel.Trigger = trigger - (&scheduleFetches{}).ProcessFetchTree(parallel) + err = (&scheduleFetches{}).ProcessFetchTree(parallel) + require.Nil(t, err) require.Equal(t, resolve.FetchTreeNodeKindSequence, parallel.Kind) require.Equal(t, trigger, parallel.Trigger) require.Equal(t, nodes(par(sf(0), sf(1))), parallel.ChildNodes) // Without a Trigger the root may collapse into the scheduled tree. sync := seq(sf(0)) - (&scheduleFetches{}).ProcessFetchTree(sync) + err = (&scheduleFetches{}).ProcessFetchTree(sync) + require.Nil(t, err) require.Equal(t, sf(0), sync) } From 74b4cafdf18bedb34f4806b4b88ab4e3a71ac0b7 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:12:33 +0300 Subject: [PATCH 17/24] fix lint --- v2/pkg/engine/postprocess/schedule_fetches_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index b4f57e4141..c2017122bd 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -917,7 +917,7 @@ func TestScheduleFetches_ProcessorFallsBackOnError(t *testing.T) { scheduled := build() require.NotPanics(t, func() { - (&scheduleFetches{}).ProcessFetchTree(scheduled) + NewProcessor().fetchTreeProcessors.organizeFetchTree(scheduled) }) require.Equal(t, legacy, scheduled) } @@ -980,7 +980,7 @@ func TestScheduleFetches_BigPlan(t *testing.T) { t.Run("scheduler does not fall back to legacy waves", func(t *testing.T) { root := seq(input()...) - (&scheduleFetches{}).ProcessFetchTree(root) + NewProcessor().fetchTreeProcessors.organizeFetchTree(root) requireEqualTrees(t, expected, root) }) } From af47a6bd3e82b11ae3864d0bcac10af3fd59d686 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:22:55 +0300 Subject: [PATCH 18/24] fix lint --- .../postprocess/order_sequence_by_dependencies_test.go | 1 - v2/pkg/engine/postprocess/schedule_fetches_test.go | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/v2/pkg/engine/postprocess/order_sequence_by_dependencies_test.go b/v2/pkg/engine/postprocess/order_sequence_by_dependencies_test.go index e04596a6b3..d8f541ea1c 100644 --- a/v2/pkg/engine/postprocess/order_sequence_by_dependencies_test.go +++ b/v2/pkg/engine/postprocess/order_sequence_by_dependencies_test.go @@ -189,4 +189,3 @@ func TestOrderSequenceByDependencies_ProcessFetchTree(t *testing.T) { require.Equal(t, expected, input) }) } - diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index c2017122bd..ad1df2bf99 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -839,7 +839,7 @@ func TestScheduleFetches_SubscriptionRootStaysSequence(t *testing.T) { single := seq(sf(0)) single.Trigger = trigger err := (&scheduleFetches{}).ProcessFetchTree(single) - require.Nil(t, err) + require.NoError(t, err) require.Equal(t, resolve.FetchTreeNodeKindSequence, single.Kind) require.Equal(t, trigger, single.Trigger) require.Equal(t, nodes(sf(0)), single.ChildNodes) @@ -847,7 +847,7 @@ func TestScheduleFetches_SubscriptionRootStaysSequence(t *testing.T) { parallel := seq(sf(0), sf(1)) parallel.Trigger = trigger err = (&scheduleFetches{}).ProcessFetchTree(parallel) - require.Nil(t, err) + require.NoError(t, err) require.Equal(t, resolve.FetchTreeNodeKindSequence, parallel.Kind) require.Equal(t, trigger, parallel.Trigger) require.Equal(t, nodes(par(sf(0), sf(1))), parallel.ChildNodes) @@ -855,7 +855,7 @@ func TestScheduleFetches_SubscriptionRootStaysSequence(t *testing.T) { // Without a Trigger the root may collapse into the scheduled tree. sync := seq(sf(0)) err = (&scheduleFetches{}).ProcessFetchTree(sync) - require.Nil(t, err) + require.NoError(t, err) require.Equal(t, sf(0), sync) } From b0d1f9d51f2e823efd2a449bd3b5cbffc0cdbfac Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:05:58 +0300 Subject: [PATCH 19/24] merge multi-fetch batches before scheduling (merge-before-schedule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When both the scheduler and multi-fetch are enabled, organizeFetchTree runs the legacy wave pipeline as a scratch phase to discover maximal same-wave merge groups, merges them via createMultiFetch, flattens the wave tree back into the flat Sequence the scheduler consumes, and schedules the merged DAG. A MultiEntityFetch participates as one node (min member ID, union dependencies; dependents are rewired by mergeGroup), so this needs no scheduler changes. Rationale: scheduling before merging dissolves the same-wave antichains that batching needs (eBay celestial query: 71 requests vs 43), while merging first keeps maximal batching AND the scheduler's dominance proof then holds against the merged wave tree — the result is provably never slower than the legacy waves + multi-fetch pipeline, usually structurally better (independent chains leave the wave barriers). An adaptive in-scheduler variant (branch experiment/mf-in-scheduler) buys one more request on the eBay query at beta=0.005 but costs a cost model, policy portfolio and greedy search; not worth the complexity. Known follow-ups, unchanged from the multi-fetch review: the sjson v1.0.4 workspace pin (multi-fetch is a no-op in consumer builds without it), and computing waves internally instead of materializing the throwaway legacy tree. --- .../graphql_datasource_multi_fetch_test.go | 20 +++--- v2/pkg/engine/postprocess/postprocess.go | 61 +++++++++++++++++-- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_multi_fetch_test.go b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_multi_fetch_test.go index 5647f3f710..c1a66205df 100644 --- a/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_multi_fetch_test.go +++ b/v2/pkg/engine/datasource/graphql_datasource/graphql_datasource_multi_fetch_test.go @@ -353,7 +353,7 @@ func TestGraphQLDataSourceFederation_MultiFetch(t *testing.T) { }, multiFetchPlanConfig(t, true), WithFieldInfo(), - WithDefaultCustomPostProcessor(postprocess.EnableMultiFetch()), + WithPostProcessor(postprocess.EnableMultiFetch()), )) t.Run("flag off keeps two separate entity fetches", RunTest( @@ -490,7 +490,9 @@ func TestGraphQLDataSourceFederation_MultiFetch(t *testing.T) { }, multiFetchPlanConfig(t, false), WithFieldInfo(), - WithDefaultCustomPostProcessor(), + // Full default pipeline with only the scheduler replaced by the legacy + // organizers — the fixture pins rendered inputs and legacy wave shapes. + WithPostProcessor(), )) } @@ -817,7 +819,7 @@ func TestGraphQLDataSourceFederation_MultiFetch_ThreeFetchGroup(t *testing.T) { }, config, WithFieldInfo(), - WithDefaultCustomPostProcessor(postprocess.EnableMultiFetch()), + WithPostProcessor(postprocess.EnableMultiFetch()), )) } @@ -1102,7 +1104,7 @@ func TestGraphQLDataSourceFederation_MultiFetch_AdditionalVariables(t *testing.T }, multiFetchArgPlanConfig(t), WithFieldInfo(), - WithDefaultCustomPostProcessor(postprocess.EnableMultiFetch()), + WithPostProcessor(postprocess.EnableMultiFetch()), )) t.Run("same client variable through both members", RunTest( @@ -1162,7 +1164,7 @@ func TestGraphQLDataSourceFederation_MultiFetch_AdditionalVariables(t *testing.T }, multiFetchArgPlanConfig(t), WithFieldInfo(), - WithDefaultCustomPostProcessor(postprocess.EnableMultiFetch()), + WithPostProcessor(postprocess.EnableMultiFetch()), )) // A default-valued client variable ($first: Int = 10) merges without error. @@ -1230,7 +1232,7 @@ func TestGraphQLDataSourceFederation_MultiFetch_AdditionalVariables(t *testing.T }, multiFetchArgPlanConfig(t), WithFieldInfo(), - WithDefaultCustomPostProcessor(postprocess.EnableMultiFetch()), + WithPostProcessor(postprocess.EnableMultiFetch()), )) } @@ -1571,7 +1573,7 @@ func TestGraphQLDataSourceFederation_MultiFetch_WaveSeparation(t *testing.T) { }, config, WithFieldInfo(), - WithDefaultCustomPostProcessor(postprocess.EnableMultiFetch()), + WithPostProcessor(postprocess.EnableMultiFetch()), )) } @@ -1996,7 +1998,7 @@ func TestGraphQLDataSourceFederation_MultiFetch_Subscription(t *testing.T) { }, config, WithFieldInfo(), - WithDefaultCustomPostProcessor(postprocess.EnableMultiFetch()), + WithPostProcessor(postprocess.EnableMultiFetch()), )) } @@ -2100,7 +2102,7 @@ func TestGraphQLDataSourceFederation_MultiFetch_RepresentationsCollision(t *test }, multiFetchArgPlanConfig(t), WithFieldInfo(), - WithDefaultCustomPostProcessor(postprocess.EnableMultiFetch()), + WithPostProcessor(postprocess.EnableMultiFetch()), )(t) }) } diff --git a/v2/pkg/engine/postprocess/postprocess.go b/v2/pkg/engine/postprocess/postprocess.go index ff0b2334ac..e1be65ec8b 100644 --- a/v2/pkg/engine/postprocess/postprocess.go +++ b/v2/pkg/engine/postprocess/postprocess.go @@ -62,6 +62,20 @@ func (p *FetchTreeProcessors) processFlatFetchTree(response *resolve.GraphQLResp // After this step fetches have tree structure of serial and parallel nodes. func (p *FetchTreeProcessors) organizeFetchTree(fetches *resolve.FetchTreeNode) { if !p.scheduleFetches.disable && fetches != nil && fetches.Kind == resolve.FetchTreeNodeKindSequence { + if !p.createMultiFetch.disable { + // Merge-before-schedule: materialize the legacy wave structure only + // to discover maximal same-wave merge groups, merge them, then drop + // the wave structure and schedule the merged DAG. The scheduler sees + // each MultiEntityFetch as one node (min member ID, union + // dependencies), so batching is decided on wave granularity while + // the final execution tree still benefits from component splitting + // and chain inlining. The scheduler's dominance proof then holds + // against the merged wave tree — the result is never slower than + // the legacy waves + multi-fetch pipeline. + p.organizeFetchTreeInWaves(fetches) + p.createMultiFetch.ProcessFetchTree(fetches) + flattenFetchTree(fetches) + } if err := p.scheduleFetches.ProcessFetchTree(fetches); err == nil { return } @@ -72,15 +86,52 @@ func (p *FetchTreeProcessors) organizeFetchTree(fetches *resolve.FetchTreeNode) func (p *FetchTreeProcessors) organizeFetchTreeInWaves(fetches *resolve.FetchTreeNode) { p.orderSequenceByDependencies.ProcessFetchTree(fetches) p.createParallelNodes.ProcessFetchTree(fetches) +} +// flattenFetchTree collapses an organized tree back into the flat Sequence shape +// the scheduler consumes: the root keeps its identity (Trigger, NormalizedQuery) +// and its children become the in-order list of Single nodes. In-order collection +// of a dependency-ordered tree is already topologically sorted, which the legacy +// fallback inside scheduleFetches relies on. +func flattenFetchTree(root *resolve.FetchTreeNode) { + if root == nil || root.Kind != resolve.FetchTreeNodeKindSequence { + return + } + var singles []*resolve.FetchTreeNode + var walk func(node *resolve.FetchTreeNode) + walk = func(node *resolve.FetchTreeNode) { + if node == nil { + return + } + if node.Kind == resolve.FetchTreeNodeKindSingle { + singles = append(singles, node) + return + } + for _, child := range node.ChildNodes { + walk(child) + } + } + for _, child := range root.ChildNodes { + walk(child) + } + root.ChildNodes = singles } -// processOrganizedFetchTree runs the stages that operate on the tree organized in waves. +// processOrganizedFetchTree runs the stages that operate on the ORGANIZED tree, +// after organizeFetchTree has materialized the real sequence/parallel waves. +// It is applied to every response tree, including each extracted defer group, +// so that the same semantics hold for deferred fetches. func (p *FetchTreeProcessors) processOrganizedFetchTree(fetches *resolve.FetchTreeNode) { - // merge same-subgraph entity fetches within each real parallel group when enabled. - p.createMultiFetch.ProcessFetchTree(fetches) - // render the deferred entity-fetch input string for unmerged fetches and - // clear the SubgraphOperation artifacts so no AST survives postprocessing. + // Under the scheduler, merging already happened inside organizeFetchTree + // (merge-before-schedule). On the legacy pipeline the organized tree IS + // the wave structure, so merging runs here, right after organizeFetchTree. + if p.scheduleFetches.disable { + p.createMultiFetch.ProcessFetchTree(fetches) + } + // renderSubgraphInputs runs unconditionally after createMultiFetch and before + // resolveInputTemplates: it renders the deferred entity-fetch input string + // for surviving (unmerged) fetches and clears the SubgraphOperation artifacts + // so no AST survives postprocessing. p.renderSubgraphInputs.ProcessFetchTree(fetches) p.resolveInputTemplates.ProcessFetchTree(fetches) p.createConcreteSingleFetchTypes.ProcessFetchTree(fetches) From 2ee8b4ec6b5d8e78dc4604b063476b2de7975f3d Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:55:02 +0300 Subject: [PATCH 20/24] do merge, then schedule --- v2/pkg/engine/postprocess/postprocess.go | 32 ++++++++---------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/v2/pkg/engine/postprocess/postprocess.go b/v2/pkg/engine/postprocess/postprocess.go index e1be65ec8b..ea4fd9d3b3 100644 --- a/v2/pkg/engine/postprocess/postprocess.go +++ b/v2/pkg/engine/postprocess/postprocess.go @@ -56,31 +56,29 @@ func (p *FetchTreeProcessors) processFlatFetchTree(response *resolve.GraphQLResp p.addMissingNestedDependencies.ProcessFetchTree(fetches) } -// organizeFetchTree organizes the fetch tree by merging entity fetches first and -// then scheduling fetches in nested Parallel/Sequence trees. If scheduling failed, then -// the fetches are organized as a Sequence of waves (Parallel groups). -// After this step fetches have tree structure of serial and parallel nodes. +// organizeFetchTree merges same-subgraph entity fetches and organizes the tree into +// a nested Parallel/Sequence structure, either via the scheduler or +// as a Sequence of waves (Parallel groups) when the scheduler is disabled func (p *FetchTreeProcessors) organizeFetchTree(fetches *resolve.FetchTreeNode) { + merged := false if !p.scheduleFetches.disable && fetches != nil && fetches.Kind == resolve.FetchTreeNodeKindSequence { if !p.createMultiFetch.disable { // Merge-before-schedule: materialize the legacy wave structure only - // to discover maximal same-wave merge groups, merge them, then drop - // the wave structure and schedule the merged DAG. The scheduler sees - // each MultiEntityFetch as one node (min member ID, union - // dependencies), so batching is decided on wave granularity while - // the final execution tree still benefits from component splitting - // and chain inlining. The scheduler's dominance proof then holds - // against the merged wave tree — the result is never slower than - // the legacy waves + multi-fetch pipeline. + // to discover maximal same-wave merge groups, merge them, + // then drop the wave structure and schedule the merged DAG. p.organizeFetchTreeInWaves(fetches) p.createMultiFetch.ProcessFetchTree(fetches) flattenFetchTree(fetches) + merged = true } if err := p.scheduleFetches.ProcessFetchTree(fetches); err == nil { return } } p.organizeFetchTreeInWaves(fetches) + if !merged { + p.createMultiFetch.ProcessFetchTree(fetches) + } } func (p *FetchTreeProcessors) organizeFetchTreeInWaves(fetches *resolve.FetchTreeNode) { @@ -122,16 +120,6 @@ func flattenFetchTree(root *resolve.FetchTreeNode) { // It is applied to every response tree, including each extracted defer group, // so that the same semantics hold for deferred fetches. func (p *FetchTreeProcessors) processOrganizedFetchTree(fetches *resolve.FetchTreeNode) { - // Under the scheduler, merging already happened inside organizeFetchTree - // (merge-before-schedule). On the legacy pipeline the organized tree IS - // the wave structure, so merging runs here, right after organizeFetchTree. - if p.scheduleFetches.disable { - p.createMultiFetch.ProcessFetchTree(fetches) - } - // renderSubgraphInputs runs unconditionally after createMultiFetch and before - // resolveInputTemplates: it renders the deferred entity-fetch input string - // for surviving (unmerged) fetches and clears the SubgraphOperation artifacts - // so no AST survives postprocessing. p.renderSubgraphInputs.ProcessFetchTree(fetches) p.resolveInputTemplates.ProcessFetchTree(fetches) p.createConcreteSingleFetchTypes.ProcessFetchTree(fetches) From 76ff7687d7b8b23c8548f08fcc9aab2d59e15c4a Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:05:46 +0300 Subject: [PATCH 21/24] refactor merging + scheduling pipeline --- execution/engine/engine_config.go | 6 ++++++ execution/engine/execution_engine.go | 3 +++ .../datasourcetesting/datasourcetesting.go | 4 ---- v2/pkg/engine/postprocess/postprocess.go | 14 ++++++-------- v2/pkg/engine/postprocess/postprocess_test.go | 2 -- .../postprocess/resolve_input_templates_test.go | 2 -- .../engine/postprocess/schedule_fetches_test.go | 17 +++++++++-------- 7 files changed, 24 insertions(+), 24 deletions(-) diff --git a/execution/engine/engine_config.go b/execution/engine/engine_config.go index 209e83fe4c..4230273164 100644 --- a/execution/engine/engine_config.go +++ b/execution/engine/engine_config.go @@ -22,6 +22,7 @@ type Configuration struct { schema *graphql.Schema plannerConfig plan.Configuration websocketBeforeStartHook WebsocketBeforeStartHook + enableScheduleFetches bool } func NewConfiguration(schema *graphql.Schema) Configuration { @@ -79,6 +80,11 @@ func (e *Configuration) EnableMultiFetch() { e.plannerConfig.EnableMultiFetch = true } +// EnableScheduleFetches organizes fetches into component-split, chain-inlined trees. +func (e *Configuration) EnableScheduleFetches() { + e.enableScheduleFetches = true +} + type dataSourceGeneratorOptions struct { streamingClient *http.Client subscriptionType SubscriptionType diff --git a/execution/engine/execution_engine.go b/execution/engine/execution_engine.go index 8029cbf3bc..25f3f93556 100644 --- a/execution/engine/execution_engine.go +++ b/execution/engine/execution_engine.go @@ -158,6 +158,9 @@ func NewExecutionEngine(ctx context.Context, logger abstractlogger.Logger, engin if engineConfig.plannerConfig.EnableMultiFetch { postProcessorOptions = append(postProcessorOptions, postprocess.EnableMultiFetch()) } + if engineConfig.enableScheduleFetches { + postProcessorOptions = append(postProcessorOptions, postprocess.EnableScheduleFetches()) + } return &ExecutionEngine{ logger: logger, diff --git a/v2/pkg/engine/datasourcetesting/datasourcetesting.go b/v2/pkg/engine/datasourcetesting/datasourcetesting.go index b1fed049e1..67dbd2e07f 100644 --- a/v2/pkg/engine/datasourcetesting/datasourcetesting.go +++ b/v2/pkg/engine/datasourcetesting/datasourcetesting.go @@ -57,16 +57,12 @@ func WithDefaultPostProcessor() func(*testOptions) { postprocess.DisableCreateParallelNodes(), postprocess.DisableMergeFields(), postprocess.DisableCollectAuthorizationCoordinates(), - // plan fixtures pin flat dependency-ordered trees, not schedule trees - postprocess.DisableScheduleFetches(), ) } } func WithPostProcessor(options ...postprocess.ProcessorOption) func(*testOptions) { return func(o *testOptions) { - // plan fixtures pin flat dependency-ordered trees, not schedule trees - options = append(options, postprocess.DisableScheduleFetches()) o.postProcessor = postprocess.NewProcessor(options...) } } diff --git a/v2/pkg/engine/postprocess/postprocess.go b/v2/pkg/engine/postprocess/postprocess.go index ea4fd9d3b3..f24d258a1a 100644 --- a/v2/pkg/engine/postprocess/postprocess.go +++ b/v2/pkg/engine/postprocess/postprocess.go @@ -144,15 +144,13 @@ type processorOptions struct { disableBuildDeferTree bool disableCollectAuthorizationCoordinates bool enableMultiFetch bool - scheduleFetches bool - disableScheduleFetches bool + enableScheduleFetches bool } -// DisableScheduleFetches replaces the nested schedule-tree scheduler with the -// legacy orderSequenceByDependencies and createParallelNodes pair. -func DisableScheduleFetches() ProcessorOption { +// EnableScheduleFetches activates the nested schedule-tree scheduler. +func EnableScheduleFetches() ProcessorOption { return func(o *processorOptions) { - o.disableScheduleFetches = true + o.enableScheduleFetches = true } } @@ -279,7 +277,7 @@ func NewProcessor(options ...ProcessorOption) *Processor { disable: opts.disableCreateParallelNodes, }, scheduleFetches: &scheduleFetches{ - disable: opts.disableScheduleFetches, + disable: !opts.enableScheduleFetches, }, }, responseTreeProcessors: &ResponseTreeProcessors{ @@ -364,7 +362,7 @@ func (p *Processor) createFetchTree(res *resolve.GraphQLResponse) { children := make([]*resolve.FetchTreeNode, len(fetches)) if p.collectDataSourceInfo { - var list = make([]resolve.DataSourceInfo, 0, len(fetches)) + list := make([]resolve.DataSourceInfo, 0, len(fetches)) for _, fetch := range fetches { info := fetch.Fetch.FetchInfo() if info != nil { diff --git a/v2/pkg/engine/postprocess/postprocess_test.go b/v2/pkg/engine/postprocess/postprocess_test.go index 22ecfdb7bf..6395a2262f 100644 --- a/v2/pkg/engine/postprocess/postprocess_test.go +++ b/v2/pkg/engine/postprocess/postprocess_test.go @@ -364,7 +364,6 @@ func TestProcess_ExtractFetches(t *testing.T) { DisableMergeFields(), DisableCreateParallelNodes(), DisableAddMissingNestedDependencies(), - DisableScheduleFetches(), ) for _, c := range cases { @@ -731,7 +730,6 @@ func TestProcess_ExtractServiceNames(t *testing.T) { DisableCreateParallelNodes(), DisableAddMissingNestedDependencies(), CollectDataSourceInfo(), - DisableScheduleFetches(), ) for _, c := range cases { diff --git a/v2/pkg/engine/postprocess/resolve_input_templates_test.go b/v2/pkg/engine/postprocess/resolve_input_templates_test.go index e828cdbc54..9b42c99a8f 100644 --- a/v2/pkg/engine/postprocess/resolve_input_templates_test.go +++ b/v2/pkg/engine/postprocess/resolve_input_templates_test.go @@ -348,7 +348,6 @@ func TestDataSourceInput_Process(t *testing.T) { DisableCreateConcreteSingleFetchTypes(), DisableCreateParallelNodes(), DisableAddMissingNestedDependencies(), - DisableScheduleFetches(), ) processor.Process(pre) @@ -466,7 +465,6 @@ func TestDataSourceInput_ProcessTrigger(t *testing.T) { DisableDeduplicateSingleFetches(), DisableCreateConcreteSingleFetchTypes(), DisableAddMissingNestedDependencies(), - DisableScheduleFetches(), ) processor.Process(pre) diff --git a/v2/pkg/engine/postprocess/schedule_fetches_test.go b/v2/pkg/engine/postprocess/schedule_fetches_test.go index ad1df2bf99..3b64971873 100644 --- a/v2/pkg/engine/postprocess/schedule_fetches_test.go +++ b/v2/pkg/engine/postprocess/schedule_fetches_test.go @@ -798,7 +798,7 @@ func TestScheduleFetches_Scenarios(t *testing.T) { } } -func TestDisableScheduleFetches_OptionWiring(t *testing.T) { +func TestScheduleFetches_OptionWiring(t *testing.T) { t.Parallel() input := func() *resolve.FetchTreeNode { return seq( @@ -818,13 +818,14 @@ func TestDisableScheduleFetches_OptionWiring(t *testing.T) { seq(sf(1), sf(3, dependsOn(1)))), sf(4, dependsOn(2, 3))) + // The scheduler is opt-in: the default pipeline organizes legacy waves. + def := input() + NewProcessor().fetchTreeProcessors.organizeFetchTree(def) + requireEqualTrees(t, wantWaves, def) + scheduled := input() - NewProcessor().fetchTreeProcessors.organizeFetchTree(scheduled) + NewProcessor(EnableScheduleFetches()).fetchTreeProcessors.organizeFetchTree(scheduled) requireEqualTrees(t, wantScheduled, scheduled) - - waves := input() - NewProcessor(DisableScheduleFetches()).fetchTreeProcessors.organizeFetchTree(waves) - requireEqualTrees(t, wantWaves, waves) } func TestScheduleFetches_SubscriptionRootStaysSequence(t *testing.T) { @@ -917,7 +918,7 @@ func TestScheduleFetches_ProcessorFallsBackOnError(t *testing.T) { scheduled := build() require.NotPanics(t, func() { - NewProcessor().fetchTreeProcessors.organizeFetchTree(scheduled) + NewProcessor(EnableScheduleFetches()).fetchTreeProcessors.organizeFetchTree(scheduled) }) require.Equal(t, legacy, scheduled) } @@ -980,7 +981,7 @@ func TestScheduleFetches_BigPlan(t *testing.T) { t.Run("scheduler does not fall back to legacy waves", func(t *testing.T) { root := seq(input()...) - NewProcessor().fetchTreeProcessors.organizeFetchTree(root) + NewProcessor(EnableScheduleFetches()).fetchTreeProcessors.organizeFetchTree(root) requireEqualTrees(t, expected, root) }) } From 65d28f53b163c0a8ba11baad5da119641f5fc498 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:40:24 +0300 Subject: [PATCH 22/24] add integration tests for features' toggles --- .../execution_engine_schedule_fetches_test.go | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 execution/engine/execution_engine_schedule_fetches_test.go diff --git a/execution/engine/execution_engine_schedule_fetches_test.go b/execution/engine/execution_engine_schedule_fetches_test.go new file mode 100644 index 0000000000..9ef12acb98 --- /dev/null +++ b/execution/engine/execution_engine_schedule_fetches_test.go @@ -0,0 +1,260 @@ +package engine + +import ( + "context" + "testing" + + "github.com/jensneuse/abstractlogger" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/execution/graphql" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/graphql_datasource" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +// Two independent fetch chains: +// - alpha root -> b entity fetch, +// - and b root -> alpha entity fetch. +const scheduleFetchesSchema = ` + type Query { + a: A + aTwo: A + b: B + } + type A { + id: ID! + bField: String + } + type B { + id: ID! + aField: String + } +` +const scheduleFetchesAlphaSDL = ` + type Query { + a: A + aTwo: A + } + type A @key(fields: "id") { + id: ID! + } + type B @key(fields: "id") { + id: ID! + aField: String + } +` +const scheduleFetchesBetaSDL = ` + type Query { + b: B + } + type B @key(fields: "id") { + id: ID! + } + type A @key(fields: "id") { + id: ID! + bField: String + } +` +const scheduleFetchesQuery = ` + query { + a { bField } + b { aField } + } +` +const scheduleFetchesCombinedQuery = ` + query { + a { bField } + aTwo { bField } + b { aField } + } +` + +const ( + scheduleFetchesAlphaRootBody = `{"query":"{a {__typename id}}"}` + scheduleFetchesAlphaRootData = `{"data":{"a":{"__typename":"A","id":"1"}}}` + scheduleFetchesAlphaEntityBody = `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on B {__typename aField}}}","variables":{"representations":[{"__typename":"B","id":"2"}]}}` + scheduleFetchesAlphaEntityData = `{"data":{"_entities":[{"__typename":"B","aField":"a"}]}}` + + scheduleFetchesBetaRootBody = `{"query":"{b {__typename id}}"}` + scheduleFetchesBetaRootData = `{"data":{"b":{"__typename":"B","id":"2"}}}` + scheduleFetchesBetaEntityBody = `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on A {__typename bField}}}","variables":{"representations":[{"__typename":"A","id":"1"}]}}` + scheduleFetchesBetaEntityData = `{"data":{"_entities":[{"__typename":"A","bField":"b"}]}}` + + scheduleFetchesClientResponse = `{"data":{"a":{"bField":"b"},"b":{"aField":"a"}}}` + + scheduleFetchesCombinedAlphaRootBody = `{"query":"{a {__typename id} aTwo {__typename id}}"}` + scheduleFetchesCombinedAlphaRootData = `{"data":{"a":{"__typename":"A","id":"1"},"aTwo":{"__typename":"A","id":"3"}}}` + scheduleFetchesMergedBetaBody = `{"query":"query($representations_f1: [_Any!]!, $includeF1: Boolean!, $representations_f2: [_Any!]!, $includeF2: Boolean!){f1: _entities(representations: $representations_f1)@include(if: $includeF1) {... on A {__typename bField}} f2: _entities(representations: $representations_f2)@include(if: $includeF2) {... on A {__typename bField}}}","variables":{"representations_f1":[{"__typename":"A","id":"1"}],"includeF1":true,"representations_f2":[{"__typename":"A","id":"3"}],"includeF2":true}}` + scheduleFetchesMergedBetaData = `{"data":{"f1":[{"__typename":"A","bField":"b"}],"f2":[{"__typename":"A","bField":"b2"}]}}` + + scheduleFetchesCombinedClientResponse = `{"data":{"a":{"bField":"b"},"aTwo":{"bField":"b2"},"b":{"aField":"a"}}}` +) + +func scheduleFetchesDataSource(t *testing.T, name, sdl string, rec *multiFetchRecorder, responses map[string]sendResponse, rootNodes []plan.TypeField) plan.DataSource { + t.Helper() + + return mustGraphqlDataSourceConfiguration(t, + name, + mustFactory(t, recordingClient(t, rec, name, "/", responses)), + &plan.DataSourceMetadata{ + RootNodes: rootNodes, + FederationMetaData: plan.FederationMetaData{ + Keys: plan.FederationFieldConfigurations{ + {TypeName: "A", SelectionSet: "id"}, + {TypeName: "B", SelectionSet: "id"}, + }, + }, + }, + mustConfiguration(t, graphql_datasource.ConfigurationInput{ + Fetch: &graphql_datasource.FetchConfiguration{URL: "http://" + name + "/", Method: "POST"}, + SchemaConfiguration: mustSchemaConfig(t, + &graphql_datasource.FederationConfiguration{Enabled: true, ServiceSDL: sdl}, + sdl, + ), + }), + ) +} + +func scheduleFetchesDataSources(t *testing.T, aRec, bRec *multiFetchRecorder) []plan.DataSource { + t.Helper() + + a := scheduleFetchesDataSource(t, "a", scheduleFetchesAlphaSDL, aRec, + map[string]sendResponse{ + scheduleFetchesAlphaRootBody: {statusCode: 200, body: scheduleFetchesAlphaRootData}, + scheduleFetchesAlphaEntityBody: {statusCode: 200, body: scheduleFetchesAlphaEntityData}, + scheduleFetchesCombinedAlphaRootBody: {statusCode: 200, body: scheduleFetchesCombinedAlphaRootData}, + }, + []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"a", "aTwo"}}, + {TypeName: "A", FieldNames: []string{"id"}}, + {TypeName: "B", FieldNames: []string{"id", "aField"}}, + }) + + b := scheduleFetchesDataSource(t, "b", scheduleFetchesBetaSDL, bRec, + map[string]sendResponse{ + scheduleFetchesBetaRootBody: {statusCode: 200, body: scheduleFetchesBetaRootData}, + scheduleFetchesBetaEntityBody: {statusCode: 200, body: scheduleFetchesBetaEntityData}, + scheduleFetchesMergedBetaBody: {statusCode: 200, body: scheduleFetchesMergedBetaData}, + }, + []plan.TypeField{ + {TypeName: "Query", FieldNames: []string{"b"}}, + {TypeName: "B", FieldNames: []string{"id"}}, + {TypeName: "A", FieldNames: []string{"id", "bField"}}, + }) + + return []plan.DataSource{a, b} +} + +// runScheduleFetchesQuery plans and executes the query and returns +// the response body, the organized fetch tree, and the request bodies each subgraph received. +func runScheduleFetchesQuery(t *testing.T, query string, enableMultiFetch, enableScheduleFetches bool) (string, *resolve.FetchTreeNode, []string, []string) { + t.Helper() + + schema, err := graphql.NewSchemaFromString(scheduleFetchesSchema) + require.NoError(t, err) + + aRec, bRec := &multiFetchRecorder{}, &multiFetchRecorder{} + engineConf := NewConfiguration(schema) + engineConf.SetDataSources(scheduleFetchesDataSources(t, aRec, bRec)) + if enableMultiFetch { + engineConf.EnableMultiFetch() + } + if enableScheduleFetches { + engineConf.EnableScheduleFetches() + } + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + engine, err := NewExecutionEngine(ctx, abstractlogger.Noop{}, engineConf, resolve.ResolverOptions{MaxConcurrency: 1024}) + require.NoError(t, err) + + operation := graphql.Request{Query: query} + resultWriter := graphql.NewEngineResultWriter() + require.NoError(t, engine.Execute(ctx, &operation, &resultWriter)) + + require.Equal(t, 1, engine.executionPlanCache.Len()) + _, cachedPlan, ok := engine.executionPlanCache.GetOldest() + require.True(t, ok) + syncPlan, ok := cachedPlan.(*plan.SynchronousResponsePlan) + require.True(t, ok) + return resultWriter.String(), syncPlan.Response.Fetches, aRec.requests(), bRec.requests() +} + +func nodeKinds(nodes []*resolve.FetchTreeNode) []resolve.FetchTreeNodeKind { + kinds := make([]resolve.FetchTreeNodeKind, len(nodes)) + for i, n := range nodes { + kinds[i] = n.Kind + } + return kinds +} + +func TestExecutionEngine_ScheduleFetches(t *testing.T) { + // Request bodies are asserted as sets: under the scheduler the chains progress independently, + // so arrival order at a host is not deterministic. + cases := []struct { + name string + query string + multiFetch, schedule bool + response string + aReqs, bReqs []string + rootKind resolve.FetchTreeNodeKind + childKinds []resolve.FetchTreeNodeKind + }{ + { + name: "default organizes legacy waves", + query: scheduleFetchesQuery, + response: scheduleFetchesClientResponse, + aReqs: []string{scheduleFetchesAlphaRootBody, scheduleFetchesAlphaEntityBody}, + bReqs: []string{scheduleFetchesBetaRootBody, scheduleFetchesBetaEntityBody}, + rootKind: resolve.FetchTreeNodeKindSequence, + childKinds: []resolve.FetchTreeNodeKind{resolve.FetchTreeNodeKindParallel, resolve.FetchTreeNodeKindParallel}, + }, + { + name: "scheduling organizes independent inlined chains", + query: scheduleFetchesQuery, + schedule: true, + response: scheduleFetchesClientResponse, + aReqs: []string{scheduleFetchesAlphaRootBody, scheduleFetchesAlphaEntityBody}, + bReqs: []string{scheduleFetchesBetaRootBody, scheduleFetchesBetaEntityBody}, + rootKind: resolve.FetchTreeNodeKindParallel, + childKinds: []resolve.FetchTreeNodeKind{resolve.FetchTreeNodeKindSequence, resolve.FetchTreeNodeKindSequence}, + }, + { + name: "multi fetch without scheduling merges within legacy waves", + query: scheduleFetchesCombinedQuery, + multiFetch: true, + response: scheduleFetchesCombinedClientResponse, + aReqs: []string{scheduleFetchesCombinedAlphaRootBody, scheduleFetchesAlphaEntityBody}, + // The two same-wave b entity fetches merged into one aliased request, + // while the tree keeps the legacy wave shape. + bReqs: []string{scheduleFetchesBetaRootBody, scheduleFetchesMergedBetaBody}, + rootKind: resolve.FetchTreeNodeKindSequence, + childKinds: []resolve.FetchTreeNodeKind{resolve.FetchTreeNodeKindParallel, resolve.FetchTreeNodeKindParallel}, + }, + { + name: "multi fetch and scheduling together produce two inlined chains", + query: scheduleFetchesCombinedQuery, + multiFetch: true, + schedule: true, + response: scheduleFetchesCombinedClientResponse, + aReqs: []string{scheduleFetchesCombinedAlphaRootBody, scheduleFetchesAlphaEntityBody}, + // The two same-wave b entity fetches merged into one aliased request. + bReqs: []string{scheduleFetchesBetaRootBody, scheduleFetchesMergedBetaBody}, + rootKind: resolve.FetchTreeNodeKindParallel, + childKinds: []resolve.FetchTreeNodeKind{resolve.FetchTreeNodeKindSequence, resolve.FetchTreeNodeKindSequence}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + response, fetches, aReqs, bReqs := runScheduleFetchesQuery(t, tc.query, tc.multiFetch, tc.schedule) + require.Equal(t, tc.response, response) + require.ElementsMatch(t, tc.aReqs, aReqs) + require.ElementsMatch(t, tc.bReqs, bReqs) + require.Equal(t, tc.rootKind, fetches.Kind) + require.Equal(t, tc.childKinds, nodeKinds(fetches.ChildNodes)) + }) + } +} From 6a3eae198e47286ddbc2108f63b94dbb0be3fe91 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:45:43 +0300 Subject: [PATCH 23/24] bump golang.org/x/text to v0.39.0 --- execution/go.mod | 14 +++++++------- execution/go.sum | 28 ++++++++++++++-------------- go.work.sum | 10 ++++++---- v2/go.mod | 14 +++++++------- v2/go.sum | 28 ++++++++++++++-------------- 5 files changed, 48 insertions(+), 46 deletions(-) diff --git a/execution/go.mod b/execution/go.mod index 5d4926d1fb..d21c875ed9 100644 --- a/execution/go.mod +++ b/execution/go.mod @@ -67,13 +67,13 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/text v0.35.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.39.0 // indirect + golang.org/x/tools v0.47.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/execution/go.sum b/execution/go.sum index c257716c37..a936169063 100644 --- a/execution/go.sum +++ b/execution/go.sum @@ -191,19 +191,19 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -217,15 +217,15 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= @@ -233,8 +233,8 @@ golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/go.work.sum b/go.work.sum index 20390f9ce0..61178be133 100644 --- a/go.work.sum +++ b/go.work.sum @@ -406,8 +406,6 @@ github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= @@ -479,8 +477,6 @@ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+x github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083/go.mod h1:eOTL6acwctsN4F3b7YE+eE2t8zcJ/doLm9sZzsxxxrE= github.com/wundergraph/go-arena v0.0.0-20251008210416-55cb97e6f68f h1:5snewyMaIpajTu4wj22L/DgrGimICqXtUVjkZInBH3Y= github.com/wundergraph/go-arena v0.0.0-20251008210416-55cb97e6f68f/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw= -github.com/wundergraph/go-arena v1.3.0 h1:n0ng5a1vbd8YGq1u3rMr0vPU5f6AZ1BXIiUhL1UIok8= -github.com/wundergraph/go-arena v1.3.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw= github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.231/go.mod h1:ErOQH1ki2+SZB8JjpTyGVnoBpg5picIyjvuWQJP4abg= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= @@ -573,6 +569,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= @@ -653,6 +651,8 @@ golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2r golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0= golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57 h1:nwGZBCt+FnXUrGsj5vjzAsEmkcaFvd82BbOjECiFYZc= +golang.org/x/telemetry v0.0.0-20260625142307-59b4966ccb57/go.mod h1:3AWMyWHS+caVoiEXpiq6+tzKA40J4vQT3MYr80ZtQpc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= @@ -668,6 +668,7 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= @@ -679,6 +680,7 @@ golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= diff --git a/v2/go.mod b/v2/go.mod index 9d3eefbf40..395b1d7fdc 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -32,10 +32,10 @@ require ( github.com/wundergraph/astjson v1.1.0 github.com/wundergraph/go-arena v1.3.0 go.uber.org/goleak v1.3.0 - golang.org/x/net v0.52.0 - golang.org/x/sync v0.20.0 - golang.org/x/sys v0.42.0 - golang.org/x/text v0.35.0 + golang.org/x/net v0.56.0 + golang.org/x/sync v0.21.0 + golang.org/x/sys v0.46.0 + golang.org/x/text v0.39.0 gonum.org/v1/gonum v0.17.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 @@ -75,9 +75,9 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/tools v0.47.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/v2/go.sum b/v2/go.sum index 30bc207aeb..c9f08c8a8d 100644 --- a/v2/go.sum +++ b/v2/go.sum @@ -183,19 +183,19 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -209,15 +209,15 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= @@ -225,8 +225,8 @@ golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 9af042878ae31290fe9815a5606dca024421bf22 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:17:08 +0300 Subject: [PATCH 24/24] replace deprecated h2c --- v2/go.mod | 2 +- .../grpc_datasource/grpc_datasource_connect_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/v2/go.mod b/v2/go.mod index 395b1d7fdc..8e1e278d9b 100644 --- a/v2/go.mod +++ b/v2/go.mod @@ -32,7 +32,6 @@ require ( github.com/wundergraph/astjson v1.1.0 github.com/wundergraph/go-arena v1.3.0 go.uber.org/goleak v1.3.0 - golang.org/x/net v0.56.0 golang.org/x/sync v0.21.0 golang.org/x/sys v0.46.0 golang.org/x/text v0.39.0 @@ -76,6 +75,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/term v0.44.0 // indirect golang.org/x/tools v0.47.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource_connect_test.go b/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource_connect_test.go index 21d676e0a4..a1e6274da4 100644 --- a/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource_connect_test.go +++ b/v2/pkg/engine/datasource/grpc_datasource/grpc_datasource_connect_test.go @@ -10,8 +10,6 @@ import ( "github.com/stretchr/testify/require" "github.com/tidwall/gjson" - "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" "google.golang.org/grpc/metadata" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" @@ -36,8 +34,10 @@ func setupTestConnectServer(t testing.TB) (baseURL string, cleanup func()) { mux := http.NewServeMux() mux.Handle(productv1connect.NewProductServiceHandler(connectImpl)) - srv := httptest.NewUnstartedServer(h2c.NewHandler(mux, &http2.Server{})) - srv.EnableHTTP2 = true + srv := httptest.NewUnstartedServer(mux) + srv.Config.Protocols = new(http.Protocols) + srv.Config.Protocols.SetHTTP1(true) + srv.Config.Protocols.SetUnencryptedHTTP2(true) srv.Start() cleanup = srv.Close