Skip to content

Commit e4a9c7c

Browse files
committed
perf(validation): optimize overlap checks (alias grouping + identical hash)
1 parent 5ca91cb commit e4a9c7c

File tree

3 files changed

+397
-0
lines changed

3 files changed

+397
-0
lines changed

fragment_queries_bench_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
//go:build bench
2+
// +build bench
3+
4+
package graphql_test
5+
6+
// Benchmark inspired by historical PR #102 ("Control memory explosion on large list of queries").
7+
// The validation phase used to exhibit O(n^2) memory/time growth for large
8+
// numbers of root selections due to pairwise overlap checks. This benchmark
9+
// recreates a simplified version focusing on many aliased root fields to
10+
// exercise overlap validation. Two scenarios are measured:
11+
// * overlapping aliases (all fields share the same alias name)
12+
// * non-overlapping aliases (each field has a unique alias)
13+
// The overlapping case forces the validator to consider merging, potentially
14+
// increasing work. Non-overlapping aliases avoid that specific merge path.
15+
//
16+
// Larger counts (5000, 10000) are included only when BIG_FRAGMENT_BENCH=1 is
17+
// set in the environment to keep default benchmarks fast.
18+
19+
import (
20+
"bytes"
21+
"context"
22+
"fmt"
23+
"os"
24+
"testing"
25+
26+
"github.com/graph-gophers/graphql-go"
27+
)
28+
29+
const fragmentBenchSchema = `
30+
type Query { a: Int! }
31+
`
32+
33+
type fragmentBenchResolver struct{}
34+
35+
func (fragmentBenchResolver) A() int32 { return 1 }
36+
37+
func buildAliasQuery(count int, nonOverlap bool) string {
38+
// Build a single operation with many aliased uses of the same field.
39+
// Example (nonOverlap, count=3):
40+
// query Q { f0: a f1: a f2: a }
41+
// Example (overlap, count=3):
42+
// query Q { x: a x: a x: a }
43+
q := "query Q {"
44+
if nonOverlap {
45+
for i := 0; i < count; i++ {
46+
q += fmt.Sprintf(" f%d: a", i)
47+
}
48+
} else {
49+
for i := 0; i < count; i++ {
50+
q += " x: a" // same alias each time
51+
}
52+
}
53+
q += " }"
54+
return q
55+
}
56+
57+
// BenchmarkSimpleRootAlias measures validation/exec cost for large flat selection sets
58+
// with and without overlapping aliases.
59+
func BenchmarkSimpleRootAlias(b *testing.B) {
60+
schema := graphql.MustParseSchema(fragmentBenchSchema, &fragmentBenchResolver{})
61+
62+
counts := []int{1, 10, 100, 500, 1000}
63+
if os.Getenv("BIG_FRAGMENT_BENCH") == "1" {
64+
counts = append(counts, 5000, 10000)
65+
}
66+
67+
ctx := context.Background()
68+
69+
for _, c := range counts {
70+
for _, nonOverlap := range []bool{false, true} {
71+
aliasMode := "overlapping"
72+
if nonOverlap {
73+
aliasMode = "non-overlapping"
74+
}
75+
queryStr := buildAliasQuery(c, nonOverlap)
76+
// Warm-up single execution (outside timing) to catch schema issues early.
77+
if resp := schema.Exec(ctx, queryStr, "", nil); len(resp.Errors) > 0 {
78+
b.Fatalf("unexpected exec errors preparing benchmark: %v", resp.Errors)
79+
}
80+
b.Run(fmt.Sprintf("%d_queries_%s_aliases", c, aliasMode), func(b *testing.B) {
81+
b.ReportAllocs()
82+
for b.Loop() {
83+
if resp := schema.Exec(ctx, queryStr, "", nil); len(resp.Errors) > 0 {
84+
b.Fatalf("exec errors: %v", resp.Errors)
85+
}
86+
}
87+
})
88+
}
89+
}
90+
}
91+
92+
func TestOverlappingAlias(t *testing.T) {
93+
query := `
94+
{
95+
hero(episode: EMPIRE) {
96+
a: name
97+
a: id
98+
}
99+
}
100+
`
101+
result := starwarsSchema.Exec(context.Background(), query, "", nil)
102+
if len(result.Errors) == 0 {
103+
t.Fatal("Expected error from overlapping alias")
104+
}
105+
}
106+
107+
// go test -bench=FragmentQueries -benchmem
108+
// BenchmarkStarWarsFragmentAlias expands the scenario with fragment spreads on the
109+
// canonical StarWars schema exercising deeper selection hashing paths.
110+
func BenchmarkStarWarsFragmentAlias(b *testing.B) {
111+
singleQuery := `
112+
composed_%d: hero(episode: EMPIRE) {
113+
name
114+
...friendsNames
115+
...friendsIds
116+
}
117+
`
118+
119+
queryTemplate := `
120+
{
121+
%s
122+
}
123+
fragment friendsNames on Character {
124+
friends {
125+
name
126+
}
127+
}
128+
fragment friendsIds on Character {
129+
friends {
130+
id
131+
}
132+
}
133+
`
134+
135+
testCases := []int{
136+
1,
137+
10,
138+
100,
139+
1000,
140+
10000,
141+
}
142+
143+
for _, c := range testCases {
144+
// for each count, add a case for overlapping aliases vs non-overlapping aliases
145+
for _, o := range []bool{true} {
146+
var buffer bytes.Buffer
147+
for i := 0; i < c; i++ {
148+
idx := 0
149+
if o {
150+
idx = i
151+
}
152+
buffer.WriteString(fmt.Sprintf(singleQuery, idx))
153+
}
154+
155+
query := fmt.Sprintf(queryTemplate, buffer.String())
156+
a := "overlapping"
157+
if o {
158+
a = "non-overlapping"
159+
}
160+
b.Run(fmt.Sprintf("%d queries %s aliases", c, a), func(b *testing.B) {
161+
for n := 0; n < b.N; n++ {
162+
result := starwarsSchema.Exec(context.Background(), query, "", nil)
163+
if len(result.Errors) != 0 {
164+
b.Fatal(result.Errors[0])
165+
}
166+
}
167+
})
168+
}
169+
}
170+
}
171+
172+
// Performance mitigation roadmap (see discussion):
173+
// 1. Implement "early exit after first conflict" in field overlap validation.
174+
// - Spec allows returning after first conflict; minimizes quadratic blow-up.
175+
// - Lowest risk change; preserves correctness, may slightly reduce number of
176+
// reported sibling conflicts (acceptable trade-off for widely used lib).
177+
// 2. (Optional, behind internal constant) Threshold-based fallback: if conflicts
178+
// in a single comparison group exceed a limit, short‑circuit with one
179+
// aggregate error to cap worst-case cost.
180+
// 3. Defer heavier changes (fragment-aware hashing, canonical structural
181+
// deduplication) until profiling shows residual hot spots; these add
182+
// complexity and require careful determinism/error messaging review.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//go:build bench
2+
// +build bench
3+
4+
package validation_test
5+
6+
import (
7+
"fmt"
8+
"testing"
9+
10+
"github.com/graph-gophers/graphql-go/ast"
11+
"github.com/graph-gophers/graphql-go/internal/query"
12+
"github.com/graph-gophers/graphql-go/internal/schema"
13+
"github.com/graph-gophers/graphql-go/internal/validation"
14+
)
15+
16+
// Builds a query with n repetitions of the same response key whose underlying field differs
17+
// every other occurrence, forcing overlap validation to perform many pairwise comparisons if
18+
// optimization is ineffective. Used to evaluate the adaptive identical-field fast path.
19+
func buildPathologicalQuery(n int) string {
20+
// Alternate between a: realField and a: otherField to trigger conflicts only in half pairs.
21+
body := "{\n"
22+
for i := 0; i < n; i++ {
23+
if i%2 == 0 {
24+
body += " x: fieldA { leaf }\n"
25+
} else {
26+
body += " x: fieldB { leaf }\n"
27+
}
28+
}
29+
body += "}\n"
30+
return body
31+
}
32+
33+
var pathologicalSchemaSDL = `
34+
type Query { fieldA: Obj fieldB: Obj }
35+
type Obj { leaf: Int }
36+
`
37+
38+
func preparePathologicalSchema(b *testing.B) *ast.Schema {
39+
s := schema.New()
40+
if err := schema.Parse(s, pathologicalSchemaSDL, false); err != nil {
41+
b.Fatalf("schema parse: %v", err)
42+
}
43+
return s
44+
}
45+
46+
// BenchmarkPathologicalOverlap stresses the worst-case alias group: repeated identical response key
47+
// with alternating underlying field definitions. Without the identical-fields fast path, this trends
48+
// toward O(k^2) pairwise comparisons; with optimization it aims for near O(k) (shallow pass + hashes).
49+
func BenchmarkPathologicalOverlap(b *testing.B) {
50+
s := preparePathologicalSchema(b)
51+
cases := []int{10, 50, 100, 250, 500}
52+
for _, n := range cases {
53+
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
54+
queryStr := buildPathologicalQuery(n)
55+
for b.Loop() {
56+
doc, err := query.Parse(queryStr)
57+
if err != nil {
58+
b.Fatalf("parse: %v", err)
59+
}
60+
_ = validation.Validate(s, doc, nil, 0)
61+
}
62+
})
63+
}
64+
}

0 commit comments

Comments
 (0)