diff --git a/src/main/java/graphql/ExecutionInput.java b/src/main/java/graphql/ExecutionInput.java index d65920fcb..a83c741e7 100644 --- a/src/main/java/graphql/ExecutionInput.java +++ b/src/main/java/graphql/ExecutionInput.java @@ -11,7 +11,8 @@ import java.util.Locale; import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.CompletableFuture; + import java.util.function.Consumer; import static graphql.Assert.assertNotNull; @@ -34,7 +35,7 @@ public class ExecutionInput { private final DataLoaderRegistry dataLoaderRegistry; private final ExecutionId executionId; private final Locale locale; - private final AtomicBoolean cancelled; + private final CompletableFuture cancellationFuture; private final boolean profileExecution; /** @@ -60,7 +61,7 @@ private ExecutionInput(Builder builder) { this.locale = builder.locale != null ? builder.locale : Locale.getDefault(); // always have a locale in place this.localContext = builder.localContext; this.extensions = builder.extensions; - this.cancelled = builder.cancelled; + this.cancellationFuture = builder.cancellationFuture; this.profileExecution = builder.profileExecution; } @@ -211,7 +212,7 @@ public Map getExtensions() { * @return true if the execution should be cancelled */ public boolean isCancelled() { - return cancelled.get(); + return cancellationFuture.isDone(); } /** @@ -219,7 +220,18 @@ public boolean isCancelled() { * and the graphql engine needs to be running on a thread to allow is to respect this flag. */ public void cancel() { - cancelled.set(true); + cancellationFuture.complete(null); + } + + /** + * Returns a {@link CompletableFuture} that completes when {@link #cancel()} is called. + * This allows async code to race against cancellation without polling. + * + * @return a future that completes (with null) when this execution is cancelled + */ + @Internal + public CompletableFuture getCancellationFuture() { + return cancellationFuture; } @@ -241,7 +253,7 @@ public ExecutionInput transform(Consumer builderConsumer) { .operationName(this.operationName) .context(this.context) .internalTransferContext(this.graphQLContext) - .internalTransferCancelBoolean(this.cancelled) + .internalTransferCancellationFuture(this.cancellationFuture) .localContext(this.localContext) .root(this.root) .dataLoaderRegistry(this.dataLoaderRegistry) @@ -306,7 +318,7 @@ public static class Builder { private DataLoaderRegistry dataLoaderRegistry = EMPTY_DATALOADER_REGISTRY; private Locale locale = Locale.getDefault(); private ExecutionId executionId; - private AtomicBoolean cancelled = new AtomicBoolean(false); + private CompletableFuture cancellationFuture = new CompletableFuture<>(); private boolean profileExecution; /** @@ -412,9 +424,8 @@ private Builder internalTransferContext(GraphQLContext graphQLContext) { return this; } - // hidden on purpose - private Builder internalTransferCancelBoolean(AtomicBoolean cancelled) { - this.cancelled = cancelled; + private Builder internalTransferCancellationFuture(CompletableFuture cancellationFuture) { + this.cancellationFuture = cancellationFuture; return this; } diff --git a/src/main/java/graphql/GraphQLUnusualConfiguration.java b/src/main/java/graphql/GraphQLUnusualConfiguration.java index e96b0b738..852d8b4bb 100644 --- a/src/main/java/graphql/GraphQLUnusualConfiguration.java +++ b/src/main/java/graphql/GraphQLUnusualConfiguration.java @@ -277,6 +277,14 @@ public ResponseMapFactoryConfig responseMapFactory() { return new ResponseMapFactoryConfig(this); } + /** + * @return an element that allows you to control cancellation behavior + */ + @ExperimentalApi + public CancellationConfig cancellation() { + return new CancellationConfig(this); + } + private void put(String named, Object value) { if (graphQLContext != null) { graphQLContext.put(named, value); @@ -410,4 +418,53 @@ public ResponseMapFactoryConfig setFactory(ResponseMapFactory factory) { return this; } } + + public static class CancellationConfig extends BaseContextConfig { + + /** + * The context key used to enable capturing partial results when an execution is cancelled. + */ + @ExperimentalApi + public static final String CAPTURE_PARTIAL_RESULTS_ON_CANCEL = "graphql.capturePartialResultsOnCancel"; + + /** + * The context key used to store the cancellation {@link java.util.concurrent.CompletableFuture} + * that completes when {@link ExecutionInput#cancel()} is called. + * This is only set when {@link #CAPTURE_PARTIAL_RESULTS_ON_CANCEL} is enabled. + */ + @Internal + public static final String CANCELLATION_FUTURE_KEY = CAPTURE_PARTIAL_RESULTS_ON_CANCEL + ".cancelFuture"; + + private CancellationConfig(GraphQLContextConfiguration contextConfig) { + super(contextConfig); + } + + /** + * Returns true if partial results should be captured when the execution is cancelled via + * {@link ExecutionInput#cancel()}. + * + * @return true if partial results capture on cancel is enabled + */ + @ExperimentalApi + public boolean isCapturePartialResultsOnCancelEnabled() { + return contextConfig.getBoolean(CAPTURE_PARTIAL_RESULTS_ON_CANCEL); + } + + /** + * When enabled, if {@link ExecutionInput#cancel()} is called during execution, the engine will + * return the partial results of any fields that have already completed, along with an error + * indicating the execution was cancelled. + *

+ * By default this is false and cancellation returns only the cancellation error with null data. + * + * @param enable true to enable capturing partial results on cancel + * + * @return this config object for chaining + */ + @ExperimentalApi + public CancellationConfig capturePartialResultsOnCancel(boolean enable) { + contextConfig.put(CAPTURE_PARTIAL_RESULTS_ON_CANCEL, enable); + return this; + } + } } diff --git a/src/main/java/graphql/execution/AbstractAsyncExecutionStrategy.java b/src/main/java/graphql/execution/AbstractAsyncExecutionStrategy.java index 25f2036cb..682e8b583 100644 --- a/src/main/java/graphql/execution/AbstractAsyncExecutionStrategy.java +++ b/src/main/java/graphql/execution/AbstractAsyncExecutionStrategy.java @@ -2,8 +2,11 @@ import graphql.ExecutionResult; import graphql.ExecutionResultImpl; +import graphql.GraphQLContext; import graphql.PublicSpi; +import java.util.concurrent.CompletionException; + import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -29,8 +32,47 @@ protected BiConsumer, Throwable> handleResults(ExecutionContext exe return; } - Map resolvedValuesByField = executionContext.getResponseMapFactory().createInsertionOrdered(fieldNames, results); - overallResult.complete(new ExecutionResultImpl(resolvedValuesByField, executionContext.getErrors())); + completeResultFuture(overallResult, executionContext, fieldNames, results); }; } + + protected BiConsumer, Throwable> handleResultsWithPartialData(ExecutionContext executionContext, List fieldNames, CompletableFuture overallResult) { + return (List results, Throwable exception) -> { + // when partial results on cancel is enabled the results list will already have partial data + // (already-completed fields) so we can build a partial response even if exception is set + if (exception != null) { + Throwable cause = exception instanceof CompletionException ? exception.getCause() : exception; + if (cause instanceof AbortExecutionException && results != null + && capturePartialResults(executionContext)) { + executionContext.addError((AbortExecutionException) cause); + completeResultFuture(overallResult, executionContext, fieldNames, results); + return; + } + handleNonNullException(executionContext, overallResult, exception); + return; + } + + // check if cancel fired while results were being gathered (no exception, but cancelled) + Throwable cancelException = executionContext.possibleCancellation(null); + if (cancelException != null) { + Throwable cancelCause = cancelException instanceof CompletionException ? cancelException.getCause() : cancelException; + if (cancelCause instanceof AbortExecutionException && results != null + && capturePartialResults(executionContext)) { + // we have partial data from already-completed CFs — use it + executionContext.addError((AbortExecutionException) cancelCause); + completeResultFuture(overallResult, executionContext, fieldNames, results); + } else { + handleNonNullException(executionContext, overallResult, cancelException); + } + return; + } + + completeResultFuture(overallResult, executionContext, fieldNames, results); + }; + } + + protected void completeResultFuture(CompletableFuture overallResult, ExecutionContext executionContext, List fieldNames, List results) { + Map resolvedValuesByField = executionContext.getResponseMapFactory().createInsertionOrdered(fieldNames, results); + overallResult.complete(new ExecutionResultImpl(resolvedValuesByField, executionContext.getErrors())); + } } diff --git a/src/main/java/graphql/execution/Async.java b/src/main/java/graphql/execution/Async.java index 8fc6ec0cf..aff242a26 100644 --- a/src/main/java/graphql/execution/Async.java +++ b/src/main/java/graphql/execution/Async.java @@ -1,7 +1,9 @@ package graphql.execution; import graphql.Assert; +import graphql.GraphQLContext; import graphql.Internal; +import graphql.GraphQLUnusualConfiguration; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -21,6 +23,8 @@ import java.util.stream.Collectors; import static graphql.Assert.assertTrue; +import static graphql.GraphQLUnusualConfiguration.CancellationConfig.CANCELLATION_FUTURE_KEY; +import static graphql.GraphQLUnusualConfiguration.CancellationConfig.CAPTURE_PARTIAL_RESULTS_ON_CANCEL; import static java.util.stream.Collectors.toList; @Internal @@ -57,6 +61,17 @@ public interface CombinedBuilder { */ CompletableFuture> await(); + /** + * Like {@link #await()} but when {@link GraphQLUnusualConfiguration.CancellationConfig#CAPTURE_PARTIAL_RESULTS_ON_CANCEL} + * is enabled in the context and an {@link AbortExecutionException} causes a CF to fail, the already-completed + * CFs will have their values harvested and returned as partial results rather than completing exceptionally. + * + * @param graphQLContext the context to check for the partial results flag + * + * @return a CompletableFuture to a List of values (possibly partial on cancellation) + */ + CompletableFuture> await(GraphQLContext graphQLContext); + /** * This will return a {@code CompletableFuture>} if ANY of the input values are async * otherwise it just return a materialised {@code List} @@ -104,6 +119,11 @@ public CompletableFuture> await() { return typedEmpty(); } + @Override + public CompletableFuture> await(GraphQLContext graphQLContext) { + return await(); + } + @Override public Object awaitPolymorphic() { Assert.assertTrue(ix == 0, () -> "expected size was " + 0 + " got " + ix); @@ -149,6 +169,11 @@ public CompletableFuture> await() { return CompletableFuture.completedFuture(Collections.singletonList((T) value)); } + @Override + public CompletableFuture> await(GraphQLContext graphQLContext) { + return await(); + } + @Override public Object awaitPolymorphic() { commonSizeAssert(); @@ -232,6 +257,101 @@ public CompletableFuture> await() { return overallResult; } + @SuppressWarnings("unchecked") + @Override + public CompletableFuture> await(GraphQLContext graphQLContext) { + commonSizeAssert(); + if (cfCount == 0) { + return CompletableFuture.completedFuture(materialisedList(array)); + } + if (!graphQLContext.getBoolean(CAPTURE_PARTIAL_RESULTS_ON_CANCEL)) { + return await(); + } + + CompletableFuture cancellationFuture = graphQLContext.get(CANCELLATION_FUTURE_KEY); + if (cancellationFuture == null) { + return await(); + } + + CompletableFuture> overallResult = new CompletableFuture<>(); + CompletableFuture[] cfsArr = copyOnlyCFsToArray(); + CompletableFuture allOf = CompletableFuture.allOf(cfsArr); + + // race: either all CFs complete normally, or cancellation fires first + CompletableFuture.anyOf(allOf, cancellationFuture) + .whenComplete((ignored, exception) -> { + if (overallResult.isDone()) { + return; + } + if (exception != null) { + // a CF failed — not our abort path, propagate + overallResult.completeExceptionally(exception); + return; + } + if (!allOf.isDone()) { + // cancellation fired before allOf: harvest already-completed CFs + List partialResults = harvestPartialResults(array); + overallResult.complete(partialResults); + return; + } + // allOf finished normally: collect all results + overallResult.complete(collectAllResults(array, cfsArr)); + }); + + // also handle when allOf completes (either normally or exceptionally) after the race + allOf.whenComplete((ignored, exception) -> { + if (overallResult.isDone()) { + return; + } + if (exception != null) { + // a CF in allOf failed — propagate + overallResult.completeExceptionally(exception); + return; + } + overallResult.complete(collectAllResults(array, cfsArr)); + }); + + return overallResult; + } + + @SuppressWarnings("unchecked") + private List harvestPartialResults(Object[] array) { + List partialResults = new ArrayList<>(array.length); + for (Object object : array) { + if (object instanceof CompletableFuture) { + CompletableFuture cf = (CompletableFuture) object; + if (cf.isDone() && !cf.isCompletedExceptionally()) { + partialResults.add(cf.join()); + } else { + partialResults.add(null); + } + } else { + partialResults.add((T) object); + } + } + return partialResults; + } + + @SuppressWarnings("unchecked") + private List collectAllResults(Object[] array, CompletableFuture[] cfsArr) { + List results = new ArrayList<>(array.length); + if (cfsArr.length == array.length) { + for (CompletableFuture cf : cfsArr) { + results.add(cf.join()); + } + } else { + for (Object object : array) { + if (object instanceof CompletableFuture) { + CompletableFuture cf = (CompletableFuture) object; + results.add(cf.join()); + } else { + results.add((T) object); + } + } + } + return results; + } + @SuppressWarnings("unchecked") @NonNull private CompletableFuture[] copyOnlyCFsToArray() { diff --git a/src/main/java/graphql/execution/AsyncExecutionStrategy.java b/src/main/java/graphql/execution/AsyncExecutionStrategy.java index 325a5829a..0d4c94d20 100644 --- a/src/main/java/graphql/execution/AsyncExecutionStrategy.java +++ b/src/main/java/graphql/execution/AsyncExecutionStrategy.java @@ -1,6 +1,7 @@ package graphql.execution; import graphql.ExecutionResult; +import graphql.GraphQLContext; import graphql.PublicApi; import graphql.execution.incremental.DeferredExecutionSupport; import org.jspecify.annotations.NullMarked; @@ -10,9 +11,12 @@ import graphql.introspection.Introspection; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.function.BiConsumer; +import java.util.stream.Collectors; /** * The standard graphql execution strategy that runs fields asynchronously non-blocking. @@ -64,14 +68,36 @@ public CompletableFuture execute(ExecutionContext executionCont CompletableFuture overallResult = new CompletableFuture<>(); executionStrategyCtx.onDispatched(); - futures.await().whenComplete((completeValueInfos, throwable) -> { + GraphQLContext graphQLContext = executionContext.getGraphQLContext(); + futures.await(graphQLContext).whenComplete((completeValueInfos, throwable) -> { List fieldsExecutedOnInitialResult = deferredExecutionSupport.getNonDeferredFieldNames(fieldNames); - BiConsumer, Throwable> handleResultsConsumer = handleResults(executionContext, fieldsExecutedOnInitialResult, overallResult); throwable = executionContext.possibleCancellation(throwable); if (throwable != null) { - handleResultsConsumer.accept(null, throwable.getCause()); + if (capturePartialResults(executionContext) && completeValueInfos != null) { + // partial results: some FieldValueInfos completed before cancel - build with those + // null entries mean that FieldValueInfo CF wasn't done yet (field was cancelled) + Async.CombinedBuilder fieldValuesFutures = Async.ofExpectedSize(completeValueInfos.size()); + for (FieldValueInfo completeValueInfo : completeValueInfos) { + if (completeValueInfo != null) { + fieldValuesFutures.addObject(completeValueInfo.getFieldValueObject()); + } else { + fieldValuesFutures.addObject((Object) null); + } + } + List nonNullValueInfos = completeValueInfos.stream() + .filter(Objects::nonNull) + .collect(Collectors.toList()); + dataLoaderDispatcherStrategy.executionStrategyOnFieldValuesInfo(nonNullValueInfos, parameters); + executionStrategyCtx.onFieldValuesInfo(nonNullValueInfos); + // Let handleResultsWithPartialData add the error to avoid duplication + BiConsumer, Throwable> fieldValuesConsumer = handleResultsWithPartialData(executionContext, fieldsExecutedOnInitialResult, overallResult); + fieldValuesFutures.await(graphQLContext).whenComplete(fieldValuesConsumer); + return; + } + Throwable cause = throwable instanceof CompletionException ? throwable.getCause() : throwable; + handleResults(executionContext, fieldsExecutedOnInitialResult, overallResult).accept(null, cause); return; } @@ -81,7 +107,9 @@ public CompletableFuture execute(ExecutionContext executionCont } dataLoaderDispatcherStrategy.executionStrategyOnFieldValuesInfo(completeValueInfos, parameters); executionStrategyCtx.onFieldValuesInfo(completeValueInfos); - fieldValuesFutures.await().whenComplete(handleResultsConsumer); + + BiConsumer, Throwable> fieldValuesConsumer = handleResultsWithPartialData(executionContext, fieldsExecutedOnInitialResult, overallResult); + fieldValuesFutures.await(graphQLContext).whenComplete(fieldValuesConsumer); }).exceptionally((ex) -> { // if there are any issues with combining/handling the field results, // complete the future at all costs and bubble up any thrown exception so diff --git a/src/main/java/graphql/execution/Execution.java b/src/main/java/graphql/execution/Execution.java index 448d1c738..df7a0f6ae 100644 --- a/src/main/java/graphql/execution/Execution.java +++ b/src/main/java/graphql/execution/Execution.java @@ -10,6 +10,7 @@ import graphql.GraphQL; import graphql.GraphQLContext; import graphql.GraphQLError; +import graphql.GraphQLUnusualConfiguration; import graphql.GraphQLException; import graphql.Internal; import graphql.Profiler; @@ -144,6 +145,13 @@ public CompletableFuture execute(Document document, GraphQLSche executionContext.getGraphQLContext().put(ResultNodesInfo.RESULT_NODES_INFO, executionContext.getResultNodesInfo()); + // When partial results on cancel is enabled, store the cancellation future in the context + // so that Async.Many#await(GraphQLContext) can race against it + if (graphQLContext.getBoolean(GraphQLUnusualConfiguration.CancellationConfig.CAPTURE_PARTIAL_RESULTS_ON_CANCEL)) { + graphQLContext.put(GraphQLUnusualConfiguration.CancellationConfig.CANCELLATION_FUTURE_KEY, + executionInput.getCancellationFuture()); + } + InstrumentationExecutionParameters parameters = new InstrumentationExecutionParameters( executionInput, graphQLSchema ); diff --git a/src/main/java/graphql/execution/ExecutionStrategy.java b/src/main/java/graphql/execution/ExecutionStrategy.java index 713da688d..fe51143a0 100644 --- a/src/main/java/graphql/execution/ExecutionStrategy.java +++ b/src/main/java/graphql/execution/ExecutionStrategy.java @@ -5,6 +5,7 @@ import graphql.EngineRunningState; import graphql.ExecutionResult; import graphql.ExecutionResultImpl; +import graphql.GraphQLContext; import graphql.GraphQLError; import graphql.Internal; import graphql.PublicSpi; @@ -58,6 +59,7 @@ import java.util.function.Function; import java.util.function.Supplier; +import static graphql.GraphQLUnusualConfiguration.CancellationConfig.CAPTURE_PARTIAL_RESULTS_ON_CANCEL; import static graphql.execution.Async.exceptionallyCompletedFuture; import static graphql.execution.FieldCollectorParameters.newParameters; import static graphql.execution.FieldValueInfo.CompleteValueType.ENUM; @@ -218,6 +220,7 @@ protected Object executeObject(ExecutionContext executionContext, ExecutionStrat resolveObjectCtx.onDispatched(); + GraphQLContext graphQLContext = executionContext.getGraphQLContext(); Object fieldValueInfosResult = resolvedFieldFutures.awaitPolymorphic(); if (fieldValueInfosResult instanceof CompletableFuture) { CompletableFuture> fieldValueInfos = (CompletableFuture>) fieldValueInfosResult; @@ -232,7 +235,7 @@ protected Object executeObject(ExecutionContext executionContext, ExecutionStrat Async.CombinedBuilder resultFutures = fieldValuesCombinedBuilder(completeValueInfos); dataLoaderDispatcherStrategy.executeObjectOnFieldValuesInfo(completeValueInfos, parameters); resolveObjectCtx.onFieldValuesInfo(completeValueInfos); - resultFutures.await().whenComplete(handleResultsConsumer); + resultFutures.await(graphQLContext).whenComplete(handleResultsConsumer); }).exceptionally((ex) -> { // if there are any issues with combining/handling the field results, // complete the future at all costs and bubble up any thrown exception so @@ -273,19 +276,35 @@ protected Object executeObject(ExecutionContext executionContext, ExecutionStrat return resultFutures; } + protected boolean capturePartialResults(ExecutionContext executionContext) { + return executionContext.getGraphQLContext().getBoolean(CAPTURE_PARTIAL_RESULTS_ON_CANCEL); + } + private BiConsumer, Throwable> buildFieldValueMap(List fieldNames, CompletableFuture> overallResult, ExecutionContext executionContext) { return (List results, Throwable exception) -> { exception = executionContext.possibleCancellation(exception); if (exception != null) { + Throwable cause = exception instanceof CompletionException ? exception.getCause() : exception; + if (cause instanceof AbortExecutionException && results != null + && capturePartialResults(executionContext)) { + // partial results mode: results already has harvested values from completed CFs + executionContext.addError((AbortExecutionException) cause); + completeFieldValueMap(overallResult, executionContext, fieldNames, results); + return; + } handleValueException(overallResult, exception, executionContext); return; } - Map resolvedValuesByField = executionContext.getResponseMapFactory().createInsertionOrdered(fieldNames, results); - overallResult.complete(resolvedValuesByField); + completeFieldValueMap(overallResult, executionContext, fieldNames, results); }; } + protected void completeFieldValueMap(CompletableFuture> overallResult, ExecutionContext executionContext, List fieldNames, List results) { + Map resolvedValuesByField = executionContext.getResponseMapFactory().createInsertionOrdered(fieldNames, results); + overallResult.complete(resolvedValuesByField); + } + DeferredExecutionSupport createDeferredExecutionSupport(ExecutionContext executionContext, ExecutionStrategyParameters parameters) { MergedSelectionSet fields = parameters.getFields(); diff --git a/src/test/groovy/graphql/ExecutionInputTest.groovy b/src/test/groovy/graphql/ExecutionInputTest.groovy index 23c06ea9c..84819950b 100644 --- a/src/test/groovy/graphql/ExecutionInputTest.groovy +++ b/src/test/groovy/graphql/ExecutionInputTest.groovy @@ -451,6 +451,178 @@ class ExecutionInputTest extends Specification { } + def "capturePartialResultsOnCancel configuration is accessible via unusualConfiguration"() { + // Smoke test: verify the configuration API works correctly + + def sdl = ''' + type Query { + field1 : String + field2 : String + } + ''' + + DataFetcher df = { DataFetchingEnvironment env -> "value" } + + def fetcherMap = ["Query": ["field1": df, "field2": df]] + def schema = TestUtil.schema(sdl, fetcherMap) + def graphQL = GraphQL.newGraphQL(schema).build() + + when: "capturePartialResultsOnCancel is enabled and execution completes normally" + ExecutionInput executionInput = ExecutionInput.newExecutionInput() + .query("{ field1 field2 }") + .graphQLContext({ c -> + GraphQL.unusualConfiguration(c).cancellation().capturePartialResultsOnCancel(true) + }) + .build() + + def er = graphQL.execute(executionInput) + + then: "normal results are returned unchanged" + er.errors.isEmpty() + er.data == [field1: "value", field2: "value"] + + when: "capturePartialResultsOnCancel is enabled but cancel is called before execution" + ExecutionInput cancelledInput = ExecutionInput.newExecutionInput() + .query("{ field1 field2 }") + .graphQLContext({ c -> + GraphQL.unusualConfiguration(c).cancellation().capturePartialResultsOnCancel(true) + }) + .build() + cancelledInput.cancel() + + er = graphQL.execute(cancelledInput) + + then: "cancel error is returned" + !er.errors.isEmpty() + er.errors.any { it["message"].contains("Execution has been asked to be cancelled") } + } + + def "capturePartialResultsOnCancel returns fast field value when slow field is cancelled"() { + // The partial-results harvest happens at the fieldValuesFutures.await(graphQLContext) level. + // FieldValueInfo.getFieldValueObject() for async object fields is a CompletableFuture. + // When the slow object's CF hasn't completed yet and cancel fires, await(graphQLContext) + // harvests the already-done fast CF and returns partial data. + def sdl = ''' + type Query { + fast : Inner + slow : Inner + } + type Inner { + value : String + } + ''' + + CountDownLatch slowStarted = new CountDownLatch(1) + CountDownLatch slowRelease = new CountDownLatch(1) + + DataFetcher fastInnerDf = { DataFetchingEnvironment env -> + return CompletableFuture.completedFuture([value: "fast-value"]) + } + + DataFetcher slowInnerDf = { DataFetchingEnvironment env -> + return CompletableFuture.supplyAsync { + slowStarted.countDown() + slowRelease.await() + return [value: "slow-value"] + } + } + + DataFetcher innerValueDf = { DataFetchingEnvironment env -> + return env.source["value"] + } + + def fetcherMap = [ + "Query": ["fast": fastInnerDf, "slow": slowInnerDf], + "Inner": ["value": innerValueDf] + ] + def schema = TestUtil.schema(sdl, fetcherMap) + def graphQL = GraphQL.newGraphQL(schema).build() + + when: + ExecutionInput executionInput = ExecutionInput.newExecutionInput() + .query("{ fast { value } slow { value } }") + .graphQLContext({ c -> + GraphQL.unusualConfiguration(c).cancellation().capturePartialResultsOnCancel(true) + }) + .build() + + def cf = graphQL.executeAsync(executionInput) + + // wait for slow DF to have started; by this point fast has already completed + slowStarted.await() + + // cancel while slow is still blocked; fast's fieldValue CF is already done + executionInput.cancel() + + // unblock slow so it doesn't hang forever + slowRelease.countDown() + + await().atMost(Duration.ofSeconds(10)).until({ -> cf.isDone() }) + def er = cf.join() + + then: + !cf.isCompletedExceptionally() + !er.errors.isEmpty() + er.errors.any { it["message"].contains("Execution has been asked to be cancelled") } + // fast field completed before cancel — its data should be present + er.data != null + er.data["fast"] != null + er.data["fast"]["value"] == "fast-value" + // slow field was cancelled — should be null + er.data["slow"] == null + } + + def "without capturePartialResultsOnCancel only the cancel error is returned"() { + def sdl = ''' + type Query { + fast : String + slow : String + } + ''' + + CountDownLatch fastDone = new CountDownLatch(1) + CountDownLatch slowLatch = new CountDownLatch(1) + + DataFetcher fastDf = { DataFetchingEnvironment env -> + return CompletableFuture.supplyAsync { + fastDone.countDown() + return "fast-value" + } + } + + DataFetcher slowDf = { DataFetchingEnvironment env -> + return CompletableFuture.supplyAsync { + slowLatch.await() + return "slow-value" + } + } + + def fetcherMap = ["Query": ["fast": fastDf, "slow": slowDf]] + def schema = TestUtil.schema(sdl, fetcherMap) + def graphQL = GraphQL.newGraphQL(schema).build() + + when: + ExecutionInput executionInput = ExecutionInput.newExecutionInput() + .query("{ fast slow }") + .build() + + def cf = graphQL.executeAsync(executionInput) + + fastDone.await() + executionInput.cancel() + slowLatch.countDown() + + await().atMost(Duration.ofSeconds(10)).until({ -> cf.isDone() }) + def er = cf.join() + + then: + !cf.isCompletedExceptionally() + !er.errors.isEmpty() + er.errors[0]["message"] == "Execution has been asked to be cancelled" + // no partial data - old behaviour + er.data == null + } + private static ExecutionResult awaitAsync(GraphQL graphQL, ExecutionInput executionInput) { def cf = graphQL.executeAsync(executionInput) await().atMost(Duration.ofSeconds(10)).until({ -> cf.isDone() })