-
Notifications
You must be signed in to change notification settings - Fork 631
Fix client-v2: cancelTransportRequest lost between two retry attempts #2990
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
63633b5
f4ef787
dd3aab1
5a65ecd
e52f655
43cdc47
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1465,50 +1465,55 @@ public CompletableFuture<InsertResponse> insert(String tableName, List<?> data, | |
| Endpoint selectedEndpoint = nodeSelector.getEndpoint(); | ||
| final String queryId = requestSettings.getQueryId(); | ||
| RuntimeException lastException = null; | ||
| for (int i = 0; i <= maxAttempts; i++) { | ||
| // Execute request | ||
| TransportRequest transportRequest = httpClientHelper.createRequest(selectedEndpoint, requestSettings.getAllSettings(), | ||
| out -> { | ||
| out.write("INSERT INTO ".getBytes()); | ||
| out.write(tableName.getBytes()); | ||
| out.write(" \n FORMAT ".getBytes()); | ||
| out.write(format.name().getBytes()); | ||
| out.write(" \n".getBytes()); | ||
| for (Object obj : data) { | ||
|
|
||
| for (POJOFieldSerializer serializer : serializersForTable) { | ||
| try { | ||
| serializer.serialize(obj, out); | ||
| } catch (InvocationTargetException | IllegalAccessException e) { | ||
| throw new DataSerializationException(obj, serializer, e); | ||
| try { | ||
| for (int i = 0; i <= maxAttempts; i++) { | ||
| failIfCancelled(queryId, i, lastException); | ||
| // Execute request | ||
| TransportRequest transportRequest = httpClientHelper.createRequest(selectedEndpoint, requestSettings.getAllSettings(), | ||
| out -> { | ||
| out.write("INSERT INTO ".getBytes()); | ||
| out.write(tableName.getBytes()); | ||
| out.write(" \n FORMAT ".getBytes()); | ||
| out.write(format.name().getBytes()); | ||
| out.write(" \n".getBytes()); | ||
| for (Object obj : data) { | ||
|
|
||
| for (POJOFieldSerializer serializer : serializersForTable) { | ||
| try { | ||
| serializer.serialize(obj, out); | ||
| } catch (InvocationTargetException | IllegalAccessException e) { | ||
| throw new DataSerializationException(obj, serializer, e); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| out.close(); | ||
| }); | ||
| out.close(); | ||
| }); | ||
|
|
||
| registerTransportReq(queryId, transportRequest); | ||
| registerTransportReq(queryId, transportRequest); | ||
|
|
||
| try (TransportResponse transportResponse = httpClientHelper.executeRequest(transportRequest)) { | ||
| ClientStatisticsHolder clientStats = globalClientStats.remove(operationId); | ||
| OperationMetrics metrics = completeOperation(transportResponse, clientStats, requestSettings.getQueryId()); | ||
| try (TransportResponse transportResponse = httpClientHelper.executeRequest(transportRequest)) { | ||
| ClientStatisticsHolder clientStats = globalClientStats.remove(operationId); | ||
| OperationMetrics metrics = completeOperation(transportResponse, clientStats, requestSettings.getQueryId()); | ||
|
|
||
| return new InsertResponse(transportResponse, metrics); | ||
| } catch (Exception e) { | ||
| String msg = requestExMsg("Insert", (i + 1), durationSince(startTime).toMillis(), requestSettings.getQueryId()); | ||
| lastException = httpClientHelper.wrapException(msg, e, requestSettings.getQueryId()); | ||
| if (httpClientHelper.shouldRetry(e, requestSettings.getAllSettings()) && requestIsNotCancelled(queryId)) { | ||
| if (i < maxAttempts) { | ||
| selectedEndpoint = logRetryAndSelectNextNode("Insert", i, maxAttempts, requestSettings.getQueryId(), selectedEndpoint, e); | ||
| return new InsertResponse(transportResponse, metrics); | ||
| } catch (Exception e) { | ||
| String msg = requestExMsg("Insert", (i + 1), durationSince(startTime).toMillis(), requestSettings.getQueryId()); | ||
| lastException = httpClientHelper.wrapException(msg, e, requestSettings.getQueryId()); | ||
| if (httpClientHelper.shouldRetry(e, requestSettings.getAllSettings()) && requestIsNotCancelled(queryId)) { | ||
| if (i < maxAttempts) { | ||
| selectedEndpoint = logRetryAndSelectNextNode("Insert", i, maxAttempts, requestSettings.getQueryId(), selectedEndpoint, e); | ||
| } else { | ||
| nodeSelector.getNextAliveNode(selectedEndpoint); | ||
| } | ||
| } else { | ||
| nodeSelector.getNextAliveNode(selectedEndpoint); | ||
| throw lastException; | ||
| } | ||
| } else { | ||
| throw lastException; | ||
| } | ||
| } finally { | ||
| unregisterTransportReq(queryId); | ||
| } | ||
| } finally { | ||
| // The request of the last attempt stays registered until the operation is over, so a cancellation | ||
| // landing between two attempts is not lost. | ||
| unregisterTransportReq(queryId); | ||
| } | ||
|
|
||
| String errMsg = requestExMsg("Insert", maxAttempts + 1, durationSince(startTime).toMillis(), requestSettings.getQueryId()); | ||
|
|
@@ -1688,6 +1693,7 @@ public CompletableFuture<InsertResponse> insert(String tableName, | |
| final String queryId = requestSettings.getQueryId(); | ||
| try { | ||
| for (int i = 0; i <= maxAttempts; i++) { | ||
| failIfCancelled(queryId, i, lastException); | ||
| // Execute request | ||
| TransportRequest transportRequest = httpClientHelper.createRequest(selectedEndpoint, requestSettings.getAllSettings(), | ||
| out -> { | ||
|
|
@@ -1711,10 +1717,6 @@ public CompletableFuture<InsertResponse> insert(String tableName, | |
| } else { | ||
| throw lastException; | ||
| } | ||
| } finally { | ||
| // Insert completes once the request returns; the response exposes no stream to read afterwards, | ||
| // so the request is no longer cancellable and can be unregistered. | ||
| unregisterTransportReq(requestSettings.getQueryId()); | ||
| } | ||
|
|
||
| if (i < maxAttempts) { | ||
|
|
@@ -1726,6 +1728,8 @@ public CompletableFuture<InsertResponse> insert(String tableName, | |
| } | ||
| } | ||
| } finally { | ||
| // The request of the last attempt stays registered until the operation is over, so a cancellation | ||
| // landing between two attempts is not lost. | ||
| unregisterTransportReq(queryId); | ||
| } | ||
|
|
||
|
|
@@ -1828,6 +1832,7 @@ public CompletableFuture<QueryResponse> query(String sqlQuery, Map<String, Objec | |
| final String queryId = requestSettings.getQueryId(); | ||
| try { | ||
| for (int i = 0; i <= maxAttempts; i++) { | ||
| failIfCancelled(queryId, i, lastException); | ||
| TransportRequest request = httpClientHelper.createRequest(selectedEndpoint, requestSettings.getAllSettings(), sqlQuery); | ||
| registerTransportReq(queryId, request); | ||
| TransportResponse transportResp = null; | ||
|
|
@@ -1901,6 +1906,24 @@ private boolean requestIsNotCancelled(String queryId) { | |
| return true; | ||
| } | ||
|
|
||
| /** | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cancel lost before new attemptMedium Severity Cancellation for a retried operation is stored only on the current Additional Locations (1)Reviewed by Cursor Bugbot for commit 43cdc47. Configure here.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks — the window is real, but I don't think it should be closed the way it first looks, so let me lay out what I checked. Confirmed: between But it is a pure thread race, not a reachable code path. Every deterministic between-attempts hook this client exposes — notably The obvious fix is wrong. Carrying cancellation over on registration ( // cancel side
ongoingRequests.computeIfPresent(queryId, (k, req) -> { req.cancel(); return req; });
// register side, attempts > 0 only
ongoingRequests.compute(queryId, (k, cur) -> (cur == previousAttemptRequest && cur.isCancelled()) ? cur : tr);That is a real design change to the registry (each loop must carry its previous request; three call sites gain an abort branch), and @chernser's direction on this PR was explicitly to simplify — keep So I'm deliberately not changing code here and leaving this thread open for @chernser / @mshustov to call: the PR as it stands makes cancellation effective for every deterministically reachable between-attempts case, and this remaining microsecond race is orthogonal to the bug in #2989. Happy to implement the identity-checked atomic registry above — in this PR or a follow-up — if you'd like it. |
||
| * Stops an operation that was cancelled before its next attempt is issued. A cancellation may have landed | ||
| * between two attempts (on the stream insert path for instance from {@link DataStreamWriter#onRetry()}): the | ||
| * request of the previous attempt stays registered until the operation is over, so it is seen here and no | ||
| * further request is issued. | ||
| * Only attempts that follow a failed one are checked: before the first attempt this operation has nothing | ||
| * registered yet, so a request found under the same query id belongs to another operation that is still | ||
| * running and its cancellation must not stop this one. | ||
| * The same exception type and message are used when a cancellation aborts an in-flight request, so a caller | ||
| * sees one outcome for a cancelled operation regardless of when the cancellation landed. The failure of the | ||
| * last attempt is kept as cause. Called from the retry loop of every operation. | ||
| */ | ||
| private void failIfCancelled(String queryId, int attempt, RuntimeException lastException) { | ||
| if (attempt > 0 && !requestIsNotCancelled(queryId)) { | ||
| throw new TransportException("Request was cancelled on client side", lastException, queryId); | ||
| } | ||
|
Comment on lines
+1921
to
+1924
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct, and the doc mismatch is on us: the PR description still described an earlier revision of this change (the On the remaining race: this is the same finding @cursor[bot] raised at the guard site (r3699162510), answered there in detail. Summary: the window between The holder shape you propose is what this PR originally had, and it is the right end state — but note the naive carry-over version is not safe on its own: preserving a cancelled entry across That is a registry redesign with branches no deterministic test can exercise (the new-code coverage gate on this PR already went red once), so it is scoped out of this bug fix and now listed as known residuals 2 and 3 in the PR description. Leaving this thread unresolved so @chernser / @mshustov can decide whether they want it here or as a follow-up — I am happy to write it either way. |
||
| } | ||
|
|
||
| public CompletableFuture<QueryResponse> query(String sqlQuery, Map<String, Object> queryParams) { | ||
| return query(sqlQuery, queryParams, null); | ||
| } | ||
|
|
@@ -2416,7 +2439,8 @@ public void updateAccessToken(String accessToken) { | |
| * Tries to cancel ongoing request. This method cancels IO operations but doesn't | ||
| * kill query on server side. Original queryId should be used to cancel the request. | ||
| * This operation cancels only operations on client side and only that still waiting | ||
| * for response. | ||
| * for response. A cancellation that lands between two attempts of a retried operation is effective too: | ||
| * the operation stops instead of issuing another request. | ||
| * | ||
| * @param queryId - original query id that was passed in operation settings. | ||
| */ | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks — the mismatch you spotted is real, and it is in the PR description, not the code: the description still described an earlier revision of this PR (an
OngoingOperationholder with a sticky flag, removed withremove(key, value)), which was dropped in favour of the much smaller guard after @chernser asked to simplify. I have rewritten the description to match what is actually shipped, so it no longer promises identity-based removal.On the substance: removing by query id only is pre-existing on
main—unregisterTransportReqthere is alsoongoingRequests.remove(queryId), andregisterTransportReqis a plainput, so two concurrent operations sharing a query id already alias each other today (the secondputorphans the first entry, and eitherremoveclears whichever entry is current). This PR does not introduce that; it only lengthens the window on the two insert paths, from one attempt to the whole operation, which is exactly what makes a between-attempts cancel visible at all.I deliberately did not close it here:
remove(queryId, request)in thefinally— it also needs the registration side to be identity-checked and atomic againstcancelTransportRequest(each loop carrying its own previous request,compute/computeIfPresenton both sides), i.e. a registry redesign across all three call sites.It is now listed explicitly as a known residual in the PR description (item 3) rather than left implicit. Happy to implement the identity-checked registry — here or as a separate PR — if @chernser / @mshustov want it; leaving this thread open for that call.