• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

graphql-go / graphql / 1912

09 May 2026 10:10PM UTC coverage: 85.967% (-6.3%) from 92.284%
1912

Pull #740

circleci

Nthalk
perf: skip extension hooks when schema has no extensions

resolvePlannedField unconditionally called handleExtensionsResolveFieldDidStart,
which allocates a map[string]ResolveFieldFinishFunc + a closure on
every resolved field — even when the schema has zero extensions
registered. With ~1000 fields per request that's 2000 wasted allocs
per request on the common no-extensions case.

Gate the call (and the matching finish-handler invocation) behind
`len(eCtx.Schema.extensions) > 0`.

Wide-query bench (100 fields × 10 items):
  before: 1.15ms / 9043 allocs/op
  after:  0.99ms / 7040 allocs/op   (-14% time, -22% allocs)

Hot-loop with native variables:
  before: 1.44ms / 9888 allocs/op
  after:  1.30ms / 7884 allocs/op   (-10% time, -20% allocs)

Schemas with extensions registered take the same path as before.
Pull Request #740: Plan + PlanQuery + ExecutePlan + PlanCache (cacheable execution shape)

916 of 1070 new or added lines in 4 files covered. (85.61%)

97 existing lines in 1 file now uncovered.

8350 of 9713 relevant lines covered (85.97%)

1296.5 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.46
/plan_cache.go
1
package graphql
2

3
import (
4
        "container/list"
5
        "hash/fnv"
6
        "strconv"
7
        "sync"
8
        "sync/atomic"
9

10
        "github.com/graphql-go/graphql/gqlerrors"
11
        "github.com/graphql-go/graphql/language/parser"
12
        "github.com/graphql-go/graphql/language/source"
13
)
14

15
// PlanCache is a bounded, schema-aware LRU of parsed + validated +
16
// planned query state. Drop-in: a server's hot loop becomes
17
//
18
//   pr := cache.Get(schema, queryString, opName)
19
//   if len(pr.Errors) > 0 { return errorResponse(pr.Errors) }
20
//   args := mergeArgs(requestArgs, pr.SynthArgs)
21
//   result := graphql.ExecutePlan(pr.Plan, graphql.ExecuteParams{
22
//       Schema: *schema, Args: args, Context: ctx,
23
//   })
24
//
25
// Each entry holds the *Plan plus any validation errors that arose
26
// at parse/validate/plan time. Entries are bound to the *Schema
27
// pointer they were planned against; on schema rebuild the *Schema
28
// pointer changes and stale entries fall out at lookup.
29
//
30
// The cache is safe for concurrent use.
31
type PlanCache struct {
32
        opts PlanCacheOptions
33

34
        mu      sync.Mutex
35
        entries map[string]*list.Element
36
        order   *list.List
37

38
        hits   atomic.Uint64
39
        misses atomic.Uint64
40
}
41

42
// PlanCacheOptions tunes the cache. Zero values get sensible
43
// defaults. MaxEntries bounds the entry count (LRU eviction past
44
// it); MaxQueryBytes bypasses the cache for over-cap queries (large
45
// queries plus unbounded entry retention is the easy way to OOM a
46
// gateway). Normalize triggers literal→variable rewriting before
47
// hashing, so two queries that differ only in literal values
48
// collapse to one cache entry.
49
type PlanCacheOptions struct {
50
        MaxEntries    int
51
        MaxQueryBytes int
52
        Normalize     bool
53
}
54

55
const (
56
        defaultPlanCacheMaxEntries    = 1024
57
        defaultPlanCacheMaxQueryBytes = 64 * 1024
58
)
59

60
// PlanResult is what PlanCache.Get returns. On a successful build,
61
// Plan is non-nil and Errors is empty; on failure, Plan is nil and
62
// Errors holds the parse/validate/plan errors in the order they
63
// were detected. SynthArgs is populated when normalization extracted
64
// literals into synthetic variables — callers must merge it into
65
// the request's Args before ExecutePlan.
66
type PlanResult struct {
67
        Plan      *Plan
68
        SynthArgs map[string]interface{}
69
        Errors    []gqlerrors.FormattedError
70
}
71

72
// internal cache entry layout. Stored as the value of a list.Element;
73
// the list owns LRU order, the map owns key-to-element lookup.
74
type planCacheEntry struct {
75
        schema *Schema
76
        result PlanResult
77
}
78

79
type planCacheItem struct {
80
        key string
81
        e   *planCacheEntry
82
}
83

84
// NewPlanCache returns a PlanCache with the given options. Pass
85
// PlanCacheOptions{} to take all defaults.
86
func NewPlanCache(opts PlanCacheOptions) *PlanCache {
13✔
87
        if opts.MaxEntries <= 0 {
26✔
88
                opts.MaxEntries = defaultPlanCacheMaxEntries
13✔
89
        }
13✔
90
        if opts.MaxQueryBytes <= 0 {
26✔
91
                opts.MaxQueryBytes = defaultPlanCacheMaxQueryBytes
13✔
92
        }
13✔
93
        return &PlanCache{
13✔
94
                opts:    opts,
13✔
95
                entries: make(map[string]*list.Element, opts.MaxEntries),
13✔
96
                order:   list.New(),
13✔
97
        }
13✔
98
}
99

