Skip to content

Dataloader dispatch never triggers inside @defer when multiple deferred fragments exist #4269

@timward60

Description

@timward60

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions