Skip to content
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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It dawns on me that it would be very useful to know which fields were still executing when we interrupted (as they or their parents were likely the reason we timed out) - but this would add a considerable amount of complexity to an already complex PR. Maybe for a future one.

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()));
}
}
78 changes: 63 additions & 15 deletions src/main/java/graphql/execution/Async.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ public class Async {
*/
public interface CombinedBuilder<T> {

/**
* Controls how {@link #await(CompletableFuture, OnCancel)} treats tracked futures that are still
* pending when cancellation is signalled before they have all completed.
*/
enum OnCancel {
/**
* Harvest immediately when cancellation is signalled; any still-pending future becomes {@code null}.
* Safe for futures that may block indefinitely on cancellation (for example raw data fetcher futures).
*/
DROP_PENDING,
/**
* Wait for still-pending futures to settle before harvesting, so their own partial data is
* captured. Only safe for cancellation-aware futures that are guaranteed to complete promptly after
* cancellation (for example nested object or list results that capture their own partial data).
*/
WAIT_FOR_PENDING
}

/**
* This adds a {@link CompletableFuture} into the collection of results
*
Expand All @@ -58,18 +76,33 @@ public interface CombinedBuilder<T> {
CompletableFuture<List<T>> await();

/**
* Like {@link #await()} but races against the given cancellation future. If the cancellation future
* completes before all the tracked futures complete, the already-completed futures will have their
* values harvested and returned as partial results (with {@code null} for incomplete entries)
* rather than completing exceptionally.
* Like {@link #await()} but also waits on the given cancellation future. This waits on two things at
* once - all the tracked futures completing, and the cancellation future completing - and proceeds as
* soon as either happens. If cancellation is signalled before all the tracked futures have completed,
* the already-completed futures have their values harvested and returned as partial results rather than
* completing exceptionally.
*
* <p>The {@code onCancel} policy controls what happens to the tracked futures that are still pending
* when cancellation is signalled first:
* <ul>
* <li>{@link OnCancel#DROP_PENDING} - harvest immediately, pending futures become {@code null}.
* Use this when the tracked futures may block indefinitely on cancellation (for example raw data
* fetcher futures).</li>
* <li>{@link OnCancel#WAIT_FOR_PENDING} - wait for the still-pending futures to settle before
* harvesting, so their own partial data is captured. Use this only when the tracked futures are
* themselves cancellation-aware and therefore guaranteed to complete promptly after cancellation
* (for example nested object or list results that capture their own partial data on cancel);
* otherwise the returned future may never complete.</li>
* </ul>
*
* <p>If {@code cancellationFuture} is {@code null}, this behaves identically to {@link #await()}.
*
* @param cancellationFuture a future that, when completed, signals cancellation; may be {@code null}
* @param onCancel how to treat still-pending tracked futures if cancellation is signalled first
*
* @return a CompletableFuture to a List of values (possibly partial on cancellation)
*/
CompletableFuture<List<T>> await(@Nullable CompletableFuture<Void> cancellationFuture);
CompletableFuture<List<T>> await(@Nullable CompletableFuture<Void> cancellationFuture, OnCancel onCancel);

/**
* This will return a {@code CompletableFuture<List<T>>} if ANY of the input values are async
Expand Down Expand Up @@ -119,7 +152,7 @@ public CompletableFuture<List<T>> await() {
}

@Override
public CompletableFuture<List<T>> await(@Nullable CompletableFuture<Void> cancellationFuture) {
public CompletableFuture<List<T>> await(@Nullable CompletableFuture<Void> cancellationFuture, OnCancel onCancel) {
return await();
}

Expand Down Expand Up @@ -168,7 +201,7 @@ public CompletableFuture<List<T>> await() {
}

@Override
public CompletableFuture<List<T>> await(@Nullable CompletableFuture<Void> cancellationFuture) {
public CompletableFuture<List<T>> await(@Nullable CompletableFuture<Void> cancellationFuture, OnCancel onCancel) {
commonSizeAssert();
if (cancellationFuture == null) {
return await();
Expand All @@ -183,6 +216,12 @@ public CompletableFuture<List<T>> await(@Nullable CompletableFuture<Void> cancel
overallResult.completeExceptionally(exception);
return;
}
if (onCancel == OnCancel.WAIT_FOR_PENDING && !valueCF.isDone()) {
// the tracked future is cancellation-aware and will complete promptly - wait for
// its partial data rather than harvesting a null for it (see interface javadoc)
valueCF.whenComplete((v, ex) -> overallResult.complete(Collections.singletonList(doneOrNull(valueCF))));
return;
}
overallResult.complete(Collections.singletonList(doneOrNull(valueCF)));
});
return overallResult;
Expand Down Expand Up @@ -279,7 +318,7 @@ public CompletableFuture<List<T>> await() {
}

@Override
public CompletableFuture<List<T>> await(@Nullable CompletableFuture<Void> cancellationFuture) {
public CompletableFuture<List<T>> await(@Nullable CompletableFuture<Void> cancellationFuture, OnCancel onCancel) {
commonSizeAssert();
if (cfCount == 0) {
return CompletableFuture.completedFuture(materialisedList(array));
Expand All @@ -291,18 +330,27 @@ public CompletableFuture<List<T>> await(@Nullable CompletableFuture<Void> cancel
CompletableFuture<List<T>> overallResult = new CompletableFuture<>();
CompletableFuture<Void> allOf = CompletableFuture.allOf(copyOnlyCFsToArray());

// Race "all field futures complete" against cancellation. The cancellation future always
// completes normally (see ExecutionInput#cancel), so anyOf can only complete exceptionally
// when a field future fails - in which case we propagate that failure.
// Wait for either "all field futures complete" (allOf) or cancellation, whichever happens
// first. The cancellation future always completes normally (see ExecutionInput#cancel), so
// anyOf can only complete exceptionally when a field future fails - which we propagate.
CompletableFuture.anyOf(allOf, cancellationFuture).whenComplete((ignored, exception) -> {
if (exception != null) {
overallResult.completeExceptionally(exception);
return;
}
// Either every field future is done (allOf won) or cancellation won the race. In both
// cases we harvest whatever has completed; field futures that are not yet done become
// null. join() is safe here: if allOf is not done then no field future has failed (a
// failure would have completed allOf exceptionally and taken the branch above).
if (onCancel == OnCancel.WAIT_FOR_PENDING && !allOf.isDone()) {
// Cancellation happened before all the field futures completed, but the caller knows
// every tracked future is itself cancellation-aware and will complete promptly (e.g.
// nested object/list results that harvest their own partial data on cancel). Wait for
// them to settle so we capture that partial data instead of harvesting null for
// entries that are just about to complete.
allOf.whenComplete((ig, ex) -> overallResult.complete(harvestResults(array)));
return;
}
// Either every field future is done (allOf completed first) or cancellation happened
// first. In both cases we harvest whatever has completed; field futures that are not yet
// done become null. join() is safe here: if allOf is not done then no field future has
// failed (a failure would have completed allOf exceptionally and taken the branch above).
overallResult.complete(harvestResults(array));
});

Expand Down
Loading
Loading