-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Description
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
masterat bd87652) - Affects all three dispatch strategy modes: default (
PerLevel),ENABLE_DATA_LOADER_CHAINING, andENABLE_DATA_LOADER_EXHAUSTED_DISPATCHING