Skip to content

Commit 56bfa8e

Browse files
committed
feat(gax): add recoverable error query and buffer realignment loop
Introduces a query-and-realign loop when encountering recoverable protocol errors during chunk uploads. Queries the server for the committed offset and adjusts the buffer window before resuming chunk transmission.
1 parent 2f7c89c commit 56bfa8e

5 files changed

Lines changed: 420 additions & 31 deletions

File tree

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
import com.google.api.core.InternalApi;
3838
import com.google.api.gax.resumable.ChunkUploadRequest;
3939
import com.google.api.gax.resumable.ChunkUploadResponse;
40+
import com.google.api.gax.resumable.QueryStatusRequest;
41+
import com.google.api.gax.resumable.QueryStatusResponse;
4042
import com.google.api.gax.resumable.ResumableUploadClient;
4143
import com.google.api.gax.resumable.ResumableUploadSession;
4244
import com.google.api.gax.retrying.ExponentialRetryAlgorithm;
@@ -74,6 +76,8 @@ public class ResumableUploadCallableImpl<RequestT, ResponseT>
7476
private final ClientContext clientContext;
7577
private final UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
7678
retryingUploadChunkCallable;
79+
private final UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>>
80+
retryingQueryCallable;
7781

7882
public ResumableUploadCallableImpl(
7983
ResumableUploadClient<RequestT, ResponseT> client,
@@ -89,6 +93,8 @@ public ResumableUploadCallableImpl(
8993
.build();
9094
this.retryingUploadChunkCallable =
9195
createRetryingCallable(client.uploadChunkCallable(), ResumableUploadCommand.UPLOAD);
96+
this.retryingQueryCallable =
97+
createRetryingCallable(client.queryStatusCallable(), ResumableUploadCommand.QUERY);
9298
}
9399

94100
@Override
@@ -110,7 +116,12 @@ public ResumableUploadFuture<ResponseT> futureCall(
110116
}
111117

112118
return ResumableUploadFutureImpl.create(
113-
startFuture, retryingUploadChunkCallable, payload, effectiveSettings, clientContext);
119+
startFuture,
120+
retryingUploadChunkCallable,
121+
retryingQueryCallable,
122+
payload,
123+
effectiveSettings,
124+
clientContext);
114125
}
115126

116127
@Override

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

Lines changed: 116 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@
3838
import com.google.api.core.SettableApiFuture;
3939
import com.google.api.gax.resumable.ChunkUploadRequest;
4040
import com.google.api.gax.resumable.ChunkUploadResponse;
41+
import com.google.api.gax.resumable.QueryStatusRequest;
42+
import com.google.api.gax.resumable.QueryStatusResponse;
4143
import com.google.api.gax.resumable.ResumableUploadStatus;
44+
import com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category;
4245
import com.google.common.util.concurrent.MoreExecutors;
4346
import java.io.IOException;
4447
import java.io.InputStream;
@@ -50,6 +53,9 @@
5053
/**
5154
* Coordinates chunk transmission steps of a resumable upload session.
5255
*
56+
* <p>Expects {@code uploadChunkCallable} and {@code queryStatusCallable} to be pre-wrapped in
57+
* retrying callables that handle transient errors.
58+
*
5359
* @param <ResponseT> the type of the final response message returned once the upload completes
5460
*/
5561
@InternalApi
@@ -65,6 +71,8 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
6571

6672
private final UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
6773
uploadChunkCallable;
74+
private final UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>>
75+
queryStatusCallable;
6876
private final String uploadUrl;
6977
private final RewindableStreamBuffer buffer;
7078
private final ApiCallContext callContext;
@@ -73,12 +81,15 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
7381

7482
ResumableUploadChunkCoordinator(
7583
UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>> uploadChunkCallable,
84+
UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>> queryStatusCallable,
7685
String uploadUrl,
7786
InputStream payload,
7887
int chunkSize,
7988
ClientContext clientContext) {
8089
this.uploadChunkCallable =
8190
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
91+
this.queryStatusCallable =
92+
checkNotNull(queryStatusCallable, "queryStatusCallable must not be null");
8293
this.uploadUrl = checkNotNull(uploadUrl, "uploadUrl must not be null");
8394
checkNotNull(payload, "payload must not be null");
8495
checkNotNull(clientContext, "clientContext must not be null");
@@ -113,48 +124,24 @@ private void transmitChunk(long currentOffset) {
113124
return;
114125
}
115126

116-
// Determine if this is the final chunk and build the chunk request.
117-
ChunkUploadRequest chunkRequest =
118-
ChunkUploadRequest.newBuilder()
119-
.setUploadUrl(uploadUrl)
120-
.setPayload(buffer.getBuffer())
121-
.setPayloadLength(buffer.getPayloadLength())
122-
.setOffset(buffer.getBufferBaseOffset())
123-
.setFinal(buffer.isFinal())
124-
.build();
127+
dispatchCurrentChunk();
128+
}
125129

