Title
Dataloader dispatch never triggers inside @defer when multiple deferred fragments exist
Labels
bug
Body
Describe the bug
When a query contains multiple @defer fragments at the same level, dataloaders invoked inside those deferred fragments are never dispatched. The deferred payloads hang until the request times out.
This was introduced in #3980 ("make dataloader work inside defer blocks"), which shipped in v25.0.
Root Cause
In DeferredExecutionSupportImpl.createDeferredFragmentCall(), the AlternativeCallContext is constructed with deferredFields.size() — the total field count across all @defer fragments — instead of the field count for the specific fragment being created:
private DeferredFragmentCall createDeferredFragmentCall(DeferredExecution deferredExecution) {
int level = parameters.getPath().getLevel() + 1;
// BUG: deferredFields.size() is the total across ALL @defer fragments
AlternativeCallContext alternativeCallContext = new AlternativeCallContext(level, deferredFields.size());
// But mergedFields is only the fields for THIS specific fragment
List<MergedField> mergedFields = deferredExecutionToFields.get(deferredExecution);
...
}
AlternativeCallContext.fields is used by both dispatch strategies to decide when to trigger dispatch:
PerLevelDataLoaderDispatchStrategy.fieldFetched() (line ~362):
if (happenedFirstLevelFetchCount == callStack.expectedFirstLevelFetchCount) {
dispatch(level, callStack); // NEVER reached when expectedFirstLevelFetchCount is inflated
}
ExhaustedDataLoaderDispatchStrategy.deferFieldFetched() (line ~174):
if (deferredFragmentRootFieldsCompleted == parameters.getDeferredCallContext().getFields()) {
decrementObjectRunningAndMaybeDispatch(callStack); // NEVER reached
}
Each DeferredFragmentCall only invokes its own fields, so the counter will only reach the count of fields in that fragment, never the inflated total.
Example: 2 @defer fragments with 1 field each → expectedFirstLevelFetchCount = 2, but each fragment only fetches 1 field → dispatch condition 1 == 2 is never satisfied → dataloaders hang.
To Reproduce
query {
shops {
id
name
... @defer(label: "deferred1") {
departments { # uses a dataloader
name
}
}
... @defer(label: "deferred2") {
expensiveDepartments { # uses a dataloader
name
}
}
}
}
With the existing test infrastructure (BatchCompareDataFetchers), both departments and expensiveDepartments use batch-loaded data fetchers. The query hangs and never delivers the deferred payloads.
A single @defer fragment with multiple fields (e.g., both departments and expensiveDepartments in one ... @defer {} block) works correctly, because in that case deferredFields.size() happens to equal mergedFields.size().
Mixed deferred and non-deferred fields
The fix is safe when a selection set contains a mix of deferred and non-deferred fields. The constructor in DeferredExecutionSupportImpl (lines 86–95) cleanly partitions the selection set: fields with any non-deferred usage are routed into nonDeferredFieldNames and never added to deferredExecutionToFields. So deferredExecutionToFields.get(deferredExecution) (which gives mergedFields) only ever contains fields belonging to that specific @defer fragment:
mergedSelectionSet.getSubFields().values().forEach(mergedField -> {
if (mergedField.getFieldsCount() > mergedField.getDeferredExecutions().size()) {
nonDeferredFieldNamesBuilder.add(mergedField.getSingleField().getResultKey());
return; // non-deferred fields are excluded from deferredExecutionToFields
}
mergedField.getDeferredExecutions().forEach(de -> {
deferredExecutionToFieldsBuilder.put(de, mergedField);
deferredFieldsBuilder.add(mergedField);
});
});
For example, a query with non-deferred departments and a deferred expensiveDepartments correctly produces an AlternativeCallContext with fields=1 for the single deferred fragment.
Expected behavior
Both deferred fragments should resolve and deliver their incremental payloads promptly.
Suggested Fix
Pass mergedFields.size() (the per-fragment field count) instead of deferredFields.size() (the total across all fragments):
private DeferredFragmentCall createDeferredFragmentCall(DeferredExecution deferredExecution) {
int level = parameters.getPath().getLevel() + 1;
- AlternativeCallContext alternativeCallContext = new AlternativeCallContext(level, deferredFields.size());
-
- List<MergedField> mergedFields = deferredExecutionToFields.get(deferredExecution);
+ List<MergedField> mergedFields = deferredExecutionToFields.get(deferredExecution);
+ AlternativeCallContext alternativeCallContext = new AlternativeCallContext(level, mergedFields.size());
Versions
- graphql-java: 25.0 (also present on
master at bd87652)
- Affects all three dispatch strategy modes: default (
PerLevel), ENABLE_DATA_LOADER_CHAINING, and ENABLE_DATA_LOADER_EXHAUSTED_DISPATCHING
Title
Dataloader dispatch never triggers inside @defer when multiple deferred fragments exist
Labels
bug
Body
Describe the bug
When a query contains multiple
@deferfragments at the same level, dataloaders invoked inside those deferred fragments are never dispatched. The deferred payloads hang until the request times out.This was introduced in #3980 ("make dataloader work inside defer blocks"), which shipped in v25.0.
Root Cause
In
DeferredExecutionSupportImpl.createDeferredFragmentCall(), theAlternativeCallContextis constructed withdeferredFields.size()— the total field count across all@deferfragments — instead of the field count for the specific fragment being created:AlternativeCallContext.fieldsis used by both dispatch strategies to decide when to trigger dispatch:PerLevelDataLoaderDispatchStrategy.fieldFetched()(line ~362):ExhaustedDataLoaderDispatchStrategy.deferFieldFetched()(line ~174):Each
DeferredFragmentCallonly invokes its own fields, so the counter will only reach the count of fields in that fragment, never the inflated total.Example: 2
@deferfragments with 1 field each →expectedFirstLevelFetchCount= 2, but each fragment only fetches 1 field → dispatch condition1 == 2is never satisfied → dataloaders hang.To Reproduce
With the existing test infrastructure (
BatchCompareDataFetchers), bothdepartmentsandexpensiveDepartmentsuse batch-loaded data fetchers. The query hangs and never delivers the deferred payloads.A single
@deferfragment with multiple fields (e.g., bothdepartmentsandexpensiveDepartmentsin one... @defer {}block) works correctly, because in that casedeferredFields.size()happens to equalmergedFields.size().Mixed deferred and non-deferred fields
The fix is safe when a selection set contains a mix of deferred and non-deferred fields. The constructor in
DeferredExecutionSupportImpl(lines 86–95) cleanly partitions the selection set: fields with any non-deferred usage are routed intononDeferredFieldNamesand never added todeferredExecutionToFields. SodeferredExecutionToFields.get(deferredExecution)(which givesmergedFields) only ever contains fields belonging to that specific@deferfragment:For example, a query with non-deferred
departmentsand a deferredexpensiveDepartmentscorrectly produces anAlternativeCallContextwithfields=1for the single deferred fragment.Expected behavior
Both deferred fragments should resolve and deliver their incremental payloads promptly.
Suggested Fix
Pass
mergedFields.size()(the per-fragment field count) instead ofdeferredFields.size()(the total across all fragments):private DeferredFragmentCall createDeferredFragmentCall(DeferredExecution deferredExecution) { int level = parameters.getPath().getLevel() + 1; - AlternativeCallContext alternativeCallContext = new AlternativeCallContext(level, deferredFields.size()); - - List<MergedField> mergedFields = deferredExecutionToFields.get(deferredExecution); + List<MergedField> mergedFields = deferredExecutionToFields.get(deferredExecution); + AlternativeCallContext alternativeCallContext = new AlternativeCallContext(level, mergedFields.size());Versions
masterat bd87652)PerLevel),ENABLE_DATA_LOADER_CHAINING, andENABLE_DATA_LOADER_EXHAUSTED_DISPATCHING