Skip to content

Wrap CompletionStage results directly in ContextDataFetcherDecorator - #1547

Closed
joshgord wants to merge 1 commit into
spring-projects:2.0.xfrom
joshgord:jgordineer/cf-direct-wrap
Closed

joshgord wants to merge 1 commit into
spring-projects:2.0.xfrom
joshgord:jgordineer/cf-direct-wrap

Conversation

@joshgord

@joshgord joshgord commented Oct 24, 2026 •

Copy link
Copy Markdown

Summary

For every data fetcher that returns a CompletionStage, ContextDataFetcherDecorator.updateValue converted the stage through Reactor (toMonoIfReactive + contextWrite + toFuture). That allocates a Mono chain per field per request, and the context snapshot only influences downstream processing when Reactor's automatic context propagation (Hooks) is enabled globally.

This change wraps a CompletionStage directly: a CompletableFuture whose completion callback installs the ContextSnapshot scope while completing normally or exceptionally. Publisher results keep the Reactor path; subscription results are unchanged. Already-completed futures are returned as-is, since their dependents run inline on the current thread.

Also: skip the duplicate captureFrom source when the local context is the same GraphQLContext instance as the execution context, avoiding a second full enumeration of the same map per field.

Closes gh-1548

Behavior

  • Cancellation and exception semantics preserved. The wrapper propagates cancellation of the returned future back to the source stage (as Mono.fromCompletionStage did through its subscription) and unwraps CompletionException before completing exceptionally (as MonoCompletionStage did before onError), so DataFetcherExceptionResolvers observe the same exception types as before. If the snapshot scope fails, the stage's original failure stays primary; a scope failure after the future completed is logged, not silently dropped.
  • Propagation no longer depends on the global Reactor Hooks state. ThreadLocals captured in a field's snapshot (including localContext values) are now installed explicitly around the completion and its synchronous dependents, whether or not automatic context propagation is enabled. Previously, on the Mono path, restoration around completion happened only with Hooks enabled.

Motivation

In a servlet-stack A/B harness (embedded Tomcat, Spring MVC, spring-graphql, micrometer context-propagation; 8 suspending fields per GraphQL query, 30k requests), removing the decorator's Reactor round-trip produced, per request:

  • reactor-bucket allocation: −141.5 MB per 30k requests (JFR ObjectAllocationSample weighted), replaced by ~24 MB of direct-wrap machinery — roughly 6x less allocation per suspending field.
  • total allocation: −3.4 KB/request (142.3 → 138.9 KB, ~2.4% of request allocation in the harness).

CPU deltas were within run-to-run noise in this harness. A self-contained single-file benchmark (see comment below) lets reviewers reproduce the allocation effect against any jar: ~250-300 B per CompletableFuture-returning field.

Scope note: the benchmark and measurements apply to data fetchers returning CompletionStage. Reactive Publisher results, including @SchemaMapping suspend functions (which produce a Mono), still take the Reactor path by design.

Testing

  • ContextDataFetcherDecoratorTests (18 tests): 3 consecutive --rerun executions, 0 failures. Coverage: cross-thread ThreadLocal propagation observed from an undecorated (TrivialDataFetcher) child of the completed value; the unwrapped exception type reaching a DataFetcherExceptionResolver; cancellation propagating to the source future; CompletionException unwrapping on both the in-flight and already-failed paths; scope-failure handling; already-completed futures returned as-is.
  • Mutation check: with the wrapper replaced by return stage, 4 tests fail deterministically (completableFutureCompletedOnAnotherThread, originalFailureIsPrimaryWhenScopeFails, completableFutureUnwrapsCompletionException, alreadyFailedFutureIsUnwrapped).
  • checkstyleMain / checkstyleTest pass.
  • Not run to green in this environment: the full module suite. Its Mongo/Neo4j/GraphiQL-browser tests fail identically on the base commit here (no Docker available).
  • The harness measurement above was run against a published-SNAPSHOT of this change via dependency substitution.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Oct 24, 2026
@joshgord
joshgord marked this pull request as ready for review October 24, 2026 06:59
@joshgord

joshgord commented Oct 25, 2026 •

Copy link
Copy Markdown
Author

Reproducible benchmark for the motivation

The allocation claim in the PR description was measured in an internal harness. To make it verifiable without that harness, here is a self-contained, dependency-light benchmark that runs against any spring-graphql jar — upstream release vs. a build of this branch.

Run it with the spring-graphql jar and its runtime dependencies on the class path (no JMH or build changes needed):

javac -cp <jars> DecoratorBench.java
java  -cp <jars>:. DecoratorBench [fieldCount] [iterations]

Results on this host (Azul Zulu 27, x86_64), 50k executions, pre-completed CompletableFuture per field, two repetitions each; allocation tracked on the calling thread:

fields variant time ns/op alloc B/op
8 upstream 2.0.6 22121 / 23239 69475 / 69681
8 this branch 22383 / 21173 67325 / 67204
32 upstream 2.0.6 53644 / 53986 165799 / 165806
32 this branch 51203 / 51188 157403 / 158762

Allocation drops ~2.3 KB per execution at 8 fields and ~7-8 KB at 32 fields (~250-300 B per CompletableFuture-returning field); time improves ~5% at 32 fields (8-field time is within run-to-run noise). This mirrors the JFR-based attribution from the harness cited in the PR description (reactor-bucket allocation eliminated, ~6x cheaper per suspending field).

