Skip to content

Commit 4dfdd22

Browse files
committed
feat(gax): enforce global timeout for resumable uploads
Enforces ResumableUploadCallSettings.getGlobalTimeout() across the resumable upload lifecycle. A scheduled task is registered at future initiation using the client context executor, covering both session initiation and chunk upload streaming. On deadline expiry, any in-flight attempt future is cancelled, the payload stream is closed, and the session future fails with a non-retryable DeadlineExceededException. The scheduled timeout task is cancelled cleanly across all terminal outcomes (success, failure, cancellation). Single payload closure is guaranteed via AtomicBoolean compare-and-set, with close errors attached via addSuppressed on failure paths and logged on success paths. Buffer safety is maintained during in-flight timeout cancellation without stream advancement or buffer corruption.
1 parent e197ee8 commit 4dfdd22

2 files changed

Lines changed: 292 additions & 10 deletions

File tree

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

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@
5252
import java.io.InputStream;
5353
import java.time.Duration;
5454
import java.util.concurrent.CancellationException;
55+
import java.util.concurrent.ScheduledExecutorService;
56+
import java.util.concurrent.ScheduledFuture;
57+
import java.util.concurrent.TimeUnit;
5558
import org.jspecify.annotations.NullMarked;
5659
import org.jspecify.annotations.Nullable;
5760

@@ -84,6 +87,7 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
8487
private final UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>>
8588
queryStatusCallable;
8689
private final InputStream payload;
90+
private final ResumableUploadCallSettings settings;
8791
private final int chunkSize;
8892
private final ApiCallContext callContext;
8993
private final ClientContext clientContext;
@@ -101,6 +105,9 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
101105
@GuardedBy("lock")
102106
private @Nullable ApiFuture<?> inFlightFuture;
103107

108+
@GuardedBy("lock")
109+
private @Nullable ScheduledFuture<?> timeoutFuture;
110+
104111
ResumableUploadChunkCoordinator(
105112
SettableApiFuture<ResponseT> result,
106113
ApiFuture<ResumableUploadSession> startFuture,
@@ -117,7 +124,7 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
117124
this.queryStatusCallable =
118125
checkNotNull(queryStatusCallable, "queryStatusCallable must not be null");
119126
this.payload = checkNotNull(payload, "payload must not be null");
120-
checkNotNull(settings, "settings must not be null");
127+
this.settings = checkNotNull(settings, "settings must not be null");
121128
checkArgument(settings.getChunkSize() > 0, "chunkSize must be > 0");
122129
this.chunkSize = settings.getChunkSize();
123130
this.callContext = checkNotNull(callContext, "callContext must not be null");
@@ -129,6 +136,19 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
129136
}
130137

131138
void start() {
139+
Duration timeout = settings.getGlobalTimeout();
140+
if (timeout != null && !timeout.isZero() && !timeout.isNegative()) {
141+
ScheduledExecutorService executor = clientContext.getExecutor();
142+
if (executor != null) {
143+
synchronized (lock) {
144+
if (!done) {
145+
this.timeoutFuture =
146+
executor.schedule(this::onTimeout, timeout.toMillis(), TimeUnit.MILLISECONDS);
147+
}
148+
}
149+
}
150+
}
151+
132152
ApiFutures.addCallback(
133153
startFuture,
134154
new ApiFutureCallback<ResumableUploadSession>() {
@@ -155,6 +175,32 @@ public void onFailure(Throwable t) {
155175
MoreExecutors.directExecutor());
156176
}
157177

178+
private static final StatusCode TIMEOUT_STATUS_CODE =
179+
new StatusCode() {
180+
@Override
181+
public StatusCode.Code getCode() {
182+
return StatusCode.Code.DEADLINE_EXCEEDED;
183+
}
184+
185+
@Override
186+
public @Nullable Object getTransportCode() {
187+
return null;
188+
}
189+
};
190+
191+
private void onTimeout() {
192+
synchronized (lock) {
193+
if (done) {
194+
return;
195+
}
196+
}
197+
String message =
198+
uploadSessionUrl != null
199+
? "Resumable upload timed out for session: " + uploadSessionUrl
200+
: "Resumable upload timed out before session initiation completed";
201+
finish(null, new DeadlineExceededException(message, null, TIMEOUT_STATUS_CODE, false));
202+
}
203+
158204
@Nullable String getUploadSessionUrl() {
159205
return uploadSessionUrl;
160206
}
@@ -163,7 +209,7 @@ void setInFlightFuture(ApiFuture<?> future) {
163209
boolean shouldCancel = false;
164210
synchronized (lock) {
165211
if (done) {
166-
shouldCancel = result.isCancelled();
212+
shouldCancel = true;
167213
} else {
168214
this.inFlightFuture = future;
169215
}
@@ -174,6 +220,7 @@ void setInFlightFuture(ApiFuture<?> future) {
174220
}
175221

176222
void cancel(boolean mayInterruptIfRunning) {
223+
ScheduledFuture<?> timeout;
177224
ApiFuture<?> inFlight;
178225
synchronized (lock) {
179226
if (done) {
@@ -182,6 +229,11 @@ void cancel(boolean mayInterruptIfRunning) {
182229
done = true;
183230
inFlight = this.inFlightFuture;
184231
this.inFlightFuture = null;
232+
timeout = this.timeoutFuture;
233+
this.timeoutFuture = null;
234+
}
235+
if (timeout != null) {
236+
timeout.cancel(false);
185237
}
186238
if (inFlight != null) {
187239
inFlight.cancel(mayInterruptIfRunning);
@@ -190,12 +242,23 @@ void cancel(boolean mayInterruptIfRunning) {
190242
}
191243

192244
private void finish(@Nullable ResponseT response, @Nullable Throwable error) {
245+
ScheduledFuture<?> timeout;
246+
ApiFuture<?> inFlight;
193247
synchronized (lock) {
194248
if (done) {
195249
return;
196250
}
197251
done = true;
252+
inFlight = inFlightFuture;
198253
inFlightFuture = null;
254+
timeout = timeoutFuture;
255+
timeoutFuture = null;
256+
}
257+
if (timeout != null) {
258+
timeout.cancel(false);
259+
}
260+
if (inFlight != null) {
261+
inFlight.cancel(true);
199262
}
200263
IOException closeError = closePayload();
201264
if (error == null) {

0 commit comments

Comments
 (0)