Repository navigation
Conversation
|
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): Results on this host (Azul Zulu 27, x86_64), 50k executions, pre-completed
Allocation drops ~2.3 KB per execution at 8 fields and ~7-8 KB at 32 fields (~250-300 B per DecoratorBench.javaimport 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);
}
} |
20bbe66 to
ef3cc09
Compare
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>
2ce9482 to
8fafa5c
Compare
|
Closing for now: testing found a regression with CompletableFuture.failedStage() results. I will reopen once the fix is validated. |
Summary
For every data fetcher that returns a
CompletionStage,ContextDataFetcherDecorator.updateValueconverted 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
CompletionStagedirectly: aCompletableFuturewhose completion callback installs theContextSnapshotscope while completing normally or exceptionally.Publisherresults 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
captureFromsource when the local context is the sameGraphQLContextinstance as the execution context, avoiding a second full enumeration of the same map per field.Closes gh-1548
Behavior
Mono.fromCompletionStagedid through its subscription) and unwrapsCompletionExceptionbefore completing exceptionally (asMonoCompletionStagedid beforeonError), soDataFetcherExceptionResolvers 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.localContextvalues) 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:
ObjectAllocationSampleweighted), replaced by ~24 MB of direct-wrap machinery — roughly 6x less allocation per suspending field.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. ReactivePublisherresults, including@SchemaMappingsuspend functions (which produce aMono), still take the Reactor path by design.Testing
ContextDataFetcherDecoratorTests(18 tests): 3 consecutive--rerunexecutions, 0 failures. Coverage: cross-thread ThreadLocal propagation observed from an undecorated (TrivialDataFetcher) child of the completed value; the unwrapped exception type reaching aDataFetcherExceptionResolver; cancellation propagating to the source future;CompletionExceptionunwrapping on both the in-flight and already-failed paths; scope-failure handling; already-completed futures returned as-is.return stage, 4 tests fail deterministically (completableFutureCompletedOnAnotherThread,originalFailureIsPrimaryWhenScopeFails,completableFutureUnwrapsCompletionException,alreadyFailedFutureIsUnwrapped).checkstyleMain/checkstyleTestpass.