Skip to content

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

Closed

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

Activity

  1. github-actions commented on Apr 26, 2026

    @github-actions
    Contributor

    Hello, this issue has been inactive for 60 days, so we're marking it as stale. If you would like to continue this discussion, please comment within the next 30 days or we'll close the issue.

  2. dondonz commented on Apr 27, 2026

    @dondonz
    Member

    Fixed in #4270

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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