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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions