Skip to content

Commit e197ee8

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 6d89325 commit e197ee8

11 files changed

Lines changed: 708 additions & 175 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(

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

Lines changed: 206 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,11 @@
3434
import com.google.api.core.ApiFuture;
3535
import com.google.api.core.ApiFutureCallback;
3636
import com.google.api.core.ApiFutures;
37+
import com.google.api.core.SettableApiFuture;
3738
import com.google.api.gax.resumable.ChunkUploadRequest;
3839
import com.google.api.gax.resumable.ChunkUploadResponse;
39-
import com.google.api.gax.retrying.NonCancellableFuture;
40+
import com.google.api.gax.resumable.QueryStatusRequest;
41+
import com.google.api.gax.resumable.QueryStatusResponse;
4042
import com.google.api.gax.retrying.RetryingFuture;
4143
import com.google.common.util.concurrent.MoreExecutors;
4244
import java.time.Duration;
@@ -48,9 +50,11 @@
4850
* A {@link Callable} representing an attempt to transmit a single chunk in a resumable upload
4951
* session. Used with {@link com.google.api.gax.retrying.ScheduledRetryingExecutor}.
5052
*
51-
* <p>Execution follows the standard six-step attempt template with an initial attempt preparation
52-
* seam. The callable never blocks on {@code .get()}; results and cancellations propagate
53-
* asynchronously.
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.
5458
*
5559
* @param <ResponseT> the type of the final response message once the upload completes
5660
*/
@@ -59,43 +63,199 @@ class ChunkAttemptCallable<ResponseT> implements Callable<ChunkUploadResponse<Re
5963

6064
private final UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
6165
uploadChunkCallable;
62-
private final ChunkUploadRequest request;
66+
private final UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>>
67+
queryStatusCallable;
68+
private final RewindableStreamBuffer buffer;
69+
private final String uploadUrl;
6370
private final ApiCallContext originalCallContext;
64-
private final UploadCommand command;
71+
72+
private volatile ChunkUploadRequest currentRequest;
73+
private volatile UploadCommand currentCommand;
6574

6675
private volatile @Nullable RetryingFuture<ChunkUploadResponse<ResponseT>> retryingFuture;
76+
private volatile @Nullable ApiFuture<?> inFlightFuture;
6777
private volatile @Nullable Throwable lastFailure;
78+
private volatile @Nullable ChunkUploadResponse<ResponseT> lastResponse;
6879

6980
ChunkAttemptCallable(
7081
UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>> uploadChunkCallable,
82+
UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>> queryStatusCallable,
83+
RewindableStreamBuffer buffer,
84+
String uploadUrl,
7185
ChunkUploadRequest request,
7286
ApiCallContext callContext,
7387
UploadCommand command) {
7488
this.uploadChunkCallable =
7589
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
76-
this.request = checkNotNull(request, "request 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");
7795
this.originalCallContext = checkNotNull(callContext, "callContext must not be null");
78-
this.command = checkNotNull(command, "command must not be null");
96+
this.currentCommand = checkNotNull(command, "command must not be null");
7997
}
8098

8199
void setRetryingFuture(RetryingFuture<ChunkUploadResponse<ResponseT>> retryingFuture) {
82100
this.retryingFuture = checkNotNull(retryingFuture, "retryingFuture must not be null");
83101
}
84102

103+
private boolean needsRecovery() {
104+
if (lastFailure != null) {
105+
UploadErrorCategory category = UploadErrorClassifier.classify(lastFailure, currentCommand);
106+
return category == UploadErrorCategory.RECOVERABLE;
107+
}
108+
if (lastResponse != null && lastResponse.getUploadStatus() == null) {
109+
UploadErrorCategory category =
110+
UploadErrorClassifier.classifyMissingStatusHeader(currentCommand);
111+
return category == UploadErrorCategory.RECOVERABLE;
112+
}
113+
return false;
114+
}
115+
116+
private void failAttempt(
117+
SettableApiFuture<ChunkUploadResponse<ResponseT>> attemptFuture, Throwable t) {
118+
lastFailure = t;
119+
lastResponse = null;
120+
attemptFuture.setException(t);
121+
}
122+
85123
/**
86-
* Pre-attempt hook called before each transmission attempt.
87-
*
88-
* <p>In this phase, Category 2 (recoverable) errors throw to fail fast. Subsequent phases expand
89-
* this seam into the query status -> realign buffer -> top up recovery sequence.
124+
* Pre-attempt recovery step invoked before transmitting an attempt when the previous attempt
125+
* encountered a Category 2 (recoverable) error or missing status header.
90126
*/
91-
void prepareAttempt() {
92-
if (lastFailure != null) {
93-
UploadErrorCategory category = UploadErrorClassifier.classify(lastFailure, command);
94-
if (category == UploadErrorCategory.RECOVERABLE) {
95-
throw new UnsupportedOperationException(
96-
"Category 2 (recoverable) error recovery is not yet implemented", lastFailure);
97-
}
127+
private void prepareAttempt(
128+
SettableApiFuture<ChunkUploadResponse<ResponseT>> attemptFuture,
129+
ApiCallContext attemptContext,
130+
RetryingFuture<ChunkUploadResponse<ResponseT>> currentRetryingFuture) {
131+
QueryStatusRequest queryRequest = QueryStatusRequest.create(uploadUrl);
132+
ApiFuture<QueryStatusResponse<ResponseT>> queryFuture =
133+
queryStatusCallable.futureCall(queryRequest, attemptContext);
134+
if (queryFuture == null) {
135+
failAttempt(
136+
attemptFuture, new IllegalStateException("queryStatusCallable returned a null future"));
137+
return;
98138
}
139+
this.inFlightFuture = queryFuture;
140+
141+
ApiFutures.addCallback(
142+
queryFuture,
143+
new ApiFutureCallback<QueryStatusResponse<ResponseT>>() {
144+
@Override
145+
public void onSuccess(QueryStatusResponse<ResponseT> queryResponse) {
146+
handleQuerySuccess(
147+
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+
RewindableStreamBuffer.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+
RewindableStreamBuffer.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+
attemptContext
235+
.getTracer()
236+
.attemptStarted(
237+
currentRequest, currentRetryingFuture.getAttemptSettings().getOverallAttemptCount());
238+
239+
ApiFuture<ChunkUploadResponse<ResponseT>> chunkFuture =
240+
uploadChunkCallable.futureCall(currentRequest, attemptContext);
241+
this.inFlightFuture = chunkFuture;
242+
243+
ApiFutures.addCallback(
244+
chunkFuture,
245+
new ApiFutureCallback<ChunkUploadResponse<ResponseT>>() {
246+
@Override
247+
public void onSuccess(ChunkUploadResponse<ResponseT> response) {
248+
lastFailure = null;
249+
lastResponse = response;
250+
attemptFuture.set(response);
251+
}
252+
253+
@Override
254+
public void onFailure(Throwable t) {
255+
failAttempt(attemptFuture, t);
256+
}
257+
},
258+
MoreExecutors.directExecutor());
99259
}
100260

101261
@Override
@@ -104,64 +264,38 @@ void prepareAttempt() {
104264
checkNotNull(retryingFuture, "retryingFuture must be set before call()");
105265
ApiCallContext attemptContext = originalCallContext;
106266

107-
try {
108-
// Seam for recoverable error handling.
109-
prepareAttempt();
110-
111-
// Set the RPC timeout if caller did not provide their own.
112-
Duration rpcTimeout = currentRetryingFuture.getAttemptSettings().getRpcTimeoutDuration();
113-
if (!rpcTimeout.isZero() && attemptContext.getTimeoutDuration() == null) {
114-
attemptContext = attemptContext.withTimeoutDuration(rpcTimeout);
115-
}
116-
117-
// Placeholder non-cancellable future.
118-
currentRetryingFuture.setAttemptFuture(
119-
new NonCancellableFuture<ChunkUploadResponse<ResponseT>>());
267+
Duration rpcTimeout = currentRetryingFuture.getAttemptSettings().getRpcTimeoutDuration();
268+
if (!rpcTimeout.isZero() && attemptContext.getTimeoutDuration() == null) {
269+
attemptContext = attemptContext.withTimeoutDuration(rpcTimeout);
270+
}
120271

121-
// Early exit if retryingFuture was already cancelled or completed.
122-
if (currentRetryingFuture.isDone()) {
123-
return null;
124-
}
272+
SettableApiFuture<ChunkUploadResponse<ResponseT>> attemptFuture = SettableApiFuture.create();
273+
currentRetryingFuture.setAttemptFuture(attemptFuture);
125274

126-
// Dispatch chunk upload and wire cancellation propagation and error
127-
// tracking.
128-
attemptContext
129-
.getTracer()
130-
.attemptStarted(
131-
request, currentRetryingFuture.getAttemptSettings().getOverallAttemptCount());
132-
133-
ApiFuture<ChunkUploadResponse<ResponseT>> internalFuture =
134-
uploadChunkCallable.futureCall(request, attemptContext);
135-
136-
// Propagate cancellation to the feeder future immediately.
137-
currentRetryingFuture.addListener(
138-
() -> {
139-
if (currentRetryingFuture.isCancelled()) {
140-
internalFuture.cancel(true);
141-
}
142-
},
143-
MoreExecutors.directExecutor());
144-
145-
ApiFutures.addCallback(
146-
internalFuture,
147-
new ApiFutureCallback<ChunkUploadResponse<ResponseT>>() {
148-
@Override
149-
public void onSuccess(ChunkUploadResponse<ResponseT> response) {
150-
lastFailure = null;
151-
}
275+
if (currentRetryingFuture.isDone()) {
276+
return null;
277+
}
152278

153-
@Override
154-
public void onFailure(Throwable t) {
155-
lastFailure = t;
279+
currentRetryingFuture.addListener(
280+
() -> {
281+
if (currentRetryingFuture.isCancelled()) {
282+
ApiFuture<?> inFlight = inFlightFuture;
283+
if (inFlight != null) {
284+
inFlight.cancel(true);
156285
}
157-
},
158-
MoreExecutors.directExecutor());
286+
attemptFuture.cancel(true);
287+
}
288+
},
289+
MoreExecutors.directExecutor());
159290

160-
currentRetryingFuture.setAttemptFuture(internalFuture);
161-
} catch (Throwable e) {
162-
lastFailure = e;
163-
currentRetryingFuture.setAttemptFuture(
164-
ApiFutures.<ChunkUploadResponse<ResponseT>>immediateFailedFuture(e));
291+
try {
292+
if (needsRecovery()) {
293+
prepareAttempt(attemptFuture, attemptContext, currentRetryingFuture);
294+
} else {
295+
dispatchChunkUpload(attemptFuture, attemptContext, currentRetryingFuture);
296+
}
297+
} catch (Throwable t) {
298+
failAttempt(attemptFuture, t);
165299
}
166300

167301
return null;

0 commit comments

Comments
 (0)