Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs-website/router/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2058,6 +2058,8 @@ Configure the GraphQL Execution Engine of the Router.
| ENGINE_VALIDATE_REQUIRED_EXTERNAL_FIELDS | validate_required_external_fields | <Icon icon="square" /> | Validate nullable external "@requires" dependencies. When a subgraph entity fetch returns a null value with an error for a field set specified in the "@requires" directive, any following fetch that depends on it should not receive such an entity. | false |
| ENGINE_SUBSCRIPTION_FETCH_TIMEOUT | subscription_fetch_timeout | <Icon icon="square" /> | The maximum time a subscription fetch can take before it is considered timed out. | 30s |
| ENGINE_ENABLE_DEFER | enable_defer | <Icon icon="square" /> | Enables support for the `@defer` directive, allowing clients to defer parts of a query so that the initial response is returned faster and deferred fields are streamed incrementally. | false |
| ENGINE_ENABLE_MULTI_FETCH | enable_multi_fetch | <Icon icon="square" /> | Merges entity fetches that target the same subgraph and execute at the same point in the query plan into a single request with aliased `_entities` fields. This reduces the number of requests sent to subgraphs. | false |
Comment thread
ysmolski marked this conversation as resolved.
| ENGINE_ENABLE_SCHEDULE_FETCHES | enable_schedule_fetches | <Icon icon="square" /> | Replaces the wave-based fetch execution with a dependency-aware fetch scheduler. Independent fetch chains progress as soon as their own dependencies complete, instead of waiting for the slowest fetch in each wave. | false |
| ENGINE_RELAX_SUBGRAPH_OPERATION_FIELD_SELECTION_MERGING_NULLABILITY | relax_subgraph_operation_field_selection_merging_nullability | <Icon icon="square" /> | Relaxes nullability validation for [field selection merging](/router/relaxed-field-selection-merging-nullability) across union member types. | false |
| ENGINE_ALLOW_STRING_LITERALS_FOR_ENUMS | allow_string_literals_for_enums | <Icon icon="square" /> | Accepts a [string literal where an enum value is expected](/router/string-literals-for-enums) if the string content matches one of the enum's values. This is a deviation from the GraphQL specification. | false |

Expand Down Expand Up @@ -2106,6 +2108,8 @@ engine:
disable_exposing_variables_content_on_validation_error: false
enable_subgraph_fetch_operation_name: true
subscription_fetch_timeout: 30s
enable_multi_fetch: false
enable_schedule_fetches: false
relax_subgraph_operation_field_selection_merging_nullability: false
allow_string_literals_for_enums: false
```
Expand Down
2 changes: 1 addition & 1 deletion router-tests/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ require (
github.com/wundergraph/cosmo/router v0.0.0-20260710155145-803a4bc06d92
github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e
github.com/wundergraph/cosmo/speedtrap v0.0.0-00010101000000-000000000000
github.com/wundergraph/graphql-go-tools/v2 v2.15.0
github.com/wundergraph/graphql-go-tools/v2 v2.16.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
Expand Down
4 changes: 2 additions & 2 deletions router-tests/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,8 @@ github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLV
github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw=
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.15.0 h1:AG4l/QZrj1IBqxxPzz40r31fmKBopXQw+UjsgHYu8j4=
github.com/wundergraph/graphql-go-tools/v2 v2.15.0/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo=
github.com/wundergraph/graphql-go-tools/v2 v2.16.0 h1:zZ8XuHGfkWkMrqKvy2vc5u//Z94/t01leQhrTTbVOxo=
github.com/wundergraph/graphql-go-tools/v2 v2.16.0/go.mod h1:Q0DH6cCkFM/LAUT2ETlo6AMIZhUklZczF2I6uWK9HSA=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
Expand Down
198 changes: 198 additions & 0 deletions router-tests/operations/multi_fetches_scheduling_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package integration

import (
"encoding/json"
"testing"

"github.com/sebdah/goldie/v2"
"github.com/stretchr/testify/require"

"github.com/wundergraph/cosmo/router-tests/testenv"
"github.com/wundergraph/cosmo/router/pkg/config"
)

func TestMultiFetch(t *testing.T) {
t.Parallel()

// multiFetchQuery resolves Consultancy.lead from the employees subgraph, then needs
// two follow-up entity fetches: Employee.derivedMood and Consultancy.isLeadAvailable
const multiFetchQuery = `query Requires {
products {
__typename
... on Consultancy {
lead {
__typename
id
derivedMood
}
isLeadAvailable
}
}
}`
const multiFetchExpectedResponse = `{"data":{"products":[{"__typename":"Consultancy","lead":{"__typename":"Employee","id":1,"derivedMood":"HAPPY"},"isLeadAvailable":false},{"__typename":"Cosmo"},{"__typename":"SDK"}]}}`

t.Run("merges same-wave entity fetches to the same subgraph when enabled", func(t *testing.T) {
t.Parallel()

testenv.Run(t, &testenv.Config{
ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) {
cfg.EnableMultiFetch = true
},
}, func(t *testing.T, xEnv *testenv.Environment) {
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: multiFetchQuery})
require.JSONEq(t, multiFetchExpectedResponse, res.Body)

// Root fetch + one merged entity fetch.
require.EqualValues(t, 2, xEnv.SubgraphRequestCount.Employees.Load())

require.EqualValues(t, 1, xEnv.SubgraphRequestCount.Mood.Load())
require.EqualValues(t, 1, xEnv.SubgraphRequestCount.Availability.Load())
require.EqualValues(t, 4, xEnv.SubgraphRequestCount.Global.Load())
})
})

t.Run("sends one request per entity fetch by default", func(t *testing.T) {
t.Parallel()

testenv.Run(t, &testenv.Config{}, func(t *testing.T, xEnv *testenv.Environment) {
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: multiFetchQuery})
// Merging must not change the result: same response as the merged case above.
require.JSONEq(t, multiFetchExpectedResponse, res.Body)

// Root fetch + two separate entity fetches.
require.EqualValues(t, 3, xEnv.SubgraphRequestCount.Employees.Load())

require.EqualValues(t, 1, xEnv.SubgraphRequestCount.Mood.Load())
require.EqualValues(t, 1, xEnv.SubgraphRequestCount.Availability.Load())
require.EqualValues(t, 5, xEnv.SubgraphRequestCount.Global.Load())
})
})
}

// queryPlanNode is a minimal mirror of the query plan JSON for structural assertions.
type queryPlanNode struct {
Kind string `json:"kind"`
Children []queryPlanNode `json:"children"`
Fetch *struct {
Kind string `json:"kind"`
SubgraphName string `json:"subgraphName"`
} `json:"fetch"`
}

func (n *queryPlanNode) fetchKinds() []string {
var kinds []string
if n.Fetch != nil {
kinds = append(kinds, n.Fetch.Kind)
}
for i := range n.Children {
kinds = append(kinds, n.Children[i].fetchKinds()...)
}
return kinds
}

// TestMultiFetchAndScheduleFetches verifies that enable_multi_fetch and enable_schedule_fetches
// can be enabled together and that both affect the generated query plan.
func TestMultiFetchAndScheduleFetches(t *testing.T) {
t.Parallel()

newGoldie := func(t *testing.T) *goldie.Goldie {
return goldie.New(
t,
goldie.WithFixtureDir("testdata/fixtures/query_plans"),
goldie.WithNameSuffix(".json"),
goldie.WithDiffEngine(goldie.ClassicDiff),
)
}
bothFlags := func(cfg *config.EngineExecutionConfiguration) {
cfg.EnableMultiFetch = true
cfg.EnableScheduleFetches = true
cfg.Debug.AlwaysIncludeQueryPlan = true
}

queryPlanFromBody := func(t *testing.T, body string) queryPlanNode {
t.Helper()
var resp struct {
Extensions struct {
QueryPlan queryPlanNode `json:"queryPlan"`
} `json:"extensions"`
}
require.NoError(t, json.Unmarshal([]byte(body), &resp))
return resp.Extensions.QueryPlan
}

t.Run("same-subgraph entity fetches merge into one MultiEntity fetch", func(t *testing.T) {
t.Parallel()

// Both employees entity fetches (derivedMood, isLeadAvailable requires)
// execute at the same point of the plan and merge.
const query = `query Requires {
products {
__typename
... on Consultancy {
lead {
__typename
id
derivedMood
}
isLeadAvailable
}
}
}`

testenv.Run(t, &testenv.Config{
ModifyEngineExecutionConfiguration: bothFlags,
}, func(t *testing.T, xEnv *testenv.Environment) {
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: query})
newGoldie(t).Assert(t, "response_with_query_plan_multi_fetch_schedule", indentedJSON(res.Body))

plan := queryPlanFromBody(t, res.Body)
require.Contains(t, plan.fetchKinds(), "MultiEntity")

// root fetch + 1 merged entity fetch to employees
require.EqualValues(t, 2, xEnv.SubgraphRequestCount.Employees.Load())

require.EqualValues(t, 1, xEnv.SubgraphRequestCount.Mood.Load())
require.EqualValues(t, 1, xEnv.SubgraphRequestCount.Availability.Load())
require.EqualValues(t, 4, xEnv.SubgraphRequestCount.Global.Load())
})
})

t.Run("independent chains are scheduled without wave barriers", func(t *testing.T) {
t.Parallel()

// Two independent chains: employee -> mood -> employees (derivedMood requires currentMood)
// and findEmployees (family) -> availability.
// The legacy organizer produces Sequence(Parallel, Parallel, ...) waves;
// the scheduler runs both chains side by side under a Parallel root.
const query = `query IndependentChains {
employee(id: 1) {
id
derivedMood
}
findEmployees(criteria: { nationality: GERMAN }) {
id
isAvailable
}
}`

testenv.Run(t, &testenv.Config{
ModifyEngineExecutionConfiguration: bothFlags,
}, func(t *testing.T, xEnv *testenv.Environment) {
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: query})
newGoldie(t).Assert(t, "response_with_query_plan_schedule_chains", indentedJSON(res.Body))

plan := queryPlanFromBody(t, res.Body)
require.Equal(t, "Parallel", plan.Kind, "scheduled plan must run independent chains in parallel")
require.Len(t, plan.Children, 2)
for _, chain := range plan.Children {
require.Equal(t, "Sequence", chain.Kind)
}

require.EqualValues(t, 2, xEnv.SubgraphRequestCount.Employees.Load())
require.EqualValues(t, 1, xEnv.SubgraphRequestCount.Family.Load())
require.EqualValues(t, 1, xEnv.SubgraphRequestCount.Mood.Load())
require.EqualValues(t, 1, xEnv.SubgraphRequestCount.Availability.Load())
require.EqualValues(t, 5, xEnv.SubgraphRequestCount.Global.Load())
})
})
}
Loading
Loading