Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions src/main/java/graphql/ExecutionInput.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Void> cancellationFuture;
private final boolean profileExecution;

/**
Expand All @@ -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;
}

Expand Down Expand Up @@ -211,15 +212,26 @@ public Map<String, Object> getExtensions() {
* @return true if the execution should be cancelled
*/
public boolean isCancelled() {
return cancelled.get();
return cancellationFuture.isDone();
}

/**
* This can be called to cancel the graphql execution. Remember this is a cooperative cancellation
* 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<Void> getCancellationFuture() {
return cancellationFuture;
}


Expand All @@ -241,7 +253,7 @@ public ExecutionInput transform(Consumer<Builder> 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)
Expand Down Expand Up @@ -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<Void> cancellationFuture = new CompletableFuture<>();
private boolean profileExecution;

/**
Expand Down Expand Up @@ -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<Void> cancellationFuture) {
this.cancellationFuture = cancellationFuture;
return this;
}

Expand Down
57 changes: 57 additions & 0 deletions src/main/java/graphql/GraphQLUnusualConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
* <p>
* 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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,25 @@ protected BiConsumer<List<Object>, Throwable> handleResults(ExecutionContext exe
exception = executionContext.possibleCancellation(exception);

if (exception != null) {
// A cancellation that fired after some fields already completed arrives here as a
// synthesised AbortExecutionException with a non-null results list (a real field
// failure always has null results). When partial capture is enabled we keep those
// results and attach the cancellation error; otherwise we report the error as usual.
if (results != null && capturePartialResults(executionContext)) {
executionContext.addError((AbortExecutionException) exception);
completeResultFuture(overallResult, executionContext, fieldNames, results);
return;
}
handleNonNullException(executionContext, overallResult, exception);
return;
}

Map<String, Object> resolvedValuesByField = executionContext.getResponseMapFactory().createInsertionOrdered(fieldNames, results);
overallResult.complete(new ExecutionResultImpl(resolvedValuesByField, executionContext.getErrors()));
completeResultFuture(overallResult, executionContext, fieldNames, results);
};
}

protected void completeResultFuture(CompletableFuture<ExecutionResult> overallResult, ExecutionContext executionContext, List<String> fieldNames, List<Object> results) {
Map<String, Object> resolvedValuesByField = executionContext.getResponseMapFactory().createInsertionOrdered(fieldNames, results);
overallResult.complete(new ExecutionResultImpl(resolvedValuesByField, executionContext.getErrors()));
}
}
61 changes: 49 additions & 12 deletions src/main/java/graphql/execution/AsyncExecutionStrategy.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@
import graphql.PublicApi;
import graphql.execution.incremental.DeferredExecutionSupport;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
import graphql.execution.instrumentation.ExecutionStrategyInstrumentationContext;
import graphql.execution.instrumentation.Instrumentation;
import graphql.execution.instrumentation.parameters.InstrumentationExecutionStrategyParameters;
import graphql.introspection.Introspection;

import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;

/**
* The standard graphql execution strategy that runs fields asynchronously non-blocking.
Expand Down Expand Up @@ -64,24 +66,23 @@ public CompletableFuture<ExecutionResult> execute(ExecutionContext executionCont
CompletableFuture<ExecutionResult> overallResult = new CompletableFuture<>();
executionStrategyCtx.onDispatched();

futures.await().whenComplete((completeValueInfos, throwable) -> {
CompletableFuture<Void> cancelCF = getCancellationFuture(executionContext);
futures.await(cancelCF).whenComplete((completeValueInfos, throwable) -> {
List<String> fieldsExecutedOnInitialResult = deferredExecutionSupport.getNonDeferredFieldNames(fieldNames);

BiConsumer<List<Object>, Throwable> handleResultsConsumer = handleResults(executionContext, fieldsExecutedOnInitialResult, overallResult);
throwable = executionContext.possibleCancellation(throwable);

if (throwable != null) {
handleResultsConsumer.accept(null, throwable.getCause());
if (throwable != null && !(completeValueInfos != null && capturePartialResults(executionContext))) {
// a genuine field failure, or a cancellation we cannot surface partial results for:
// there is nothing usable to return, so just report the error
handleResults(executionContext, fieldsExecutedOnInitialResult, overallResult).accept(null, throwable);
return;
}

Async.CombinedBuilder<Object> fieldValuesFutures = Async.ofExpectedSize(completeValueInfos.size());
for (FieldValueInfo completeValueInfo : completeValueInfos) {
fieldValuesFutures.addObject(completeValueInfo.getFieldValueObject());
}
dataLoaderDispatcherStrategy.executionStrategyOnFieldValuesInfo(completeValueInfos, parameters);
executionStrategyCtx.onFieldValuesInfo(completeValueInfos);
fieldValuesFutures.await().whenComplete(handleResultsConsumer);
// normal completion, or partial-results-on-cancel: completeValueInfos holds the
// FieldValueInfos that completed (with null entries for any cancelled before completing)
completeFieldValues(executionContext, parameters, executionStrategyCtx, dataLoaderDispatcherStrategy,
completeValueInfos, fieldsExecutedOnInitialResult, cancelCF, overallResult);
}).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
Expand All @@ -96,4 +97,40 @@ public CompletableFuture<ExecutionResult> execute(ExecutionContext executionCont
return overallResult;
}

/**
* Turns the completed {@link FieldValueInfo}s into field values and completes the {@code overallResult}.
* <p>
* When partial-results-on-cancel is in play {@code completeValueInfos} may contain {@code null}
* entries for fields that were cancelled before they completed; those become {@code null} field
* values and are excluded from the instrumentation callbacks.
*/
@SuppressWarnings("FutureReturnValueIgnored")
private void completeFieldValues(ExecutionContext executionContext,
ExecutionStrategyParameters parameters,
ExecutionStrategyInstrumentationContext executionStrategyCtx,
DataLoaderDispatchStrategy dataLoaderDispatcherStrategy,
List<FieldValueInfo> completeValueInfos,
List<String> fieldNames,
@Nullable CompletableFuture<Void> cancelCF,
CompletableFuture<ExecutionResult> overallResult) {
Async.CombinedBuilder<Object> fieldValuesFutures = Async.ofExpectedSize(completeValueInfos.size());
boolean hasNulls = false;
for (FieldValueInfo completeValueInfo : completeValueInfos) {
if (completeValueInfo != null) {
fieldValuesFutures.addObject(completeValueInfo.getFieldValueObject());
} else {
hasNulls = true;
fieldValuesFutures.addObject((Object) null);
}
}
// null entries only occur for partial-results-on-cancel; the instrumentation callbacks should
// not see them, so filter only when needed and otherwise pass the list straight through
List<FieldValueInfo> valueInfosForInstrumentation = hasNulls
? completeValueInfos.stream().filter(Objects::nonNull).collect(Collectors.toList())
: completeValueInfos;
dataLoaderDispatcherStrategy.executionStrategyOnFieldValuesInfo(valueInfosForInstrumentation, parameters);
executionStrategyCtx.onFieldValuesInfo(valueInfosForInstrumentation);
fieldValuesFutures.await(cancelCF).whenComplete(handleResults(executionContext, fieldNames, overallResult));
}

}
8 changes: 8 additions & 0 deletions src/main/java/graphql/execution/Execution.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -144,6 +145,13 @@ public CompletableFuture<ExecutionResult> 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
);
Expand Down
Loading
Loading