99designs / 99designs/gqlgen

Feature request: @disableConcurrency directive to opt a field/object out of per-field goroutines

Open
#4,321 2 comments 0 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
10.8k
Forks
1.3k
Avg merge
2d 36m
Merged PRs (30d)
26

Description

## 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`:

```go
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`:

```go
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:

```gotpl
{{- 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`:

```go
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:

```go
// (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)
}
```

```go
// (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. (Stashing `ctx` on 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`:

```graphql
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.DisableConcurrency` at field granularity).
- Extend `Field.IsConcurrent()`:

```go
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 `FieldSet` already 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 `ctx` access (see above).
- **`worker_limit` in `gqlgen.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.DisableConcurrency` via `@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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.