130+
private void dispatchCurrentChunk() {
126131
// Dispatch the chunk upload call and register the in-flight future for cancellation.
127-
long chunkLength = chunkRequest.getPayloadLength();
128-
boolean isFinal = chunkRequest.isFinal();
129132
try {
130-
ApiFuture<ChunkUploadResponse<ResponseT>> chunkFuture =
131-
uploadChunkCallable.futureCall(chunkRequest, callContext);
133+
ApiFuture<ChunkUploadResponse<ResponseT>> chunkFuture = executeChunkWithRecovery();
132134
this.currentChunkFuture = chunkFuture;
133135
if (result.isCancelled()) {
134136
chunkFuture.cancel(true);
135137
return;
136138
}
137-
138139
ApiFutures.addCallback(
139140
chunkFuture,
140141
new ApiFutureCallback<ChunkUploadResponse<ResponseT>>() {
141142
@Override
142143
public void onSuccess(ChunkUploadResponse<ResponseT> response) {
143-
if (result.isDone()) {
144-
return;
145-
}
146-
long nextOffset = currentOffset + chunkLength;
147-
if (response.getUploadStatus() == ResumableUploadStatus.FINAL) {
148-
result.set(response.getResponse());
149-
} else if (isFinal) {
150-
result.setException(
151-
new IllegalStateException(
152-
"Upload stream ended and final chunk was transmitted, but server returned"
153-
+ " incomplete status for upload URL: "
154-
+ uploadUrl));
155-
} else {
156-
chunkExecutor.execute(() -> transmitChunk(nextOffset));
157-
}
144+
chunkExecutor.execute(() -> onChunkUploaded(response));
158145
}
159146

160147
@Override
@@ -170,4 +157,104 @@ public void onFailure(Throwable t) {
170157
result.setException(t);
171158
}
172159
}
160+
161+
private ApiFuture<ChunkUploadResponse<ResponseT>> executeChunkWithRecovery() {
162+
ApiFuture<ChunkUploadResponse<ResponseT>> transmitted =
163+
ApiFutures.catchingAsync(
164+
uploadChunkCallable.futureCall(buildCurrentChunkRequest(), callContext),
165+
Throwable.class,
166+
t -> {
167+
Category category =
168+
ResumableUploadErrorClassifier.classify(t, ResumableUploadCommand.UPLOAD);
169+
if (category == Category.RECOVERABLE) {
170+
return recover();
171+
}
172+
// Category.TRANSIENT errors reaching here have already exhausted their retry budget
173+
// in the underlying RetryingCallable and become fatal per protocol specification.
174+
return ApiFutures.immediateFailedFuture(t);
175+
},
176+
chunkExecutor);
177+
return ApiFutures.transformAsync(
178+
transmitted,
179+
response -> {
180+
if (response.getUploadStatus() == ResumableUploadStatus.UNKNOWN) {
181+
return recover();
182+
}
183+
return ApiFutures.immediateFuture(response);
184+
},
185+
chunkExecutor);
186+
}
187+
188+
private ApiFuture<ChunkUploadResponse<ResponseT>> recover() {
189+
return ApiFutures.transformAsync(
190+
queryStatusCallable.futureCall(QueryStatusRequest.create(uploadUrl), callContext),
191+
this::resumeFrom,
192+
chunkExecutor);
193+
}
194+
195+
private ApiFuture<ChunkUploadResponse<ResponseT>> resumeFrom(
196+
QueryStatusResponse<ResponseT> queryResponse) throws IOException {
197+
if (queryResponse.getUploadStatus() == ResumableUploadStatus.UNKNOWN) {
198+
throw protocolViolation(
199+
"Query status response missing X-Goog-Upload-Status header for upload URL: " + uploadUrl);
200+
}
201+
if (queryResponse.getUploadStatus() == ResumableUploadStatus.FINAL) {
202+
return ApiFutures.immediateFuture(
203+
ChunkUploadResponse.create(ResumableUploadStatus.FINAL, queryResponse.getResponse()));
204+
}
205+
Long committedOffset = queryResponse.getCommittedOffset();
206+
if (committedOffset == null) {
207+
throw protocolViolation(
208+
"Incomplete query status response did not include a committed offset for upload URL: "
209+
+ uploadUrl);
210+
}
211+
buffer.realignTo(committedOffset);
212+
return executeChunkWithRecovery();
213+
}
214+
215+
private void onChunkUploaded(ChunkUploadResponse<ResponseT> response) {
216+
if (result.isDone()) {
217+
return;
218+
}
219+
long nextOffset = buffer.getBufferBaseOffset() + buffer.getPayloadLength();
220+
if (response.getUploadStatus() == ResumableUploadStatus.FINAL) {
221+
result.set(response.getResponse());
222+
} else if (buffer.isFinal()) {
223+
result.setException(
224+
new IllegalStateException(
225+
"Upload stream ended and final chunk was transmitted, but server returned"
226+
+ " incomplete status for upload URL: "
227+
+ uploadUrl));
228+
} else {
229+
chunkExecutor.execute(() -> transmitChunk(nextOffset));
230+
}
231+
}
232+
233+
private ChunkUploadRequest buildCurrentChunkRequest() {
234+
// Determine if this is the final chunk and build the chunk request.
235+
return ChunkUploadRequest.newBuilder()
236+
.setUploadUrl(uploadUrl)
237+
.setPayload(buffer.getBuffer())
238+
.setPayloadLength(buffer.getPayloadLength())
239+
.setOffset(buffer.getBufferBaseOffset())
240+
.setFinal(buffer.isFinal())
241+
.build();
242+
}
243+
244+
private static final StatusCode FAILED_PRECONDITION_STATUS_CODE =
245+
new StatusCode() {
246+
@Override
247+
public StatusCode.Code getCode() {
248+
return StatusCode.Code.FAILED_PRECONDITION;
249+
}
250+
251+
@Override
252+
public @Nullable Object getTransportCode() {
253+
return null;
254+
}
255+
};
256+
257+
private static FailedPreconditionException protocolViolation(String message) {
258+
return new FailedPreconditionException(message, null, FAILED_PRECONDITION_STATUS_CODE, false);
259+
}
173260
}

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
import com.google.api.core.SettableApiFuture;
3939
import com.google.api.gax.resumable.ChunkUploadRequest;
4040
import com.google.api.gax.resumable.ChunkUploadResponse;
41+
import com.google.api.gax.resumable.QueryStatusRequest;
42+
import com.google.api.gax.resumable.QueryStatusResponse;
4143
import com.google.api.gax.resumable.ResumableUploadSession;
4244
import com.google.common.util.concurrent.MoreExecutors;
4345
import com.google.errorprone.annotations.concurrent.GuardedBy;
@@ -65,6 +67,8 @@ final class ResumableUploadFutureImpl<ResponseT> implements ResumableUploadFutur
6567
private final ApiFuture<ResumableUploadSession> startFuture;
6668
private final UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
6769
uploadChunkCallable;
70+
private final UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>>
71+
queryStatusCallable;
6872
private final InputStream payload;
6973
private final ResumableUploadCallSettings settings;
7074
private final ClientContext clientContext;
@@ -85,12 +89,18 @@ final class ResumableUploadFutureImpl<ResponseT> implements ResumableUploadFutur
8589
static <ResponseT> ResumableUploadFutureImpl<ResponseT> create(
8690
ApiFuture<ResumableUploadSession> startFuture,
8791
UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>> uploadChunkCallable,
92+
UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>> queryStatusCallable,
8893
InputStream payload,
8994
ResumableUploadCallSettings settings,
9095
ClientContext clientContext) {
9196
ResumableUploadFutureImpl<ResponseT> future =
9297
new ResumableUploadFutureImpl<>(
93-
startFuture, uploadChunkCallable, payload, settings, clientContext);
98+
startFuture,
99+
uploadChunkCallable,
100+
queryStatusCallable,
101+
payload,
102+
settings,
103+
clientContext);
94104
try {
95105
future.start();
96106
} catch (Throwable t) {
@@ -102,12 +112,15 @@ static <ResponseT> ResumableUploadFutureImpl<ResponseT> create(
102112
private ResumableUploadFutureImpl(
103113
ApiFuture<ResumableUploadSession> startFuture,
104114
UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>> uploadChunkCallable,
115+
UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>> queryStatusCallable,
105116
InputStream payload,
106117
ResumableUploadCallSettings settings,
107118
ClientContext clientContext) {
108119
this.startFuture = checkNotNull(startFuture, "startFuture must not be null");
109120
this.uploadChunkCallable =
110121
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
122+
this.queryStatusCallable =
123+
checkNotNull(queryStatusCallable, "queryStatusCallable must not be null");
111124
this.payload = checkNotNull(payload, "payload must not be null");
112125
this.settings = checkNotNull(settings, "settings must not be null");
113126
checkArgument(settings.getChunkSize() > 0, "chunkSize must be > 0");
@@ -128,6 +141,7 @@ public void onSuccess(ResumableUploadSession session) {
128141
ResumableUploadChunkCoordinator<ResponseT> coordinator =
129142
new ResumableUploadChunkCoordinator<>(
130143
uploadChunkCallable,
144+
queryStatusCallable,
131145
uploadSessionUrl,
132146
payload,
133147
settings.getChunkSize(),

‎sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/CallableTest.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,8 @@ void testResumableUploadCallable() {
216216
mock(ResumableUploadClient.class, Mockito.withSettings().withoutAnnotations());
217217
when(uploadClient.uploadChunkCallable())
218218
.thenReturn(mock(UnaryCallable.class, Mockito.withSettings().withoutAnnotations()));
219+
when(uploadClient.queryStatusCallable())
220+
.thenReturn(mock(UnaryCallable.class, Mockito.withSettings().withoutAnnotations()));
219221
ResumableUploadCallSettings settings =
220222
ResumableUploadCallSettings.newBuilder().setChunkSize(1024).build();
221223

0 commit comments

Comments
 (0)