100
// Get parses+validates+plans `query` against `schema`, or returns a
101
// cached entry if the query has been seen before with the same
102
// schema pointer.
103
//
104
// Behavior on miss:
105
//  1. Parse the query string. Parse errors → PlanResult{Errors}, no
106
//     cache (parse failures are usually client bugs that don't
107
//     repeat).
108
//  2. (When opts.Normalize is true) rewrite leaf literal arguments
109
//     into synth variables; the printed normalized doc becomes the
110
//     cache key, so two queries that differ only in literal values
111
//     collapse to one entry.
112
//  3. Validate the (normalized) document against the schema.
113
//     Validation errors → PlanResult{Errors}, cached so
114
//     repeat-bad-queries don't hammer the validator.
115
//  4. Plan the operation. Plan errors → cached as PlanResult{Errors}.
116
//
117
// On a hit with normalize=true, the synthetic arguments extracted at
118
// THIS call's parse+normalize step are returned in PlanResult.SynthArgs;
119
// the cached *Plan itself is shared across all calls.
120
func (c *PlanCache) Get(schema *Schema, query, operationName string) PlanResult {
555✔
121
        if c == nil {
555✔
NEW
122
                // Nil-receiver convenience: caller can pass nil and still
×
NEW
123
                // get a working (uncached) path. Useful for tests +
×
NEW
124
                // "off by default" deployments.
×
NEW
125
                return planAndValidate(schema, query, operationName)
×
NEW
126
        }
×
127
        if !c.shouldCache(len(query)) {
555✔
NEW
128
                // Over-cap queries bypass the cache entirely.
×
NEW
129
                return planAndValidate(schema, query, operationName)
×
NEW
130
        }
×
131

132
        if !c.opts.Normalize {
1,075✔
133
                // Plain cache: raw query string is the key.
520✔
134
                key := operationName + "\x00" + query
520✔
135
                if pr, ok := c.lookup(schema, key); ok {
1,017✔
136
                        return pr
497✔
137
                }
497✔
138
                pr := planAndValidate(schema, query, operationName)
23✔
139
                c.store(schema, key, pr)
23✔
140
                return pr
23✔
141
        }
142

143
        // Normalized cache: parse first (the AST is the input to
144
        // normalization), then key the cache on the printed normalized
145
        // document. Each call carries its own synth-args even when the
146
        // underlying *Plan is shared.
147
        src := source.NewSource(&source.Source{Body: []byte(query), Name: "GraphQL request"})
35✔
148
        doc, parseErr := parser.Parse(parser.ParseParams{Source: src})
35✔
149
        if parseErr != nil {
35✔
NEW
150
                return PlanResult{Errors: gqlerrors.FormatErrors(parseErr)}
×
NEW
151
        }
×
152
        normDoc, synthArgs, normKey, normErr := normalizeDocument(schema, doc, operationName)
35✔
153
        if normErr != nil {
35✔
NEW
154
                return PlanResult{Errors: gqlerrors.FormatErrors(normErr)}
×
NEW
155
        }
×
156
        if normKey == "" {
39✔
157
                // Normalization isn't applicable (op-not-found, ambiguous
4✔
158
                // multi-op without operationName). Fingerprint the raw query
4✔
159
                // so unrelated docs don't collide on the empty key — otherwise
4✔
160
                // any cached parse/validate/plan error from the first such
4✔
161
                // query would be returned for every subsequent malformed
4✔
162
                // query under the same operationName.
4✔
163
                h := fnv.New64a()
4✔
164
                _, _ = h.Write([]byte(query))
4✔
165
                normKey = "raw:" + strconv.FormatUint(h.Sum64(), 16)
4✔
166
        }
4✔
167
        cacheKey := operationName + "\x00" + normKey
35✔
168
        if pr, ok := c.lookup(schema, cacheKey); ok {
48✔
169
                // Stash this call's synthArgs onto the returned result.
13✔
170
                // The cached PlanResult deliberately stores no synthArgs
13✔
171
                // — those are per-call, derived freshly from the literals
13✔
172
                // in the incoming query.
13✔
173
                pr.SynthArgs = synthArgs
13✔
174
                return pr
13✔
175
        }
13✔
176
        if vr := ValidateDocument(schema, normDoc, nil); !vr.IsValid {
22✔
NEW
177
                pr := PlanResult{Errors: vr.Errors}
×
NEW
178
                c.store(schema, cacheKey, pr)
×
NEW
179
                return pr
×
NEW
180
        }
×
181
        plan, err := PlanQuery(schema, normDoc, operationName)
22✔
182
        if err != nil {
24✔
183
                pr := PlanResult{Errors: gqlerrors.FormatErrors(err)}
2✔
184
                c.store(schema, cacheKey, pr)
2✔
185
                return pr
2✔
186
        }
2✔
187
        c.store(schema, cacheKey, PlanResult{Plan: plan})
20✔
188
        return PlanResult{Plan: plan, SynthArgs: synthArgs}
20✔
189
}
190

