Skip to content

Commit 896cd2f

Browse files
committed
feat(gax): retry chunk upload on transient errors
Wraps chunk uploads in a retrying executor to retry transient network and server errors using exponential backoff. Retries individual chunks without restarting the entire upload session.
1 parent dcd33e6 commit 896cd2f

6 files changed

Lines changed: 624 additions & 46 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ public ResumableUploadFuture<ResponseT> futureCall(
9191
client.uploadChunkCallable(),
9292
payload,
9393
effectiveSettings,
94-
clientContext.getDefaultCallContext());
94+
clientContext.getDefaultCallContext(),
95+
clientContext);
9596
}
9697

9798
@Override

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

Lines changed: 95 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,21 @@
3939
import com.google.api.gax.resumable.ChunkUploadRequest;
4040
import com.google.api.gax.resumable.ChunkUploadResponse;
4141
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.RetryingFuture;
46+
import com.google.api.gax.retrying.ScheduledRetryingExecutor;
4247
import com.google.common.io.ByteStreams;
4348
import com.google.common.util.concurrent.MoreExecutors;
4449
import com.google.errorprone.annotations.concurrent.GuardedBy;
4550
import java.io.IOException;
4651
import java.io.InputStream;
52+
import java.time.Duration;
4753
import java.util.Arrays;
4854
import java.util.concurrent.CancellationException;
55+
import java.util.concurrent.atomic.AtomicBoolean;
56+
import java.util.concurrent.atomic.AtomicLong;
4957
import org.jspecify.annotations.NullMarked;
5058
import org.jspecify.annotations.Nullable;
5159

@@ -57,18 +65,34 @@
5765
@NullMarked
5866
final class ResumableUploadChunkCoordinator<ResponseT> {
5967

68+
static final RetrySettings DEFAULT_CHUNK_RETRY_SETTINGS =
69+
RetrySettings.newBuilder()
70+
.setInitialRetryDelayDuration(Duration.ofMillis(100))
71+
.setRetryDelayMultiplier(1.3)
72+
.setMaxRetryDelayDuration(Duration.ofMinutes(1))
73+
.setInitialRpcTimeoutDuration(Duration.ofSeconds(30))
74+
.setRpcTimeoutMultiplier(1.0)
75+
.setMaxRpcTimeoutDuration(Duration.ofSeconds(30))
76+
.setTotalTimeoutDuration(Duration.ofMinutes(5))
77+
.setMaxAttempts(5)
78+
.build();
79+
6080
private static final byte[] EMPTY_PAYLOAD = new byte[0];
6181

6282
private final Object lock = new Object();
83+
private final AtomicBoolean dispatching = new AtomicBoolean(false);
84+
private final AtomicLong nextChunkOffset = new AtomicLong(-1L);
6385

6486
private final SettableApiFuture<ResponseT> result;
6587
private final ApiFuture<ResumableUploadSession> startFuture;
66-
private final UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
67-
uploadChunkCallable;
88+
private final RetryingCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
89+
retryingChunkCallable;
6890
private final InputStream payload;
6991
private final byte[] buffer;
7092
private final int chunkSize;
7193
private final ApiCallContext callContext;
94+
private final ClientContext clientContext;
95+
private final RetrySettings chunkRetrySettings;
7296

7397
private volatile @Nullable String uploadSessionUrl;
7498

@@ -87,17 +111,30 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
87111
UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>> uploadChunkCallable,
88112
InputStream payload,
89113
ResumableUploadCallSettings settings,
90-
ApiCallContext callContext) {
114+
ApiCallContext callContext,
115+
ClientContext clientContext) {
91116
this.result = checkNotNull(result, "result must not be null");
92117
this.startFuture = checkNotNull(startFuture, "startFuture must not be null");
93-
this.uploadChunkCallable =
94-
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
118+
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
95119
this.payload = checkNotNull(payload, "payload must not be null");
96120
checkNotNull(settings, "settings must not be null");
97121
checkArgument(settings.getChunkSize() > 0, "chunkSize must be > 0");
98122
this.chunkSize = settings.getChunkSize();
99123
this.callContext = checkNotNull(callContext, "callContext must not be null");
124+
this.clientContext = checkNotNull(clientContext, "clientContext must not be null");
125+
this.chunkRetrySettings = DEFAULT_CHUNK_RETRY_SETTINGS;
100126
this.buffer = new byte[chunkSize];
127+
128+
RetryAlgorithm<ChunkUploadResponse<ResponseT>> retryAlgorithm =
129+
new RetryAlgorithm<>(
130+
new UploadResultRetryAlgorithm<>(UploadCommand.UPLOAD),
131+
new ExponentialRetryAlgorithm(chunkRetrySettings, clientContext.getClock()));
132+
ScheduledRetryingExecutor<ChunkUploadResponse<ResponseT>> retryingExecutor =
133+
new ScheduledRetryingExecutor<>(retryAlgorithm, clientContext.getExecutor());
134+
this.retryingChunkCallable =
135+
new RetryingCallable<>(
136+
clientContext.getDefaultCallContext(), uploadChunkCallable, retryingExecutor);
137+
101138
synchronized (lock) {
102139
this.inFlightFuture = startFuture;
103140
}
@@ -115,7 +152,7 @@ public void onSuccess(ResumableUploadSession session) {
115152
}
116153
}
117154
uploadSessionUrl = session.getUploadUrl();
118-
transmitChunk(0L);
155+
scheduleNextChunk(0L);
119156
}
120157

