Skip to content

Commit 6b71690

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 a6d366b commit 6b71690

6 files changed

Lines changed: 664 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

4957
/**
@@ -54,10 +62,25 @@
5462
@NullMarked
5563
final class ResumableUploadChunkCoordinator<ResponseT> {
5664

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

59-
private final UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
60-
uploadChunkCallable;
79+
private final AtomicBoolean dispatching = new AtomicBoolean(false);
80+
private final AtomicLong nextChunkOffset = new AtomicLong(-1L);
81+
82+
private final RetryingCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
83+
retryingChunkCallable;
6184
private final String uploadUrl;
6285
private final InputStream payload;
6386
private final byte[] buffer;
@@ -70,22 +93,50 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
7093
String uploadUrl,
7194
InputStream payload,
7295
int chunkSize,
73-
ApiCallContext callContext) {
74-
this.uploadChunkCallable =
75-
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
96+
ApiCallContext callContext,
97+
ClientContext clientContext) {
98+
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
7699
this.uploadUrl = checkNotNull(uploadUrl, "uploadUrl must not be null");
77100
this.payload = checkNotNull(payload, "payload must not be null");
78101
this.chunkSize = chunkSize;
79102
this.callContext = checkNotNull(callContext, "callContext must not be null");
103+
checkNotNull(clientContext, "clientContext must not be null");
80104
this.buffer = new byte[chunkSize];
105+
106+
RetryAlgorithm<ChunkUploadResponse<ResponseT>> retryAlgorithm =
107+
new RetryAlgorithm<>(
108+
new ResumableUploadResultRetryAlgorithm<>(ResumableUploadCommand.UPLOAD),
109+
new ExponentialRetryAlgorithm(DEFAULT_CHUNK_RETRY_SETTINGS, clientContext.getClock()));
110+
ScheduledRetryingExecutor<ChunkUploadResponse<ResponseT>> retryingExecutor =
111+
new ScheduledRetryingExecutor<>(retryAlgorithm, clientContext.getExecutor());
112+
this.retryingChunkCallable =
113+
new RetryingCallable<>(
114+
clientContext.getDefaultCallContext(), uploadChunkCallable, retryingExecutor);
81115
}
82116

83117
ApiFuture<ResponseT> start() {
84-
transmitChunk(0L);
118+
scheduleNextChunk(0L);
85119
return result;
86120
}
87121

88-
private void transmitChunk(long currentOffset) {
122+
private void scheduleNextChunk(long offset) {
123+
nextChunkOffset.set(offset);
124+
if (dispatching.compareAndSet(false, true)) {
125+
driveLoop();
126+
}
127+
}
128+
129+
private void driveLoop() {
130+
do {
131+
long offset = nextChunkOffset.getAndSet(-1L);
132+
if (offset >= 0) {
133+
transmitSingleChunk(offset);
134+
}
135+
dispatching.set(false);
136+
} while (nextChunkOffset.get() >= 0 && dispatching.compareAndSet(false, true));
137+
}
138+
139+
private void transmitSingleChunk(long currentOffset) {
89140
if (result.isDone()) {
90141
return;
91142
}
@@ -118,18 +169,18 @@ private void transmitChunk(long currentOffset) {
118169

119170
long chunkLength = chunkPayload.length;
120171
try {
121-
ApiFuture<ChunkUploadResponse<ResponseT>> chunkFuture =
122-
uploadChunkCallable.futureCall(chunkRequest, callContext);
172+
RetryingFuture<ChunkUploadResponse<ResponseT>> retryingFuture =
173+
retryingChunkCallable.futureCall(chunkRequest, callContext);
123174
result.addListener(
124175
() -> {
125176
if (result.isCancelled()) {
126-
chunkFuture.cancel(true);
177+
retryingFuture.cancel(true);
127178
}
128179
},
129180
MoreExecutors.directExecutor());
130181

131182
ApiFutures.addCallback(
132-
chunkFuture,
183+
retryingFuture,
133184
new ApiFutureCallback<ChunkUploadResponse<ResponseT>>() {
134185
@Override
135186
public void onSuccess(ChunkUploadResponse<ResponseT> response) {
@@ -143,9 +194,10 @@ public void onSuccess(ChunkUploadResponse<ResponseT> response) {
143194
result.setException(
144195
new IllegalStateException(
145196
"Upload stream ended and final chunk was transmitted, but server returned"
146-
+ " incomplete status"));
197+
+ " incomplete status for upload URL: "
198+
+ uploadUrl));
147199
} else {
148-
transmitChunk(nextOffset);
200+
scheduleNextChunk(nextOffset);
149201
}
150202
}
151203

‎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)