Skip to content

Commit 12cd1cb

Browse files
committed
feat(gax): add recoverable error query and buffer realignment loop
Implements Category 2 (recoverable) error recovery for chunk uploads by querying server upload status, realigning the buffer to the committed offset, topping up from the payload stream, and re-transmitting. - In ChunkAttemptCallable, asynchronously query upload status via queryStatusCallable, handle server finalized sessions, validate committed offset invariants (raising UploadProtocolViolationException on violations), and realign RewindableStreamBuffer to the server committed byte offset. - Wrap QueryStatusCallable in ResumableUploadCallableImpl with a RetryingCallable using UploadResultRetryAlgorithm(UploadCommand.QUERY) and ExponentialRetryAlgorithm. Resolves queryStatusCallable eagerly at construction time in ResumableUploadCallableImpl, with CallableTest stubbing queryStatusCallable(). - Propagate query future cancellation immediately to the in-flight future.
1 parent da1275c commit 12cd1cb

12 files changed

Lines changed: 1087 additions & 76 deletions

‎sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadResponse.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public static <ResponseT> Builder<ResponseT> newBuilder() {
6969

7070
public static <ResponseT> ChunkUploadResponse<ResponseT> create(
7171
boolean isComplete, @Nullable ResponseT response) {
72-
return create(isComplete, response, null);
72+
return create(isComplete, response, isComplete ? "final" : "active");
7373
}
7474

7575
public static <ResponseT> ChunkUploadResponse<ResponseT> create(
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
* * Neither the name of Google LLC nor the names of its
15+
* contributors may be used to endorse or promote products derived from
16+
* this software without specific prior written permission.
17+
*
18+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package com.google.api.gax.rpc;
31+
32+
import static com.google.common.base.Preconditions.checkNotNull;
33+
34+
import com.google.api.core.ApiFuture;
35+
import com.google.api.core.ApiFutureCallback;
36+
import com.google.api.core.ApiFutures;
37+
import com.google.api.core.SettableApiFuture;
38+
import com.google.api.gax.resumable.ChunkUploadRequest;
39+
import com.google.api.gax.resumable.ChunkUploadResponse;
40+
import com.google.api.gax.resumable.QueryStatusRequest;
41+
import com.google.api.gax.resumable.QueryStatusResponse;
42+
import com.google.api.gax.retrying.RetryingFuture;
43+
import com.google.common.util.concurrent.MoreExecutors;
44+
import java.time.Duration;
45+
import java.util.concurrent.Callable;
46+
import org.jspecify.annotations.NullMarked;
47+
import org.jspecify.annotations.Nullable;
48+
49+
/**
50+
* A {@link Callable} representing an attempt to transmit a single chunk in a resumable upload
51+
* session. Used with {@link com.google.api.gax.retrying.ScheduledRetryingExecutor}.
52+
*
53+
* <p>Execution follows the standard attempt template with pre-attempt recovery handling. When the
54+
* previous attempt failed with a Category 2 (recoverable) error or missing status header, {@code
55+
* prepareAttempt} queries session status, realigns the buffer window, tops up from the stream, and
56+
* dispatches the chunk upload request. The callable never blocks on {@code .get()}; results and
57+
* cancellations propagate asynchronously.
58+
*
59+
* @param <ResponseT> the type of the final response message once the upload completes
60+
*/
61+
@NullMarked
62+
class ChunkAttemptCallable<ResponseT> implements Callable<ChunkUploadResponse<ResponseT>> {
63+
64+
private final UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
65+
uploadChunkCallable;
66+
private final UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>>
67+
queryStatusCallable;
68+
private final RewindableStreamBuffer buffer;
69+
private final String uploadUrl;
70+
private final ApiCallContext originalCallContext;
71+
72+
private volatile ChunkUploadRequest currentRequest;
73+
private volatile UploadCommand currentCommand;
74+
75+
private volatile @Nullable RetryingFuture<ChunkUploadResponse<ResponseT>> retryingFuture;
76+
private volatile @Nullable ApiFuture<?> inFlightFuture;
77+
private volatile @Nullable Throwable lastFailure;
78+
private volatile @Nullable ChunkUploadResponse<ResponseT> lastResponse;
79+
80+
ChunkAttemptCallable(
81+
UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>> uploadChunkCallable,
82+
UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>> queryStatusCallable,
83+
RewindableStreamBuffer buffer,
84+
String uploadUrl,
85+
ChunkUploadRequest request,
86+
ApiCallContext callContext,
87+
UploadCommand command) {
88+
this.uploadChunkCallable =
89+
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
90+
this.queryStatusCallable =
91+
checkNotNull(queryStatusCallable, "queryStatusCallable must not be null");
92+
this.buffer = checkNotNull(buffer, "buffer must not be null");
93+
this.uploadUrl = checkNotNull(uploadUrl, "uploadUrl must not be null");
94+
this.currentRequest = checkNotNull(request, "request must not be null");
95+
this.originalCallContext = checkNotNull(callContext, "callContext must not be null");
96+
this.currentCommand = checkNotNull(command, "command must not be null");
97+
}
98+
99+
void setRetryingFuture(RetryingFuture<ChunkUploadResponse<ResponseT>> retryingFuture) {
100+
this.retryingFuture = checkNotNull(retryingFuture, "retryingFuture must not be null");
101+
}
102+
103+
private boolean needsRecovery() {
104+
if (lastFailure != null) {
105+
UploadErrorClassifier.Category category =
106+
UploadErrorClassifier.classify(lastFailure, currentCommand);
107+
return category == UploadErrorClassifier.Category.RECOVERABLE;
108+
}
109+
if (lastResponse != null && lastResponse.getUploadStatus() == null) {
110+
UploadErrorClassifier.Category category =
111+
UploadErrorClassifier.classifyMissingStatusHeader(currentCommand);
112+
return category == UploadErrorClassifier.Category.RECOVERABLE;
113+
}
114+
return false;
115+
}
116+
117+
private void failAttempt(
118+
SettableApiFuture<ChunkUploadResponse<ResponseT>> attemptFuture, Throwable t) {
119+
lastFailure = t;
120+
lastResponse = null;
121+
attemptFuture.setException(t);
122+
}
123+
124+
/**
125+
* Pre-attempt recovery step invoked before transmitting an attempt when the previous attempt
126+
* encountered a Category 2 (recoverable) error or missing status header.
127+
*/
128+
private void prepareAttempt(
129+
SettableApiFuture<ChunkUploadResponse<ResponseT>> attemptFuture,
130+
ApiCallContext attemptContext,
131+
RetryingFuture<ChunkUploadResponse<ResponseT>> currentRetryingFuture) {
132+
QueryStatusRequest queryRequest = QueryStatusRequest.create(uploadUrl);
133+
ApiFuture<QueryStatusResponse<ResponseT>> queryFuture =
134+
queryStatusCallable.futureCall(queryRequest, attemptContext);
135+
if (queryFuture == null) {
136+
failAttempt(
137+
attemptFuture, new IllegalStateException("queryStatusCallable returned a null future"));
138+
return;
139+
}
140+
this.inFlightFuture = queryFuture;
141+
142+
ApiFutures.addCallback(
143+
queryFuture,
144+
new ApiFutureCallback<QueryStatusResponse<ResponseT>>() {
145+
@Override
146+
public void onSuccess(QueryStatusResponse<ResponseT> queryResponse) {
147+
handleQuerySuccess(queryResponse, attemptFuture, attemptContext, currentRetryingFuture);
148+
}
149+
150+
@Override
151+
public void onFailure(Throwable t) {
152+
failAttempt(attemptFuture, t);
153+
}
154+
},
155+
MoreExecutors.directExecutor());
156+
}
157+
158+
private void handleQuerySuccess(
159+
QueryStatusResponse<ResponseT> queryResponse,
160+
SettableApiFuture<ChunkUploadResponse<ResponseT>> attemptFuture,
161+
ApiCallContext attemptContext,
162+
RetryingFuture<ChunkUploadResponse<ResponseT>> currentRetryingFuture) {
163+
if (currentRetryingFuture.isDone()) {
164+
return;
165+
}
166+
167+
if (queryResponse.getUploadStatus() == null) {
168+
failAttempt(
169+
attemptFuture,
170+
UploadErrors.protocolViolation(
171+
"Query status response missing X-Goog-Upload-Status header for upload URL: "
172+
+ uploadUrl));
173+
return;
174+
}
175+
176+
// Server already finalized the upload.
177+
if (queryResponse.isComplete()) {
178+
ChunkUploadResponse<ResponseT> response =
179+
ChunkUploadResponse.create(
180+
true, queryResponse.getResponse(), queryResponse.getUploadStatus());
181+
lastFailure = null;
182+
lastResponse = response;
183+
attemptFuture.set(response);
184+
return;
185+
}
186+
187+
// Incomplete query response with null committed offset violates the protocol invariant.
188+
Long committedOffset = queryResponse.getCommittedOffset();
189+
if (committedOffset == null) {
190+
failAttempt(
191+
attemptFuture,
192+
UploadErrors.protocolViolation(
193+
"Incomplete query status response did not include a committed offset for upload URL: "
194+
+ uploadUrl));
195+
return;
196+
}
197+
198+
// Normal path: realign buffer to committedOffset, compact and top up.
199+
try {
200+
buffer.realignTo(committedOffset);
201+
} catch (Throwable e) {
202+
failAttempt(attemptFuture, e);
203+
return;
204+
}
205+
206+
// Determine the upload command for the realigned buffer.
207+
// Preserve upload,finalize for a trailing partial after realignment.
208+
UploadCommand realignedCommand;
209+
if (buffer.isFinal()) {
210+
realignedCommand = buffer.isEmpty() ? UploadCommand.FINALIZE : UploadCommand.UPLOAD_FINALIZE;
211+
} else {
212+
realignedCommand = UploadCommand.UPLOAD;
213+
}
214+
215+
ChunkUploadRequest realignedRequest =
216+
ChunkUploadRequest.newBuilder()
217+
.setUploadUrl(uploadUrl)
218+
.setPayload(buffer.getBuffer())
219+
.setPayloadLength(buffer.getPayloadLength())
220+
.setOffset(buffer.getBufferBaseOffset())
221+
.setFinal(buffer.isFinal())
222+
.build();
223+
224+
this.currentRequest = realignedRequest;
225+
this.currentCommand = realignedCommand;
226+
227+
dispatchChunkUpload(attemptFuture, attemptContext, currentRetryingFuture);
228+
}
229+
230+
private void dispatchChunkUpload(
231+
SettableApiFuture<ChunkUploadResponse<ResponseT>> attemptFuture,
232+
ApiCallContext attemptContext,
233+
RetryingFuture<ChunkUploadResponse<ResponseT>> currentRetryingFuture) {
234+
ApiFuture<ChunkUploadResponse<ResponseT>> chunkFuture =
235+
uploadChunkCallable.futureCall(currentRequest, attemptContext);
236+
this.inFlightFuture = chunkFuture;
237+
238+
ApiFutures.addCallback(
239+
chunkFuture,
240+
new ApiFutureCallback<ChunkUploadResponse<ResponseT>>() {
241+
@Override
242+
public void onSuccess(ChunkUploadResponse<ResponseT> response) {
243+
lastFailure = null;
244+
lastResponse = response;
245+
attemptFuture.set(response);
246+
}
247+
248+
@Override
249+
public void onFailure(Throwable t) {
250+
failAttempt(attemptFuture, t);
251+
}
252+
},
253+
MoreExecutors.directExecutor());
254+
}
255+
256+
@Override
257+
public @Nullable ChunkUploadResponse<ResponseT> call() {
258+
RetryingFuture<ChunkUploadResponse<ResponseT>> currentRetryingFuture =
259+
checkNotNull(retryingFuture, "retryingFuture must be set before call()");
260+
ApiCallContext attemptContext = originalCallContext;
261+
262+
Duration rpcTimeout = currentRetryingFuture.getAttemptSettings().getRpcTimeoutDuration();
263+
if (!rpcTimeout.isZero() && attemptContext.getTimeoutDuration() == null) {
264+
attemptContext = attemptContext.withTimeoutDuration(rpcTimeout);
265+
}
266+
267+
SettableApiFuture<ChunkUploadResponse<ResponseT>> attemptFuture = SettableApiFuture.create();
268+
currentRetryingFuture.setAttemptFuture(attemptFuture);
269+
270+
if (currentRetryingFuture.isDone()) {
271+
return null;
272+
}
273+
274+
currentRetryingFuture.addListener(
275+
() -> {
276+
if (currentRetryingFuture.isCancelled()) {
277+
ApiFuture<?> inFlight = inFlightFuture;
278+
if (inFlight != null) {
279+
inFlight.cancel(true);
280+
}
281+
attemptFuture.cancel(true);
282+
}
283+
},
284+
MoreExecutors.directExecutor());
285+
286+
try {
287+
if (needsRecovery()) {
288+
prepareAttempt(attemptFuture, attemptContext, currentRetryingFuture);
289+
} else {
290+
dispatchChunkUpload(attemptFuture, attemptContext, currentRetryingFuture);
291+
}
292+
} catch (Throwable t) {
293+
failAttempt(attemptFuture, t);
294+
}
295+
296+
return null;
297+
}
298+
}

‎sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallableImpl.java‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,16 @@
3535
import com.google.api.core.ApiFutures;
3636
import com.google.api.core.BetaApi;
3737
import com.google.api.core.InternalApi;
38+
import com.google.api.gax.resumable.QueryStatusRequest;
39+
import com.google.api.gax.resumable.QueryStatusResponse;
3840
import com.google.api.gax.resumable.ResumableUploadClient;
3941
import com.google.api.gax.resumable.ResumableUploadSession;
42+
import com.google.api.gax.retrying.ExponentialRetryAlgorithm;
43+
import com.google.api.gax.retrying.RetryAlgorithm;
44+
import com.google.api.gax.retrying.RetrySettings;
45+
import com.google.api.gax.retrying.ScheduledRetryingExecutor;
4046
import java.io.InputStream;
47+
import java.time.Duration;
4148
import org.jspecify.annotations.NullMarked;
4249
import org.jspecify.annotations.Nullable;
4350

@@ -54,9 +61,22 @@
5461
public class ResumableUploadCallableImpl<RequestT, ResponseT>
5562
extends ResumableUploadCallable<RequestT, ResponseT> {
5663

64+
static final RetrySettings DEFAULT_QUERY_RETRY_SETTINGS =
65+
RetrySettings.newBuilder()
66+
.setInitialRetryDelayDuration(Duration.ofMillis(100))
67+
.setRetryDelayMultiplier(1.3)
68+
.setMaxRetryDelayDuration(Duration.ofMinutes(1))
69+
.setInitialRpcTimeoutDuration(Duration.ofSeconds(30))
70+
.setRpcTimeoutMultiplier(1.0)
71+
.setMaxRpcTimeoutDuration(Duration.ofSeconds(30))
72+
.setTotalTimeoutDuration(Duration.ofMinutes(5))
73+
.build();
74+
5775
private final ResumableUploadClient<RequestT, ResponseT> client;
5876
private final ResumableUploadCallSettings defaultCallSettings;
5977
private final ClientContext clientContext;
78+
private final UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>>
79+
retryingQueryCallable;
6080

6181
public ResumableUploadCallableImpl(
6282
ResumableUploadClient<RequestT, ResponseT> client,
@@ -66,6 +86,17 @@ public ResumableUploadCallableImpl(
6686
this.defaultCallSettings =
6787
checkNotNull(defaultCallSettings, "defaultCallSettings must not be null");
6888
this.clientContext = checkNotNull(clientContext, "clientContext must not be null");
89+
90+
RetryAlgorithm<QueryStatusResponse<ResponseT>> queryRetryAlgorithm =
91+
new RetryAlgorithm<>(
92+
new UploadResultRetryAlgorithm<>(UploadCommand.QUERY),
93+
new ExponentialRetryAlgorithm(DEFAULT_QUERY_RETRY_SETTINGS, clientContext.getClock()));
94+
95+
this.retryingQueryCallable =
96+
new RetryingCallable<>(
97+
clientContext.getDefaultCallContext(),
98+
checkNotNull(client.queryStatusCallable(), "queryStatusCallable must not be null"),
99+
new ScheduledRetryingExecutor<>(queryRetryAlgorithm, clientContext.getExecutor()));
69100
}
70101

71102
@Override
@@ -89,6 +120,7 @@ public ResumableUploadFuture<ResponseT> futureCall(
89120
return ResumableUploadFutureImpl.create(
90121
startFuture,
91122
client.uploadChunkCallable(),
123+
retryingQueryCallable,
92124
payload,
93125
effectiveSettings,
94126
clientContext.getDefaultCallContext(),

0 commit comments

Comments
 (0)