191
// HitsMisses returns the cumulative hit and miss counts. Useful for
192
// surfacing as Prometheus counters.
193
func (c *PlanCache) HitsMisses() (hits, misses uint64) {
8✔
194
        if c == nil {
8✔
NEW
195
                return 0, 0
×
NEW
196
        }
×
197
        return c.hits.Load(), c.misses.Load()
8✔
198
}
199

200
// Reset drops every entry. Operators rebuilding the schema can call
201
// this to reclaim memory immediately rather than waiting for the
202
// schema-pointer mismatch to evict entries one at a time.
203
func (c *PlanCache) Reset() {
1✔
204
        if c == nil {
1✔
NEW
205
                return
×
NEW
206
        }
×
207
        c.mu.Lock()
1✔
208
        defer c.mu.Unlock()
1✔
209
        c.entries = make(map[string]*list.Element, c.opts.MaxEntries)
1✔
210
        c.order = list.New()
1✔
211
}
212

213
func (c *PlanCache) shouldCache(querySize int) bool {
555✔
214
        return c.opts.MaxQueryBytes <= 0 || querySize <= c.opts.MaxQueryBytes
555✔
215
}
555✔
216

217
func (c *PlanCache) lookup(schema *Schema, key string) (PlanResult, bool) {
555✔
218
        c.mu.Lock()
555✔
219
        defer c.mu.Unlock()
555✔
220
        el, ok := c.entries[key]
555✔
221
        if !ok {
598✔
222
                c.misses.Add(1)
43✔
223
                return PlanResult{}, false
43✔
224
        }
43✔
225
        item := el.Value.(*planCacheItem)
512✔
226
        if item.e.schema != schema {
514✔
227
                c.order.Remove(el)
2✔
228
                delete(c.entries, key)
2✔
229
                c.misses.Add(1)
2✔
230
                return PlanResult{}, false
2✔
231
        }
2✔
232
        c.order.MoveToFront(el)
510✔
233
        c.hits.Add(1)
510✔
234
        return item.e.result, true
510✔
235
}
236

237
func (c *PlanCache) store(schema *Schema, key string, pr PlanResult) {
45✔
238
        c.mu.Lock()
45✔
239
        defer c.mu.Unlock()
45✔
240
        if el, ok := c.entries[key]; ok {
60✔
241
                item := el.Value.(*planCacheItem)
15✔
242
                item.e.schema = schema
15✔
243
                item.e.result = pr
15✔
244
                c.order.MoveToFront(el)
15✔
245
                return
15✔
246
        }
15✔
247
        item := &planCacheItem{key: key, e: &planCacheEntry{schema: schema, result: pr}}
30✔
248
        el := c.order.PushFront(item)
30✔
249
        c.entries[key] = el
30✔
250
        for c.order.Len() > c.opts.MaxEntries {
30✔
NEW
251
                oldest := c.order.Back()
×
NEW
252
                if oldest == nil {
×
NEW
253
                        break
×
254
                }
NEW
255
                oi := oldest.Value.(*planCacheItem)
×
NEW
256
                c.order.Remove(oldest)
×
NEW
257
                delete(c.entries, oi.key)
×
258
        }
259
}
260

261
// planAndValidate is the cache-miss path: parse, validate, plan,
262
// returning a PlanResult ready to store. Exposed as a free function
263
// (no receiver) so the nil-receiver Get can fall through to it
264
// without re-implementing the pipeline.
265
func planAndValidate(schema *Schema, query, operationName string) PlanResult {
23✔
266
        src := source.NewSource(&source.Source{Body: []byte(query), Name: "GraphQL request"})
23✔
267
        doc, parseErr := parser.Parse(parser.ParseParams{Source: src})
23✔
268
        if parseErr != nil {
23✔
NEW
269
                return PlanResult{Errors: gqlerrors.FormatErrors(parseErr)}
×
NEW
270
        }
×
271
        if vr := ValidateDocument(schema, doc, nil); !vr.IsValid {
23✔
NEW
272
                return PlanResult{Errors: vr.Errors}
×
NEW
273
        }
×
274
        plan, err := PlanQuery(schema, doc, operationName)
23✔
275
        if err != nil {
23✔
NEW
276
                return PlanResult{Errors: gqlerrors.FormatErrors(err)}
×
NEW
277
        }
×
278
        return PlanResult{Plan: plan}
23✔
279
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc