Feature request: @disableConcurrency directive to opt a field/object out of per-field goroutines
Summary
Add a schema directive (e.g. @disableConcurrency) that lets a field (or object) be resolved inline on the parent goroutine even when it is bound to a model method that takes context.Context.
Today the only way to keep a field off the goroutine path is to bind it to a plain struct field or a context-free method. That forces an unwanted choice: either take ctx (and pay for a goroutine you don't want) or drop ctx (and lose access to request-scoped
clients, loaders, metrics, and logging). A directive would decouple "the method needs ctx" from "this field should run concurrently."
Current behavior
Whether a field is dispatched in its own goroutine is decided solely by Field.IsConcurrent() — codegen/field.go:603:
func (f *Field) IsConcurrent() bool {
if f.Object.DisableConcurrency {
return false
}
return f.MethodHasContext || f.IsResolver
}DisableConcurrency already exists on the Object — codegen/object.go:34 — but it is only ever set for the Mutation root (to satisfy the spec's serial-execution requirement), and there is no way to set it from the schema or gqlgen.yml — codegen/object.go:49:
DisableConcurrency: typ == b.Schema.Mutation,codegen/object.gotpl then branches on IsConcurrent. Concurrent fields register into the FieldSet via out.Concurrently(...); non-concurrent fields take the inline path:
{{- if $field.IsConcurrent }}
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { ... })
{{- else }}
out.Values[i] = ec._{{$object.Name}}_{{$field.Name}}(ctx, field, obj)
{{- end }}At runtime FieldSet.Dispatch runs the first registered field on the caller's goroutine and spawns a dedicated goroutine for every other registered field — graphql/fieldset.go:37:
func (m *FieldSet) Dispatch(ctx context.Context) {
if len(m.delayed) == 1 {
// only one concurrent task, no goroutine
d := m.delayed[0]
m.Values[d.i] = d.f(ctx)
} else if len(m.delayed) > 1 {
var wg sync.WaitGroup
for _, d := range m.delayed[1:] { // every field except the first...
wg.Add(1)
go func(d delayedResult) { // ...gets its own goroutine
defer wg.Done()
m.Values[d.i] = d.f(ctx)
}(d)
}
m.Values[m.delayed[0].i] = m.delayed[0].f(ctx)
wg.Wait()
}
}So a selection set of N concurrent fields spawns N−1 goroutines — regardless of whether those fields do any real I/O.
The ctx cliff (the core problem)
Consider a field bound to a model method. The presence of context.Context in the signature is the only difference, yet it flips concurrency on:
// (A) Runs in a DEDICATED GOROUTINE.
// MethodHasContext == true -> IsConcurrent() == true.
func (m *Model) Field1(ctx context.Context) (string, error) {
// needs ctx: request-scoped clients, dataloaders, metrics, logging...
return compute(ctx)
}// (B) Runs INLINE on the parent goroutine.
// MethodHasContext == false -> IsConcurrent() == false.
func (m *Model) Field1() (string, error) {
// no ctx available — cannot reach request-scoped clients/loaders.
return m.precomputed, nil
}For a field whose work is trivial (e.g. reading an already-memoized value) but which still needs ctx to reach request-scoped dependencies, neither option is good:
- (A) pays for a goroutine to do near-zero work.
- (B) avoids the goroutine but can't use
ctx, so the value must be precomputed eagerly upstream — which over-fetches when the field isn't selected. (Stashingctxon the struct to fake a context-free method is an anti-pattern.)
On large lists this is real overhead: M assets × K such fields ≈ M·(K−…) goroutines doing trivial cached reads, adding scheduler pressure for no parallelism benefit.
Proposed solution
A directive that sets DisableConcurrency for the annotated field (and/or object), independent of MethodHasContext:
directive @disableConcurrency on FIELD_DEFINITION | OBJECT
type Model {
# keeps the ctx-taking method, but resolved inline on the parent goroutine
field1: String! @disableConcurrency
}With @disableConcurrency, example (A) above would generate the inline path (out.Values[i] = ec._Model_field1(ctx, field, obj)) instead of out.Concurrently(...), even though the method takes ctx.
Suggested implementation sketch
Recognize the directive during object/field build and set a per-field disable flag (or reuse/propagate
Object.DisableConcurrencyat field granularity).Extend
Field.IsConcurrent():func (f *Field) IsConcurrent() bool { if f.Object.DisableConcurrency || f.DisableConcurrency { return false } return f.MethodHasContext || f.IsResolver }No runtime change needed — the existing inline template branch and
FieldSetalready handle non-concurrent fields; the directive only changes which branch codegen emits.
Scope note for resolver fields
For plain model-method fields this is straightforward. If the directive is also allowed on @goField(forceResolver: true) fields, the generated resolver call would run inline in the selection loop rather than via Dispatch. That's a reasonable extension but could be
deferred; the model-method case alone already covers the main use case.
Alternatives considered
- Context-free method / plain struct field. Works, but forces eager precomputation and loses
ctxaccess (see above). worker_limitingqlgen.yml. Global, and only caps concurrency for list-element marshaling (MarshalSliceConcurrently,codegen/type.gotpl:138); it does not make individual object fields inline.- Expose the existing
Object.DisableConcurrencyvia@goModel/ config. Object-level only — too coarse when some fields on the object genuinely benefit from parallel I/O and only a few (cheap, cached) fields want to opt out. A field-level directive is the finer-grained fit.
Backward compatibility
Fully additive. The directive defaults to absent, so existing schemas generate identical code. Only annotated fields change from the concurrent path to the inline path.
Use case
Fields that resolve from a per-request memoized/loader-backed cache: the expensive fetch is already de-duplicated, so the remaining per-field work is a trivial read, and spawning a goroutine per field is pure overhead — but the method still needs ctx to reach the cache/loader. @disableConcurrency would let such fields keep ctx and resolve inline.
Source: 99designs/gqlgen