Skip to content

Commit d1e3b92

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 d885bb2 commit d1e3b92

6 files changed

Lines changed: 641 additions & 24 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: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,20 @@
3838
import com.google.api.gax.resumable.ChunkUploadRequest;
3939
import com.google.api.gax.resumable.ChunkUploadResponse;
4040
import com.google.api.gax.resumable.ResumableUploadStatus;
41+
import com.google.api.gax.retrying.ExponentialRetryAlgorithm;
42+
import com.google.api.gax.retrying.RetryAlgorithm;
43+
import com.google.api.gax.retrying.RetrySettings;
44+
import com.google.api.gax.retrying.RetryingFuture;
45+
import com.google.api.gax.retrying.ScheduledRetryingExecutor;
4146
import com.google.common.io.ByteStreams;
4247
import com.google.common.util.concurrent.MoreExecutors;
4348
import java.io.IOException;
4449
import java.io.InputStream;
50+
import java.time.Duration;
4551
import java.util.Arrays;
4652
import java.util.concurrent.CancellationException;
53+
import java.util.concurrent.atomic.AtomicBoolean;
54+
import java.util.concurrent.atomic.AtomicLong;
4755
import org.jspecify.annotations.NullMarked;
4856
import org.jspecify.annotations.Nullable;
4957

@@ -55,10 +63,25 @@
5563
@NullMarked
5664
final class ResumableUploadChunkCoordinator<ResponseT> {
5765

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

60-
private final UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
61-
uploadChunkCallable;
80+
private final AtomicBoolean dispatching = new AtomicBoolean(false);
81+
private final AtomicLong nextChunkOffset = new AtomicLong(-1L);
82+
83+
private final RetryingCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
84+
retryingChunkCallable;
6285
private final String uploadUrl;
6386
private final InputStream payload;
6487
private final byte[] buffer;
@@ -72,14 +95,25 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
7295
String uploadUrl,
7396
InputStream payload,
7497
int chunkSize,
75-
ApiCallContext callContext) {
76-
this.uploadChunkCallable =
77-
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
98+
ApiCallContext callContext,
99+
ClientContext clientContext) {
100+
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
78101
this.uploadUrl = checkNotNull(uploadUrl, "uploadUrl must not be null");
79102
this.payload = checkNotNull(payload, "payload must not be null");
80103
this.chunkSize = chunkSize;
81104
this.callContext = checkNotNull(callContext, "callContext must not be null");
105+
checkNotNull(clientContext, "clientContext must not be null");
82106
this.buffer = new byte[chunkSize];
107+
108+
RetryAlgorithm<ChunkUploadResponse<ResponseT>> retryAlgorithm =
109+
new RetryAlgorithm<>(
110+
new ResumableUploadResultRetryAlgorithm<>(ResumableUploadCommand.UPLOAD),
111+
new ExponentialRetryAlgorithm(DEFAULT_CHUNK_RETRY_SETTINGS, clientContext.getClock()));
112+
ScheduledRetryingExecutor<ChunkUploadResponse<ResponseT>> retryingExecutor =
113+
new ScheduledRetryingExecutor<>(retryAlgorithm, clientContext.getExecutor());
114+
this.retryingChunkCallable =
115+
new RetryingCallable<>(
116+
clientContext.getDefaultCallContext(), uploadChunkCallable, retryingExecutor);
83117
}
84118

85119
ApiFuture<ResponseT> start() {
@@ -93,11 +127,28 @@ ApiFuture<ResponseT> start() {
93127
}
94128
},
95129
MoreExecutors.directExecutor());
96-
transmitChunk(0L);
130+
scheduleNextChunk(0L);
97131
return result;
98132
}
99133

