Capture partial results on cancellation#4398
Conversation
Web clients can often impose request timeouts. If a GraphQL server takes too long to respond, then nothing is returned. While we can call `ExecutionInput.cancel()` in order to slide a web client's timeout, there are no usable results for the client to use. This change improves on this - if we set `#capturePartialResultsOnCancel`, then calling `.cancel` will save all the evaluated `DataFetcher` results so that something potentially usable can be returned to the client. Full disclosure: almost all of this was generated with Rovo, with a preliminary review from Brad before submitting here.
Test ReportTest Results
Code Coverage (Java 25)
Changed Class Coverage (4 classes)
ExecutionStrategy — method details
|
| return await(); | ||
| } | ||
|
|
||
| CompletableFuture<Void> cancellationFuture = graphQLContext.get(CANCELLATION_FUTURE_KEY); |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
|
|
||
| futures.await().whenComplete((completeValueInfos, throwable) -> { | ||
| GraphQLContext graphQLContext = executionContext.getGraphQLContext(); | ||
| futures.await(graphQLContext).whenComplete((completeValueInfos, throwable) -> { |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
| } | ||
|
|
||
| CompletableFuture<Void> cancellationFuture = graphQLContext.get(CANCELLATION_FUTURE_KEY); | ||
| if (cancellationFuture == null) { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
`Async` is intended to be a simple utility class - we thus just pass in the `CompletableFuture`s that are necessary to have this function.
| def list = resultCF.join() | ||
|
|
||
| then: | ||
| list == ["X", "Y"] |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
The idea was that we still get our results out even if the cancelCF hasn't completed. I've fixed up the test to clarify this.
| def list = asyncBuilder.await(cancelCF).join() | ||
|
|
||
| then: | ||
| list == ["A", "B", "C"] |
There was a problem hiding this comment.
Maybe add a test with a mix of materialised and CF objects
There was a problem hiding this comment.
I think we had this with await with cancelCF returns partial results with mixed objects and CFs - are you thinking of a different kind of test case?
| def list = asyncBuilder.await(cancelCF).join() | ||
|
|
||
| then: | ||
| list == ["A"] |
There was a problem hiding this comment.
Is there some condition then where if there is only 1 Cf then the cancel wont be listened to?
Should we test for that?
There was a problem hiding this comment.
Good point - have added a test for this.
|
|
||
| if (throwable != null) { | ||
| handleResultsConsumer.accept(null, throwable.getCause()); | ||
| if (capturePartialResults(executionContext) && completeValueInfos != null) { |
There was a problem hiding this comment.
maybe reverse that check completeValueInfos != null is cheaper than checking capturePartialResults(executionContext)
bbakerman
left a comment
There was a problem hiding this comment.
I have left some improvement comments but overall I am happy with this
| // race: either all CFs complete normally, or cancellation fires first | ||
| CompletableFuture.anyOf(allOf, cancellationFuture) | ||
| .whenComplete((ignored, exception) -> { | ||
| if (overallResult.isDone()) { |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
| }); | ||
|
|
||
| // also handle when allOf completes (either normally or exceptionally) after the race | ||
| allOf.whenComplete((ignored, exception) -> { |
There was a problem hiding this comment.
I don't fully understand this here: when can this happen exactly and why do we want to handle it?
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
|
@AlexandreCarlton In general we need the 100% code coverage to proof that all new code is actually reachable (because AI is great in inventing unnecessary defensive logic and checks) |
Collapse the two whenComplete callbacks into a single one on anyOf(allOf, cancellationFuture). The previous second callback on allOf was unreachable dead code - anyOf already fires when either future completes - and the overallResult.isDone() guards could never be true, hurting branch coverage. Merge harvestPartialResults and collectAllResults into a single harvestResults helper: when allOf has won the race every field future is done so it yields the full result, and when cancellation wins it yields the partial result with null for not-yet-done futures. join() is safe because allOf being incomplete implies no field future has failed. All new code is now 100% line and branch covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The exception != null branch tried to capture partial results, but a whenComplete((value, throwable)) callback always delivers a null value when a throwable is present, so the results != null guard was never true and that capture branch was unreachable (JaCoCo confirmed zero coverage). The exception path now simply delegates to handleNonNullException. Also drop two dead CompletionException unwraps - possibleCancellation returns a raw AbortExecutionException, never a wrapped one - and the always-true instanceof/results != null checks on the cancellation path. No behavioural change (full suite passes); the method is now 100% line and branch covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
handleResultsWithPartialData differed from handleResults in exactly one case: a cancellation that fired after some fields completed shows up as a synthesised AbortExecutionException with a non-null results list (a real field failure always carries null results). Fold that single case into handleResults via a `results != null && capturePartialResults` check and delete handleResultsWithPartialData. In AsyncExecutionStrategy.execute the partial-results-on-cancel branch and the normal branch both built field values, fired the instrumentation callbacks and awaited the same way - only differing in tolerating null FieldValueInfo entries. Extract that into completeFieldValues, used by both paths, shrinking the execute() lambda back to roughly its pre-feature shape. The instrumentation list is only copied/filtered when null entries are actually present, so the common path stays allocation-free. Also drop a redundant CompletionException unwrap (handleNonNullException already unwraps) and the now-unused imports. Add a test for the previously-untested behaviour where, with capture disabled, a late cancel discards even fully gathered nested data. All changed code is 100% line and branch covered; full suite passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@copilot resolve the merge conflicts in this pull request |
| public CompletableFuture<List<T>> await(@Nullable CompletableFuture<Void> cancellationFuture) { | ||
| return await(); | ||
| } | ||
|
|
There was a problem hiding this comment.
Sorry this is a bad merge via the web editor and now its not compiling.
I fixed locally but I cant push back to Alexandre branch it seems
Web clients can often impose request timeouts. If a GraphQL server takes too long to respond, then nothing is returned. While we can call
ExecutionInput.cancel()in order to slide under a web client's timeout, there are no usable results for the client to handle.This change improves upon this - if we set
#capturePartialResultsOnCancel, then calling.cancelwill save all the evaluatedDataFetcherresults so that something potentially usable can be returned to the client.Full disclosure: almost all of this was generated with Rovo, with a preliminary review from @bbakerman before submitting here.