121158
@Override
@@ -197,7 +234,24 @@ private void finish(@Nullable ResponseT response, @Nullable Throwable error) {
197234
}
198235
}
199236

200-
private void transmitChunk(long currentOffset) {
237+
private void scheduleNextChunk(long offset) {
238+
nextChunkOffset.set(offset);
239+
if (dispatching.compareAndSet(false, true)) {
240+
driveLoop();
241+
}
242+
}
243+
244+
private void driveLoop() {
245+
do {
246+
long offset = nextChunkOffset.getAndSet(-1L);
247+
if (offset >= 0) {
248+
transmitSingleChunk(offset);
249+
}
250+
dispatching.set(false);
251+
} while (nextChunkOffset.get() >= 0 && dispatching.compareAndSet(false, true));
252+
}
253+
254+
private void transmitSingleChunk(long currentOffset) {
201255
synchronized (lock) {
202256
if (done) {
203257
return;
@@ -236,47 +290,44 @@ private void transmitChunk(long currentOffset) {
236290
.setFinal(isFinal)
237291
.build();
238292

239-
long chunkLength = chunkPayload.length;
240-
try {
241-
ApiFuture<ChunkUploadResponse<ResponseT>> chunkFuture =
242-
uploadChunkCallable.futureCall(chunkRequest, callContext);
243-
setInFlightFuture(chunkFuture);
293+
RetryingFuture<ChunkUploadResponse<ResponseT>> retryingFuture =
294+
retryingChunkCallable.futureCall(chunkRequest, callContext);
295+
setInFlightFuture(retryingFuture);
244296

245-
ApiFutures.addCallback(
246-
chunkFuture,
247-
new ApiFutureCallback<ChunkUploadResponse<ResponseT>>() {
248-
@Override
249-
public void onSuccess(ChunkUploadResponse<ResponseT> response) {
250-
synchronized (lock) {
251-
if (done) {
252-
return;
253-
}
254-
}
255-
long nextOffset = currentOffset + chunkLength;
256-
if (response.isComplete()) {
257-
finish(response.getResponse(), null);
258-
} else if (isFinal) {
259-
finish(
260-
null,
261-
new IllegalStateException(
262-
"Upload stream ended and final chunk was transmitted, but server returned"
263-
+ " incomplete status"));
264-
} else {
265-
transmitChunk(nextOffset);
297+
long chunkLength = chunkPayload.length;
298+
ApiFutures.addCallback(
299+
retryingFuture,
300+
new ApiFutureCallback<ChunkUploadResponse<ResponseT>>() {
301+
@Override
302+
public void onSuccess(ChunkUploadResponse<ResponseT> response) {
303+
synchronized (lock) {
304+
if (done) {
305+
return;
266306
}
267307
}
308+
long nextOffset = currentOffset + chunkLength;
309+
if (response.isComplete()) {
310+
finish(response.getResponse(), null);
311+
} else if (isFinal) {
312+
finish(
313+
null,
314+
new IllegalStateException(
315+
"Upload stream ended and final chunk was transmitted, but server returned"
316+
+ " incomplete status for upload URL: "
317+
+ url));
318+
} else {
319+
scheduleNextChunk(nextOffset);
320+
}
321+
}
268322

269-
@Override
270-
public void onFailure(Throwable t) {
271-
if (t instanceof CancellationException) {
272-
return;
273-
}
274-
finish(null, t);
323+
@Override
324+
public void onFailure(Throwable t) {
325+
if (t instanceof CancellationException) {
326+
return;
275327
}
276-
},
277-
MoreExecutors.directExecutor());
278-
} catch (Throwable t) {
279-
finish(null, t);
280-
}
328+
finish(null, t);
329+
}
330+
},
331+
MoreExecutors.directExecutor());
281332
}
282333
}

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,32 @@ static <ResponseT> ResumableUploadFutureImpl<ResponseT> create(
6161
InputStream payload,
6262
ResumableUploadCallSettings settings,
6363
ApiCallContext callContext) {
64+
return create(
65+
startFuture,
66+
uploadChunkCallable,
67+
payload,
68+
settings,
69+
callContext,
70+
ClientContext.newBuilder().setDefaultCallContext(callContext).build());
71+
}
72+
73+
static <ResponseT> ResumableUploadFutureImpl<ResponseT> create(
74+
ApiFuture<ResumableUploadSession> startFuture,
75+
UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>> uploadChunkCallable,
76+
InputStream payload,
77+
ResumableUploadCallSettings settings,
78+
ApiCallContext callContext,
79+
ClientContext clientContext) {
6480
SettableApiFuture<ResponseT> result = SettableApiFuture.create();
6581
ResumableUploadChunkCoordinator<ResponseT> coordinator =
6682
new ResumableUploadChunkCoordinator<>(
67-
result, startFuture, uploadChunkCallable, payload, settings, callContext);
83+
result,
84+
startFuture,
85+
uploadChunkCallable,
86+
payload,
87+
settings,
88+
callContext,
89+
clientContext);
6890
ResumableUploadFutureImpl<ResponseT> handle =
6991
new ResumableUploadFutureImpl<>(result, coordinator);
7092
coordinator.start();

0 commit comments

Comments
 (0)