100-
private void transmitChunk(long currentOffset) {
134+
private void scheduleNextChunk(long offset) {
135+
nextChunkOffset.set(offset);
136+
if (dispatching.compareAndSet(false, true)) {
137+
driveLoop();
138+
}
139+
}
140+
141+
private void driveLoop() {
142+
do {
143+
long offset = nextChunkOffset.getAndSet(-1L);
144+
if (offset >= 0) {
145+
transmitSingleChunk(offset);
146+
}
147+
dispatching.set(false);
148+
} while (nextChunkOffset.get() >= 0 && dispatching.compareAndSet(false, true));
149+
}
150+
151+
private void transmitSingleChunk(long currentOffset) {
101152
if (result.isDone()) {
102153
return;
103154
}
@@ -130,12 +181,12 @@ private void transmitChunk(long currentOffset) {
130181

131182
long chunkLength = chunkPayload.length;
132183
try {
133-
ApiFuture<ChunkUploadResponse<ResponseT>> chunkFuture =
134-
uploadChunkCallable.futureCall(chunkRequest, callContext);
135-
this.currentChunkFuture = chunkFuture;
184+
RetryingFuture<ChunkUploadResponse<ResponseT>> retryingFuture =
185+
retryingChunkCallable.futureCall(chunkRequest, callContext);
186+
this.currentChunkFuture = retryingFuture;
136187

137188
ApiFutures.addCallback(
138-
chunkFuture,
189+
retryingFuture,
139190
new ApiFutureCallback<ChunkUploadResponse<ResponseT>>() {
140191
@Override
141192
public void onSuccess(ChunkUploadResponse<ResponseT> response) {
@@ -149,9 +200,10 @@ public void onSuccess(ChunkUploadResponse<ResponseT> response) {
149200
result.setException(
150201
new IllegalStateException(
151202
"Upload stream ended and final chunk was transmitted, but server returned"
152-
+ " incomplete status"));
203+
+ " incomplete status for upload URL: "
204+
+ uploadUrl));
153205
} else {
154-
transmitChunk(nextOffset);
206+
scheduleNextChunk(nextOffset);
155207
}
156208
}
157209

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

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ final class ResumableUploadFutureImpl<ResponseT> implements ResumableUploadFutur
6868
private final InputStream payload;
6969
private final ResumableUploadCallSettings settings;
7070
private final ApiCallContext callContext;
71+
private final ClientContext clientContext;
7172
private final SettableApiFuture<ResponseT> resultFuture = SettableApiFuture.create();
7273

7374
private volatile @Nullable String uploadSessionUrl;
@@ -81,22 +82,31 @@ final class ResumableUploadFutureImpl<ResponseT> implements ResumableUploadFutur
8182
@GuardedBy("lock")
8283
private @Nullable ApiFuture<?> inFlightFuture;
8384

84-
/**
85-
* Creates and initiates a new resumable upload future tracking session initiation and chunk
86-
* streaming.
87-
*
88-
* <p>The provided {@code payload} stream is managed by the returned future and will be closed
89-
* automatically upon completion, failure, or cancellation.
90-
*/
9185
static <ResponseT> ResumableUploadFutureImpl<ResponseT> create(
9286
ApiFuture<ResumableUploadSession> startFuture,
9387
UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>> uploadChunkCallable,
9488
InputStream payload,
9589
ResumableUploadCallSettings settings,
9690
ApiCallContext callContext) {
91+
return create(
92+
startFuture,
93+
uploadChunkCallable,
94+
payload,
95+
settings,
96+
callContext,
97+
ClientContext.newBuilder().setDefaultCallContext(callContext).build());
98+
}
99+
100+
static <ResponseT> ResumableUploadFutureImpl<ResponseT> create(
101+
ApiFuture<ResumableUploadSession> startFuture,
102+
UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>> uploadChunkCallable,
103+
InputStream payload,
104+
ResumableUploadCallSettings settings,
105+
ApiCallContext callContext,
106+
ClientContext clientContext) {
97107
ResumableUploadFutureImpl<ResponseT> future =
98108
new ResumableUploadFutureImpl<>(
99-
startFuture, uploadChunkCallable, payload, settings, callContext);
109+
startFuture, uploadChunkCallable, payload, settings, callContext, clientContext);
100110
future.start();
101111
return future;
102112
}
@@ -106,14 +116,16 @@ static <ResponseT> ResumableUploadFutureImpl<ResponseT> create(
106116
UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>> uploadChunkCallable,
107117
InputStream payload,
108118
ResumableUploadCallSettings settings,
109-
ApiCallContext callContext) {
119+
ApiCallContext callContext,
120+
ClientContext clientContext) {
110121
this.startFuture = checkNotNull(startFuture, "startFuture must not be null");
111122
this.uploadChunkCallable =
112123
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
113124
this.payload = checkNotNull(payload, "payload must not be null");
114125
this.settings = checkNotNull(settings, "settings must not be null");
115126
checkArgument(settings.getChunkSize() > 0, "chunkSize must be > 0");
116127
this.callContext = checkNotNull(callContext, "callContext must not be null");
128+
this.clientContext = checkNotNull(clientContext, "clientContext must not be null");
117129
synchronized (lock) {
118130
this.inFlightFuture = startFuture;
119131
}
@@ -137,7 +149,8 @@ public void onSuccess(ResumableUploadSession session) {
137149
uploadSessionUrl,
138150
payload,
139151
settings.getChunkSize(),
140-
callContext);
152+
callContext,
153+
clientContext);
141154
uploadFuture = coordinator.start();
142155
inFlightFuture = uploadFuture;
143156
}

0 commit comments

Comments
 (0)