Skip to content

Commit 04da457

Browse files
committed
feat(gax): add recoverable error query and buffer realignment loop
Introduces ChunkAttemptCallable with a query-and-realign loop when encountering recoverable protocol errors. Queries the server for the committed offset and adjusts the buffer window before resuming chunk transmission.
1 parent 4a4b890 commit 04da457

11 files changed

Lines changed: 1217 additions & 93 deletions
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
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.resumable.ResumableUploadStatus;
43+
import com.google.api.gax.retrying.RetryingFuture;
44+
import com.google.common.util.concurrent.MoreExecutors;
45+
import java.time.Duration;
46+
import java.util.concurrent.Callable;
47+
import org.jspecify.annotations.NullMarked;
48+
import org.jspecify.annotations.Nullable;
49+
50+
/**
51+
* A {@link Callable} representing an attempt to transmit a single chunk in a resumable upload
52+
* session. Used with {@link com.google.api.gax.retrying.ScheduledRetryingExecutor}.
53+
*
54+
* <p>Execution follows the standard attempt template with pre-attempt recovery handling. When the
55+
* previous attempt failed with a Category 2 (recoverable) error or missing status header, {@code
56+
* prepareAttempt} queries session status, realigns the buffer window, tops up from the stream, and
57+
* dispatches the chunk upload request. The callable never blocks on {@code .get()}; results and
58+
* cancellations propagate asynchronously.
59+
*
60+
* @param <ResponseT> the type of the final response message once the upload completes
61+
*/
62+
@NullMarked
63+
class ChunkAttemptCallable<ResponseT> implements Callable<ChunkUploadResponse<ResponseT>> {
64+
65+
private final UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
66+
uploadChunkCallable;
67+
private final UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>>
68+
queryStatusCallable;
69+
private final RewindableStreamBuffer buffer;
70+
private final String uploadUrl;
71+
private final ApiCallContext originalCallContext;
72+
73+
private volatile ChunkUploadRequest currentRequest;
74+
private volatile ResumableUploadCommand currentCommand;
75+
76+
private volatile @Nullable RetryingFuture<ChunkUploadResponse<ResponseT>> retryingFuture;
77+
private volatile @Nullable ApiFuture<?> inFlightFuture;
78+
private volatile @Nullable Throwable lastFailure;
79+
private volatile @Nullable ChunkUploadResponse<ResponseT> lastResponse;
80+
81+
ChunkAttemptCallable(
82+
UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>> uploadChunkCallable,
83+
UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>> queryStatusCallable,
84+
RewindableStreamBuffer buffer,
85+
String uploadUrl,
86+
ChunkUploadRequest request,
87+
ApiCallContext callContext,
88+
ResumableUploadCommand command) {
89+
this.uploadChunkCallable =
90+
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
91+
this.queryStatusCallable =
92+
checkNotNull(queryStatusCallable, "queryStatusCallable must not be null");
93+
this.buffer = checkNotNull(buffer, "buffer must not be null");
94+
this.uploadUrl = checkNotNull(uploadUrl, "uploadUrl must not be null");
95+
this.currentRequest = checkNotNull(request, "request must not be null");
96+
this.originalCallContext = checkNotNull(callContext, "callContext must not be null");
97+
this.currentCommand = checkNotNull(command, "command must not be null");
98+
}
99+
100+
void setRetryingFuture(RetryingFuture<ChunkUploadResponse<ResponseT>> retryingFuture) {
101+
this.retryingFuture = checkNotNull(retryingFuture, "retryingFuture must not be null");
102+
}
103+
104+
ResumableUploadCommand getCurrentCommand() {
105+
return currentCommand;
106+
}
107+
108+
private boolean needsRecovery() {
109+
if (lastFailure != null) {
110+
ResumableUploadErrorClassifier.Category category =
111+
ResumableUploadErrorClassifier.classify(lastFailure, currentCommand);
112+
return category == ResumableUploadErrorClassifier.Category.RECOVERABLE;
113+
}
114+
if (lastResponse != null && lastResponse.getUploadStatus() == ResumableUploadStatus.UNKNOWN) {
115+
ResumableUploadErrorClassifier.Category category =
116+
ResumableUploadErrorClassifier.classifyMissingStatusHeader(currentCommand);
117+
return category == ResumableUploadErrorClassifier.Category.RECOVERABLE;
118+
}
119+
return false;
120+
}
121+
122+
private void failAttempt(
123+
SettableApiFuture<ChunkUploadResponse<ResponseT>> attemptFuture, Throwable t) {
124+
lastFailure = t;
125+
lastResponse = null;
126+
attemptFuture.setException(t);
127+
}
128+
129+
/**
130+
* Pre-attempt recovery step invoked before transmitting an attempt when the previous attempt
131+
* encountered a Category 2 (recoverable) error or missing status header.
132+
*/
133+
private void prepareAttempt(
134+
SettableApiFuture<ChunkUploadResponse<ResponseT>> attemptFuture,
135+
ApiCallContext attemptContext,
136+
RetryingFuture<ChunkUploadResponse<ResponseT>> currentRetryingFuture) {
137+
this.currentCommand = ResumableUploadCommand.QUERY;
138+
QueryStatusRequest queryRequest = QueryStatusRequest.create(uploadUrl);
139+
ApiFuture<QueryStatusResponse<ResponseT>> queryFuture =
140+
queryStatusCallable.futureCall(queryRequest, attemptContext);
141+
if (queryFuture == null) {
142+
failAttempt(
143+
attemptFuture, new IllegalStateException("queryStatusCallable returned a null future"));
144+
return;
145+
}
146+
this.inFlightFuture = queryFuture;
147+
148+
ApiFutures.addCallback(
149+
queryFuture,
150+
new ApiFutureCallback<QueryStatusResponse<ResponseT>>() {
151+
@Override
152+
public void onSuccess(QueryStatusResponse<ResponseT> queryResponse) {
153+
handleQuerySuccess(queryResponse, attemptFuture, attemptContext, currentRetryingFuture);
154+
}
155+
156+
@Override
157+
public void onFailure(Throwable t) {
158+
failAttempt(attemptFuture, t);
159+
}
160+
},
161+
MoreExecutors.directExecutor());
162+
}
163+
164+
private void handleQuerySuccess(
165+
QueryStatusResponse<ResponseT> queryResponse,
166+
SettableApiFuture<ChunkUploadResponse<ResponseT>> attemptFuture,
167+
ApiCallContext attemptContext,
168+
RetryingFuture<ChunkUploadResponse<ResponseT>> currentRetryingFuture) {
169+
if (currentRetryingFuture.isDone()) {
170+
return;
171+
}
172+
173+
if (queryResponse.getUploadStatus() == ResumableUploadStatus.UNKNOWN) {
174+
failAttempt(
175+
attemptFuture,
176+
UploadErrors.protocolViolation(
177+
"Query status response missing X-Goog-Upload-Status header for upload URL: "
178+
+ uploadUrl));
179+
return;
180+
}
181+
182+
// Server already finalized the upload.
183+
if (queryResponse.getUploadStatus() == ResumableUploadStatus.FINAL) {
184+
ChunkUploadResponse<ResponseT> response =
185+
ChunkUploadResponse.create(ResumableUploadStatus.FINAL, queryResponse.getResponse());
186+
lastFailure = null;
187+
lastResponse = response;
188+
attemptFuture.set(response);
189+
return;
190+
}
191+
192+
// Incomplete query response with null committed offset violates the protocol invariant.
193+
Long committedOffset = queryResponse.getCommittedOffset();
194+
if (committedOffset == null) {
195+
failAttempt(
196+
attemptFuture,
197+
UploadErrors.protocolViolation(
198+
"Incomplete query status response did not include a committed offset for upload URL: "
199+
+ uploadUrl));
200+
return;
201+
}
202+
203+
// Normal path: realign buffer to committedOffset, compact and top up.
204+
try {
205+
buffer.realignTo(committedOffset);
206+
} catch (Throwable e) {
207+
failAttempt(attemptFuture, e);
208+
return;
209+
}
210+
211+
// Determine the upload command for the realigned buffer.
212+
// Preserve upload,finalize for a trailing partial after realignment.
213+
ResumableUploadCommand realignedCommand;
214+
if (buffer.isFinal()) {
215+
realignedCommand =
216+
buffer.isEmpty()
217+
? ResumableUploadCommand.FINALIZE
218+
: ResumableUploadCommand.UPLOAD_FINALIZE;
219+
} else {
220+
realignedCommand = ResumableUploadCommand.UPLOAD;
221+
}
222+
223+
ChunkUploadRequest realignedRequest =
224+
ChunkUploadRequest.newBuilder()
225+
.setUploadUrl(uploadUrl)
226+
.setPayload(buffer.getBuffer())
227+
.setPayloadLength(buffer.getPayloadLength())
228+
.setOffset(buffer.getBufferBaseOffset())
229+
.setFinal(buffer.isFinal())
230+
.build();
231+
232+
this.currentRequest = realignedRequest;
233+
this.currentCommand = realignedCommand;
234+
235+
dispatchChunkUpload(attemptFuture, attemptContext, currentRetryingFuture);
236+
}
237+
238+
private void dispatchChunkUpload(
239+
SettableApiFuture<ChunkUploadResponse<ResponseT>> attemptFuture,
240+
ApiCallContext attemptContext,
241+
RetryingFuture<ChunkUploadResponse<ResponseT>> currentRetryingFuture) {
242+
ApiFuture<ChunkUploadResponse<ResponseT>> chunkFuture =
243+
uploadChunkCallable.futureCall(currentRequest, attemptContext);
244+
this.inFlightFuture = chunkFuture;
245+
246+
ApiFutures.addCallback(
247+
chunkFuture,
248+
new ApiFutureCallback<ChunkUploadResponse<ResponseT>>() {
249+
@Override
250+
public void onSuccess(ChunkUploadResponse<ResponseT> response) {
251+
lastFailure = null;
252+
lastResponse = response;
253+
attemptFuture.set(response);
254+
}
255+
256+
@Override
257+
public void onFailure(Throwable t) {
258+
failAttempt(attemptFuture, t);
259+
}
260+
},
261+
MoreExecutors.directExecutor());
262+
}
263+
264+
@Override
265+
public @Nullable ChunkUploadResponse<ResponseT> call() {
266+
RetryingFuture<ChunkUploadResponse<ResponseT>> currentRetryingFuture =
267+
checkNotNull(retryingFuture, "retryingFuture must be set before call()");
268+
ApiCallContext attemptContext = originalCallContext;
269+
270+
Duration rpcTimeout = currentRetryingFuture.getAttemptSettings().getRpcTimeoutDuration();
271+
if (!rpcTimeout.isZero() && attemptContext.getTimeoutDuration() == null) {
272+
attemptContext = attemptContext.withTimeoutDuration(rpcTimeout);
273+
}
274+
275+
SettableApiFuture<ChunkUploadResponse<ResponseT>> attemptFuture = SettableApiFuture.create();
276+
currentRetryingFuture.setAttemptFuture(attemptFuture);
277+
278+
if (currentRetryingFuture.isDone()) {
279+
return null;
280+
}
281+
282+
currentRetryingFuture.addListener(
283+
() -> {
284+
if (currentRetryingFuture.isCancelled()) {
285+
ApiFuture<?> inFlight = inFlightFuture;
286+
if (inFlight != null) {
287+
inFlight.cancel(true);
288+
}
289+
attemptFuture.cancel(true);
290+
}
291+
},
292+
MoreExecutors.directExecutor());
293+
294+
try {
295+
if (needsRecovery()) {
296+
prepareAttempt(attemptFuture, attemptContext, currentRetryingFuture);
297+
} else {
298+
dispatchChunkUpload(attemptFuture, attemptContext, currentRetryingFuture);
299+
}
300+
} catch (Throwable t) {
301+
failAttempt(attemptFuture, t);
302+
}
303+
304+
return null;
305+
}
306+
}

‎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 ResumableUploadResultRetryAlgorithm<>(ResumableUploadCommand.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)