diff --git a/router-tests/go.mod b/router-tests/go.mod index 9a23d7d64a..8c884c56d0 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -27,7 +27,7 @@ require ( github.com/wundergraph/cosmo/demo/pkg/subgraphs/projects v0.0.0-20250715110703-10f2e5f9c79e github.com/wundergraph/cosmo/router v0.0.0-20260213130455-6e3277e7b850 github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e - github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.252 + github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.253 go.opentelemetry.io/otel v1.36.0 go.opentelemetry.io/otel/sdk v1.36.0 go.opentelemetry.io/otel/sdk/metric v1.36.0 diff --git a/router-tests/go.sum b/router-tests/go.sum index 7001df47ca..f659b1e421 100644 --- a/router-tests/go.sum +++ b/router-tests/go.sum @@ -356,8 +356,8 @@ github.com/wundergraph/astjson v1.0.0 h1:rETLJuQkMWWW03HCF6WBttEBOu8gi5vznj5KEUP github.com/wundergraph/astjson v1.0.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw= github.com/wundergraph/go-arena v1.1.0 h1:9+wSRkJAkA2vbYHp6s8tEGhPViRGQNGXqPHT0QzhdIc= github.com/wundergraph/go-arena v1.1.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.252 h1:qhm6obHtRwgZm84Gu3G63ywGz2ys2xLBwC0gloDKZuo= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.252/go.mod h1:MFbY0QI8ncF60DHs7yyyiyyhWyld0WE1JokiyTVY8j4= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.253 h1:QW7wGThQdplffQHkePTmJ/Lh32ZXB8EEOq3zC/v48yg= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.253/go.mod h1:MFbY0QI8ncF60DHs7yyyiyyhWyld0WE1JokiyTVY8j4= 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= diff --git a/router/go.mod b/router/go.mod index 3ae4a9da22..a0ab9cf78a 100644 --- a/router/go.mod +++ b/router/go.mod @@ -31,7 +31,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/twmb/franz-go v1.16.1 - github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.252 + github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.253 // Do not upgrade, it renames attributes we rely on go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 go.opentelemetry.io/contrib/propagators/b3 v1.23.0 diff --git a/router/go.sum b/router/go.sum index a51d0b4dbd..1654887ad5 100644 --- a/router/go.sum +++ b/router/go.sum @@ -326,8 +326,8 @@ github.com/wundergraph/astjson v1.0.0 h1:rETLJuQkMWWW03HCF6WBttEBOu8gi5vznj5KEUP github.com/wundergraph/astjson v1.0.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw= github.com/wundergraph/go-arena v1.1.0 h1:9+wSRkJAkA2vbYHp6s8tEGhPViRGQNGXqPHT0QzhdIc= github.com/wundergraph/go-arena v1.1.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.252 h1:qhm6obHtRwgZm84Gu3G63ywGz2ys2xLBwC0gloDKZuo= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.252/go.mod h1:MFbY0QI8ncF60DHs7yyyiyyhWyld0WE1JokiyTVY8j4= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.253 h1:QW7wGThQdplffQHkePTmJ/Lh32ZXB8EEOq3zC/v48yg= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.253/go.mod h1:MFbY0QI8ncF60DHs7yyyiyyhWyld0WE1JokiyTVY8j4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= diff --git a/router/pkg/plan_generator/plan_generator.go b/router/pkg/plan_generator/plan_generator.go index 62e1b5d5e2..a8987b69c5 100644 --- a/router/pkg/plan_generator/plan_generator.go +++ b/router/pkg/plan_generator/plan_generator.go @@ -49,6 +49,9 @@ type QueryPlanResult struct { Timings core.OperationTimes `json:"timings,omitempty"` } +// PlanGenerator reads GraphQL operation files from cfg.SourceDir, +// generates query plans for each using the execution config, +// and writes results to cfg.OutDir as individual files and/or a consolidated report. func PlanGenerator(ctx context.Context, cfg QueryPlanConfig) error { if cfg.Concurrency == 0 { cfg.Concurrency = runtime.GOMAXPROCS(0) @@ -60,27 +63,27 @@ func PlanGenerator(ctx context.Context, cfg QueryPlanConfig) error { queriesPath, err := filepath.Abs(cfg.SourceDir) if err != nil { - return fmt.Errorf("failed to get absolute path for queries: %v", err) + return fmt.Errorf("failed to get absolute path for queries: %w", err) } outPath, err := filepath.Abs(cfg.OutDir) if err != nil { - return fmt.Errorf("failed to get absolute path for output: %v", err) + return fmt.Errorf("failed to get absolute path for output: %w", err) } if err := os.MkdirAll(outPath, 0755); err != nil { - return fmt.Errorf("failed to create output directory: %v", err) + return fmt.Errorf("failed to create output directory: %w", err) } executionConfigPath, err := filepath.Abs(cfg.ExecutionConfig) if err != nil { - return fmt.Errorf("failed to get absolute path for execution config: %v", err) + return fmt.Errorf("failed to get absolute path for execution config: %w", err) } var filter []string if cfg.Filter != "" { filterContent, err := os.ReadFile(cfg.Filter) if err != nil { - return fmt.Errorf("failed to read filter file: %v", err) + return fmt.Errorf("failed to read filter file: %w", err) } filter = strings.Split(string(filterContent), "\n") @@ -88,7 +91,7 @@ func PlanGenerator(ctx context.Context, cfg QueryPlanConfig) error { queries, err := os.ReadDir(queriesPath) if err != nil { - return fmt.Errorf("failed to read queries directory: %v", err) + return fmt.Errorf("failed to read queries directory: %w", err) } queriesQueue := make(chan os.DirEntry, len(queries)) @@ -102,7 +105,7 @@ func PlanGenerator(ctx context.Context, cfg QueryPlanConfig) error { duration, parseErr := time.ParseDuration(cfg.Timeout) if parseErr != nil { - return fmt.Errorf("failed to parse timeout: %v", parseErr) + return fmt.Errorf("failed to parse timeout: %w", parseErr) } ctx, cancel := context.WithTimeout(ctx, duration) defer cancel() @@ -111,7 +114,7 @@ func PlanGenerator(ctx context.Context, cfg QueryPlanConfig) error { pg, err := core.NewPlanGenerator(executionConfigPath, cfg.Logger, cfg.MaxDataSourceCollectorsConcurrency) if err != nil { - return fmt.Errorf("failed to create plan generator: %v", err) + return fmt.Errorf("failed to create plan generator: %w", err) } var planError atomic.Bool @@ -120,13 +123,6 @@ func PlanGenerator(ctx context.Context, cfg QueryPlanConfig) error { for i := 0; i < cfg.Concurrency; i++ { go func(i int) { defer wg.Done() - planner, err := pg.GetPlanner() - if err != nil { - // if we fail to get the planner, we need to cancel the context to stop the other goroutines - // and return here to stop the current goroutine - cancelError(fmt.Errorf("failed to get planner: %v", err)) - return - } for { select { case <-ctxError.Done(): @@ -146,6 +142,15 @@ func PlanGenerator(ctx context.Context, cfg QueryPlanConfig) error { queryFilePath := filepath.Join(queriesPath, queryFile.Name()) + // Planners should not be reused. + planner, err := pg.GetPlanner() + if err != nil { + // If we fail to get the planner, we have to cancel the context + // to stop this and the other goroutines via ctxError. + cancelError(fmt.Errorf("failed to get a planner: %w", err)) + return + } + outContent, opTimes, err := planner.PlanOperation(queryFilePath, cfg.OutputFormat) res := QueryPlanResult{ FileName: queryFile.Name(), @@ -170,7 +175,7 @@ func PlanGenerator(ctx context.Context, cfg QueryPlanConfig) error { outFileName := filepath.Join(outPath, queryFile.Name()) err = os.WriteFile(outFileName, []byte(outContent), 0644) if err != nil { - cancelError(fmt.Errorf("failed to write file: %v", err)) + cancelError(fmt.Errorf("failed to write file: %w", err)) } } resultsMux.Lock() @@ -187,7 +192,7 @@ func PlanGenerator(ctx context.Context, cfg QueryPlanConfig) error { reportFile, err := os.Create(reportFilePath) if err != nil { cancel() - return fmt.Errorf("failed to create results file: %v", err) + return fmt.Errorf("failed to create results file: %w", err) } defer func() { _ = reportFile.Close() @@ -203,11 +208,11 @@ func PlanGenerator(ctx context.Context, cfg QueryPlanConfig) error { } data, jsonErr := json.Marshal(resultData) if jsonErr != nil { - return fmt.Errorf("failed to marshal result: %v", jsonErr) + return fmt.Errorf("failed to marshal result: %w", jsonErr) } _, writeErr := fmt.Fprintf(reportFile, "%s\n", data) if writeErr != nil { - return fmt.Errorf("failed to write result: %v", writeErr) + return fmt.Errorf("failed to write result: %w", writeErr) } } diff --git a/router/pkg/plan_generator/plan_generator_test.go b/router/pkg/plan_generator/plan_generator_test.go index 3d919f2035..bd8888f2b2 100644 --- a/router/pkg/plan_generator/plan_generator_test.go +++ b/router/pkg/plan_generator/plan_generator_test.go @@ -407,3 +407,21 @@ func TestPlanGenerator(t *testing.T) { }) } + +func BenchmarkPlanGenerator(b *testing.B) { + tempDir := b.TempDir() + cfg := QueryPlanConfig{ + SourceDir: path.Join(getTestDataDir(), "queries", "bench"), + OutDir: tempDir, + ExecutionConfig: path.Join(getTestDataDir(), "execution_config", "base.json"), + Timeout: "30s", + Concurrency: 1, + } + b.ReportAllocs() + for b.Loop() { + err := PlanGenerator(context.Background(), cfg) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/router/pkg/plan_generator/testdata/queries/bench/full.graphql b/router/pkg/plan_generator/testdata/queries/bench/full.graphql new file mode 100644 index 0000000000..ac89514918 --- /dev/null +++ b/router/pkg/plan_generator/testdata/queries/bench/full.graphql @@ -0,0 +1,125 @@ +query Full { + employees { + # resolved through employees subgraph + id + # overridden by the products subgraph + notes + details { + # resolved through either employees or family subgraph + forename + surname + # resolved through employees subgraph + location { + language + } + # resolved through family subgraph + hasChildren + # maritalStatus can return null + maritalStatus + nationality + # pets can return null + pets { + class + gender + name + ... on Cat { + type + } + ... on Dog { + breed + } + ... on Alligator { + dangerous + } + } + } + # resolved through employees subgraph + role { + departments + title + ... on Engineer { + engineerType + } + ... on Operator { + operatorType + } + } + # resolved through hobbies subgraph + hobbies { + ... on Exercise { + category + } + ... on Flying { + planeModels + yearsOfExperience + } + ... on Gaming { + genres + name + yearsOfExperience + } + ... on Other { + name + } + ... on Programming { + languages + } + ... on Travelling { + countriesLived { + language + key { + name + } + } + } + } + # resolved through products subgraph + products + } + # can return null + employee(id: 1) { + # resolved through employees subgraph + id + details { + forename + location { + language + } + } + } + teammates(team: OPERATIONS) { + # resolved through employees subgraph + id + ...EmployeeNameFragment + # resolved through products subgraph + products + } + productTypes { + ... on Documentation { + url(product: SDK) + urls(products: [COSMO, MARKETING]) + } + ... on Consultancy { + lead { + ...EmployeeNameFragment + } + name + } + } + a: findEmployees(criteria: { + hasPets: true, nationality: UKRAINIAN, nested: { maritalStatus: ENGAGED } + }) { + ...EmployeeNameFragment + } + b: findEmployees(criteria: { + hasPets: true, nationality: GERMAN, nested: { maritalStatus: MARRIED, hasChildren: true } + }) { + ...EmployeeNameFragment + } +} + +fragment EmployeeNameFragment on Employee { + details { + forename + } +}