Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
1,953 changes: 1,953 additions & 0 deletions execution/engine/execution_engine_cost_test.go

Large diffs are not rendered by default.

2,167 changes: 104 additions & 2,063 deletions execution/engine/execution_engine_test.go

Large diffs are not rendered by default.

380 changes: 376 additions & 4 deletions execution/engine/federation_caching_test.go

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions v2/pkg/engine/plan/datasource_configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,12 @@ func (d *dataSourceConfiguration[T]) CustomConfiguration() T {
return d.custom
}

// cloneForSplit creates a new dataSourceConfiguration with the same factory and custom config
// but with a new ID and metadata. Used internally by splitSingleDataSourceByRootFieldCaching.
func (d *dataSourceConfiguration[T]) cloneForSplit(newID string, metadata *DataSourceMetadata) (DataSource, error) {
return NewDataSourceConfigurationWithName[T](newID, d.name, d.factory, metadata, d.custom)
}

func (d *dataSourceConfiguration[T]) CreatePlannerConfiguration(logger abstractlogger.Logger, fetchConfig *objectFetchConfiguration, pathConfig *plannerPathsConfiguration, configuration *Configuration) PlannerConfiguration {
if configuration.RelaxSubgraphOperationFieldSelectionMergingNullability {
if relaxer, ok := d.factory.(SubgraphFieldSelectionMergingNullabilityRelaxer); ok {
Expand Down
5 changes: 5 additions & 0 deletions v2/pkg/engine/plan/datasource_filter_visitor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ func (b *dsBuilder) Id(id string) *dsBuilder {
b.ds.id = id
return b
}

func (b *dsBuilder) Name(name string) *dsBuilder {
b.ds.name = name
return b
}
func (b *dsBuilder) DS() DataSource {
if err := b.ds.DataSourceMetadata.Init(); err != nil {
panic(err)
Expand Down
176 changes: 176 additions & 0 deletions v2/pkg/engine/plan/datasource_split.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package plan

import "fmt"

// dataSourceSplitter is implemented by dataSourceConfiguration[T] to enable
// cloning a datasource with new ID and metadata during root field splitting.
type dataSourceSplitter interface {
cloneForSplit(newID string, metadata *DataSourceMetadata) (DataSource, error)
}

// SplitDataSourcesByRootFieldCaching splits datasources that have root field caching
// configured into separate per-field datasources. This ensures each cacheable root field
// gets its own fetch, enabling independent L2 caching per field.
//
// Why split? The planner merges root fields from the same datasource into a single fetch.
// This means a query like { me { id } cat { name } } produces one request to the subgraph.
// However, configureFetchCaching requires all root fields in a fetch to have identical
// cache configs. By splitting each cached root field into its own datasource, the planner
// creates separate fetches, and each fetch can have its own TTL and cache key.
//
// The split produces up to N+1 datasources from the original:
// - One datasource per cached root field (each with its own RootFieldCaching entry)
// - One remainder datasource for all uncached root fields (no RootFieldCaching)
//
// All split datasources share the same non-Query root nodes (entity types, Mutation,
// Subscription), child nodes, entity caching config, and federation metadata (keys,
// requires, provides). This preserves entity resolution capability across all splits.
func SplitDataSourcesByRootFieldCaching(dataSources []DataSource) ([]DataSource, error) {
var result []DataSource
for _, ds := range dataSources {
split, err := splitSingleDataSourceByRootFieldCaching(ds)
if err != nil {
return nil, fmt.Errorf("failed to split data source %s by root field caching: %w", ds.Id(), err)
}
result = append(result, split...)
}
return result, nil
}

func splitSingleDataSourceByRootFieldCaching(ds DataSource) ([]DataSource, error) {
fedConfig := ds.FederationConfiguration()

// No root field caching configured — nothing to split
if len(fedConfig.RootFieldCaching) == 0 {
return []DataSource{ds}, nil
}

// Check if the datasource supports cloning (all dataSourceConfiguration[T] do)
splitter, ok := ds.(dataSourceSplitter)
if !ok {
return []DataSource{ds}, nil
}

nodesAccess, ok := ds.(NodesAccess)
if !ok {
return []DataSource{ds}, nil
}

// Find the Query root node — we only split Query fields, not Mutation/Subscription
rootNodes := nodesAccess.ListRootNodes()
queryNodeIdx := -1
for i, node := range rootNodes {
if node.TypeName == "Query" {
queryNodeIdx = i
break
}
}
if queryNodeIdx == -1 {
// No Query root node — nothing to split (entity-only datasource)
return []DataSource{ds}, nil
}

// Partition Query fields into cached and uncached buckets
queryNode := rootNodes[queryNodeIdx]
var cachedFields, uncachedFields []string
for _, fieldName := range queryNode.FieldNames {
if fedConfig.RootFieldCaching.FindByTypeAndField("Query", fieldName) != nil {
cachedFields = append(cachedFields, fieldName)
} else {
uncachedFields = append(uncachedFields, fieldName)
}
}

// Skip splitting when there's only a single cached field and no uncached fields.
// A single-field datasource already gets its own fetch — splitting adds no benefit.
if len(cachedFields) <= 1 && len(uncachedFields) == 0 {
return []DataSource{ds}, nil
}

childNodes := nodesAccess.ListChildNodes()

// Collect non-Query root nodes (e.g. User entity, Mutation) — these are shared
// across all split datasources so entity resolution continues to work
var nonQueryRootNodes TypeFields
for _, node := range rootNodes {
if node.TypeName != "Query" {
nonQueryRootNodes = append(nonQueryRootNodes, node)
}
}

var result []DataSource

// Create one datasource per cached Query root field.
// Each gets a unique ID (original_rf_fieldName) and only its own cache config.
for _, fieldName := range cachedFields {
// Build root nodes: single Query field + all non-Query root nodes
splitRootNodes := make(TypeFields, 0, len(nonQueryRootNodes)+1)
splitRootNodes = append(splitRootNodes, TypeField{
TypeName: "Query",
FieldNames: []string{fieldName},
ExternalFieldNames: queryNode.ExternalFieldNames,
FetchReasonFields: queryNode.FetchReasonFields,
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how ExternalFieldNames and FetchReasonFields are used in the codebase
# to understand if they're field-specific or type-wide

echo "=== Checking ExternalFieldNames usage ==="
rg -n -C3 'ExternalFieldNames' --type=go | head -100

echo ""
echo "=== Checking FetchReasonFields usage ==="
rg -n -C3 'FetchReasonFields' --type=go | head -100

Repository: wundergraph/graphql-go-tools

Length of output: 15548


Filter FetchReasonFields to only include fields present in the split.

When splitting a TypeField, FetchReasonFields should be filtered to only include field names that exist in the split's FieldNames array. Currently, the entire array from the original queryNode is copied, which causes field coordinates to be registered for fields not actually present in the split.

For example, if the original has FetchReasonFields: ["userProfile"] and the split contains only FieldNames: ["settings"], the split's FetchReasonFields should be empty, not include "userProfile".

This happens at lines 108-113 and again at lines 133-138 where remainder datasources are created.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@v2/pkg/engine/plan/datasource_split.go` around lines 108 - 113, When creating
split TypeField entries (e.g., where splitRootNodes is appended from queryNode
and similarly for remainder datasources), filter queryNode.FetchReasonFields so
it only contains names present in the split's FieldNames slice; replace the
direct copy of queryNode.FetchReasonFields with a filtered slice computed by
checking membership against the new FieldNames (use the TypeField.FieldNames for
the split and the queryNode variable for source), ensuring both the split append
(the block that builds TypeField with TypeName "Query") and the remainder
datasource creation do the same filtering so no FetchReasonFields refer to
fields not in that split.

splitRootNodes = append(splitRootNodes, nonQueryRootNodes...)

// Attach only this field's cache config to the new datasource
cacheConfig := fedConfig.RootFieldCaching.FindByTypeAndField("Query", fieldName)
metadata := cloneMetadataForSplit(ds, splitRootNodes, childNodes)
metadata.FederationMetaData.RootFieldCaching = RootFieldCacheConfigurations{*cacheConfig}

splitID := fmt.Sprintf("%s_rf_%s", ds.Id(), fieldName)
splitDS, err := splitter.cloneForSplit(splitID, metadata)
if err != nil {
return nil, err
}
result = append(result, splitDS)
}

// Create a remainder datasource for uncached fields (if any).
// This keeps the original datasource ID so existing planner behavior is preserved.
if len(uncachedFields) > 0 {
remainderRootNodes := make(TypeFields, 0, len(nonQueryRootNodes)+1)
remainderRootNodes = append(remainderRootNodes, TypeField{
TypeName: "Query",
FieldNames: uncachedFields,
ExternalFieldNames: queryNode.ExternalFieldNames,
FetchReasonFields: queryNode.FetchReasonFields,
})
remainderRootNodes = append(remainderRootNodes, nonQueryRootNodes...)

metadata := cloneMetadataForSplit(ds, remainderRootNodes, childNodes)
// Explicitly clear root field caching — uncached fields should not inherit cache config
metadata.FederationMetaData.RootFieldCaching = nil

remainderDS, err := splitter.cloneForSplit(ds.Id(), metadata)
if err != nil {
return nil, err
}
result = append(result, remainderDS)
}

return result, nil
}

// cloneMetadataForSplit creates new DataSourceMetadata with the given root nodes
// while preserving all federation metadata, child nodes, and directives from the original.
func cloneMetadataForSplit(original DataSource, rootNodes, childNodes TypeFields) *DataSourceMetadata {
origFed := original.FederationConfiguration()
origDirectives := original.DirectiveConfigurations()

return &DataSourceMetadata{
RootNodes: rootNodes,
ChildNodes: childNodes,
Directives: origDirectives,
FederationMetaData: FederationMetaData{
Keys: origFed.Keys,
Requires: origFed.Requires,
Provides: origFed.Provides,
EntityInterfaces: origFed.EntityInterfaces,
InterfaceObjects: origFed.InterfaceObjects,
EntityCaching: origFed.EntityCaching,
RootFieldCaching: origFed.RootFieldCaching,
SubscriptionEntityPopulation: origFed.SubscriptionEntityPopulation,
},
}
}
Loading
Loading