DecoratorBench.java
import java.lang.management.ManagementFactory;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;

import graphql.ExecutionInput;
import graphql.GraphQL;
import graphql.schema.DataFetcher;

import org.springframework.core.io.ByteArrayResource;
import org.springframework.graphql.execution.GraphQlSource;

/**
 * Single-file benchmark for the ContextDataFetcherDecorator CompletionStage path.
 * Run once against the upstream spring-graphql release and once against a build of the
 * PR branch; compare time/op and bytes/op. Every fetcher returns a CompletableFuture,
 * so the per-field cost of wrapping the CompletionStage result is included in each
 * execution. Allocation is tracked on the calling thread, which is valid because all
 * futures are pre-completed and execution is synchronous.
 */
public class DecoratorBench {

  public static void main(String[] args) {
    int fieldCount = args.length > 0 ? Integer.parseInt(args[0]) : 8;
    int iterations = args.length > 1 ? Integer.parseInt(args[1]) : 50_000;

    StringBuilder schemaText = new StringBuilder("type Query {");
    StringBuilder queryText = new StringBuilder("{");
    for (int i = 0; i < fieldCount; i++) {
      schemaText.append(" f").append(i).append(": String!");
      queryText.append(" f").append(i);
    }
    schemaText.append(" }");
    queryText.append(" }");

    GraphQlSource source = GraphQlSource.schemaResourceBuilder()
        .schemaResources(new ByteArrayResource(schemaText.toString().getBytes()))
        .configureRuntimeWiring(wiring -> wiring.type("Query", builder -> {
          for (int i = 0; i < fieldCount; i++) {
            String field = "f" + i;
            builder.dataFetcher(field,
                (DataFetcher<CompletableFuture<String>>) env -> CompletableFuture.completedFuture("v"));
          }
          return builder;
        }))
        .build();

    GraphQL graphQL = source.graphQl();
    ExecutionInput input = ExecutionInput.newExecutionInput().query(queryText.toString()).build();

    AtomicInteger sink = new AtomicInteger();
    for (int i = 0; i < 10_000; i++) {
      sink.addAndGet(graphQL.executeAsync(input).join().getData().toString().length());
    }

    com.sun.management.ThreadMXBean bean =
        (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
    long threadId = Thread.currentThread().threadId();
    long t0 = System.nanoTime();
    long a0 = bean.getThreadAllocatedBytes(threadId);
    for (int i = 0; i < iterations; i++) {
      sink.addAndGet(graphQL.executeAsync(input).join().getData().toString().length());
    }
    long a1 = bean.getThreadAllocatedBytes(threadId);
    long t1 = System.nanoTime();

    if (sink.get() == 42) { // defeat dead-code elimination
      System.out.println("impossible");
    }
    System.out.printf("fields=%d iterations=%d time=%.1f ns/op alloc=%.1f B/op%n",
        fieldCount, iterations,
        (double) (t1 - t0) / iterations, (double) (a1 - a0) / iterations);
  }
}

@joshgord
joshgord force-pushed the jgordineer/cf-direct-wrap branch from 20bbe66 to ef3cc09 Compare October 25, 2026 00:25
@joshgord
joshgord marked this pull request as draft October 25, 2026 01:10
For every data fetcher that returns a CompletionStage, updateValue converted
the stage through Reactor (toMonoIfReactive + contextWrite + toFuture),
allocating a Mono chain per field per request, and restoring ThreadLocals
around completion only when Reactor's automatic context propagation was
enabled globally.

The decorator now wraps a CompletionStage directly: a CompletableFuture
whose completion callback installs the ContextSnapshot scope while
completing normally or exceptionally. Publisher results keep the Reactor
path; subscription results are unchanged. Already-completed futures are
returned as-is, since their dependents run inline on the current thread.
Also skip the duplicate captureFrom source when the local context is the
same GraphQLContext instance as the execution context.

Semantics preserved from the Reactor path (verified against reactor-core
3.8.8): cancellation of the returned future propagates to the source
stage, as Mono.fromCompletionStage did through its subscription;
CompletionException is unwrapped before completing exceptionally, as
MonoCompletionStage did before onError, so exception resolvers observe the
original exception types. A snapshot scope failure keeps the stage's
original failure primary (scope error suppressed); a scope failure after
completion is logged instead of dropped silently.

Tests cover: cross-thread ThreadLocal propagation observed from an
undecorated (TrivialDataFetcher) child of the completed value; the
unwrapped exception type reaching a DataFetcherExceptionResolver;
cancellation propagating to the source future; CompletionException
unwrapping on the in-flight and already-failed paths; scope-failure
handling; already-completed futures returned as-is. Mutation checks
confirm these tests fail when the wrapper (or the cancel link) is
reverted.

See spring-projectsgh-1548

Generated by Josh-Bot 9000

Signed-off-by: Josh Gordineer <jgordineer@netflix.com>
@joshgord

joshgord commented Oct 25, 2026 •

Copy link
Copy Markdown
Author

Closing for now: testing found a regression with CompletableFuture.failedStage() results. I will reopen once the fix is validated.

@joshgord joshgord closed this Oct 25, 2026
@bclozel bclozel added status: declined A suggestion or change that we don't feel we should currently apply and removed status: waiting-for-triage An issue we've not yet triaged labels Oct 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: declined A suggestion or change that we don't feel we should currently apply

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants