Skip to content

Commit 9d6f045

Browse files
committed
feat(gax): wire progress listener into coordinator and attempt lifecycle
1 parent 174a03a commit 9d6f045

4 files changed

Lines changed: 398 additions & 21 deletions

File tree

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

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
7070
private final String uploadUrl;
7171
private final RewindableStreamBuffer buffer;
7272
private final ApiCallContext callContext;
73+
private final ResumableUploadProgressTracker progressTracker;
7374
private final SettableApiFuture<ResponseT> result = SettableApiFuture.create();
7475
private volatile @Nullable ApiFuture<?> currentChunkFuture;
7576

@@ -79,14 +80,16 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
7980
String uploadUrl,
8081
InputStream payload,
8182
int chunkSize,
82-
ApiCallContext callContext) {
83+
ApiCallContext callContext,
84+
ResumableUploadProgressTracker progressTracker) {
8385
this.uploadChunkCallable =
8486
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
8587
this.queryStatusCallable =
8688
checkNotNull(queryStatusCallable, "queryStatusCallable must not be null");
8789
this.uploadUrl = checkNotNull(uploadUrl, "uploadUrl must not be null");
8890
checkNotNull(payload, "payload must not be null");
8991
this.callContext = checkNotNull(callContext, "callContext must not be null");
92+
this.progressTracker = checkNotNull(progressTracker, "progressTracker must not be null");
9093
this.buffer = new RewindableStreamBuffer(payload, chunkSize, uploadUrl);
9194
}
9295

@@ -136,7 +139,7 @@ public void onSuccess(ChunkUploadResponse<ResponseT> response) {
136139
return;
137140
}
138141
if (response.getUploadStatus() == ResumableUploadStatus.UNKNOWN) {
139-
recover();
142+
recover(null);
140143
} else {
141144
onChunkUploaded(response);
142145
}
@@ -150,7 +153,7 @@ public void onFailure(Throwable t) {
150153
Category category =
151154
ResumableUploadErrorClassifier.classify(t, ResumableUploadCommand.UPLOAD);
152155
if (category == Category.RECOVERABLE) {
153-
recover();
156+
recover(t);
154157
} else {
155158
// Category.TRANSIENT errors reaching here have already exhausted their retry budget
156159
// in the underlying RetryingCallable and become fatal per protocol specification.
@@ -164,7 +167,14 @@ public void onFailure(Throwable t) {
164167
}
165168
}
166169

167-
private void recover() {
170+
/**
171+
* Queries the session for the server's committed offset and resumes transmission from it.
172+
*
173+
* @param cause the error that triggered recovery, or null if the server acknowledged the chunk
174+
* without an upload status header
175+
*/
176+
private void recover(@Nullable Throwable cause) {
177+
progressTracker.onRecovering();
168178
try {
169179
// Dispatch the query status call and register the in-flight future for cancellation.
170180
ApiFuture<QueryStatusResponse<ResponseT>> queryFuture =
@@ -228,22 +238,29 @@ private void handleQueryResponse(QueryStatusResponse<ResponseT> queryResponse)
228238
"Incomplete query status response did not include a committed offset for upload URL: "
229239
+ uploadUrl);
230240
}
241+
progressTracker.onOffsetReceived(committedOffset);
231242
buffer.realignTo(committedOffset);
232243
dispatchCurrentChunk();
233244
}
234245

235246
private void onChunkUploaded(ChunkUploadResponse<ResponseT> response) {
236-
long nextOffset = buffer.getBufferBaseOffset() + buffer.getPayloadLength();
237-
if (response.getUploadStatus() == ResumableUploadStatus.FINAL) {
238-
result.set(response.getResponse());
239-
} else if (buffer.isFinal()) {
240-
result.setException(
241-
new IllegalStateException(
242-
"Upload stream ended and final chunk was transmitted, but server returned"
243-
+ " incomplete status for upload URL: "
244-
+ uploadUrl));
245-
} else {
246-
chunkExecutor.execute(() -> transmitChunk(nextOffset));
247+
try {
248+
long nextOffset = buffer.getBufferBaseOffset() + buffer.getPayloadLength();
249+
if (response.getUploadStatus() == ResumableUploadStatus.FINAL) {
250+
progressTracker.onChunkUploaded(nextOffset);
251+
result.set(response.getResponse());
252+
} else if (buffer.isFinal()) {
253+
result.setException(
254+
new IllegalStateException(
255+
"Upload stream ended and final chunk was transmitted, but server returned"
256+
+ " incomplete status for upload URL: "
257+
+ uploadUrl));
258+
} else {
259+
progressTracker.onChunkUploaded(nextOffset);
260+
chunkExecutor.execute(() -> transmitChunk(nextOffset));
261+
}
262+
} catch (Throwable t) {
263+
result.setException(t);
247264
}
248265
}
249266

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131

3232
import com.google.api.core.ApiFuture;
3333
import com.google.api.core.BetaApi;
34+
import com.google.api.gax.resumable.ResumableUploadProgress;
35+
import com.google.api.gax.resumable.ResumableUploadProgressListener;
36+
import java.util.concurrent.Executor;
3437
import org.jspecify.annotations.NullMarked;
3538
import org.jspecify.annotations.Nullable;
3639

@@ -48,4 +51,18 @@ public interface ResumableUploadFuture<ResponseT> extends ApiFuture<ResponseT> {
4851

4952
/** Returns the upload session URL, or {@code null} if session initiation is in progress. */
5053
@Nullable String getUploadSessionUrl();
54+
55+
/**
56+
* Registers a listener to receive progress and state transition notifications for this upload.
57+
*
58+
* <p>A snapshot of the current upload status is dispatched to the listener immediately upon
59+
* subscription on the provided executor. Subsequent status updates are delivered in order.
60+
*
61+
* @param listener callback listener to receive progress notifications
62+
* @param executor executor on which the listener callbacks are dispatched
63+
*/
64+
void addProgressListener(ResumableUploadProgressListener listener, Executor executor);
65+
66+
/** Returns the current progress snapshot of the upload session. */
67+
ResumableUploadProgress getStatus();
5168
}

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

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
import com.google.api.gax.resumable.ChunkUploadResponse;
4242
import com.google.api.gax.resumable.QueryStatusRequest;
4343
import com.google.api.gax.resumable.QueryStatusResponse;
44+
import com.google.api.gax.resumable.ResumableUploadProgress;
45+
import com.google.api.gax.resumable.ResumableUploadProgressListener;
4446
import com.google.api.gax.resumable.ResumableUploadSession;
4547
import com.google.common.util.concurrent.MoreExecutors;
4648
import com.google.errorprone.annotations.concurrent.GuardedBy;
@@ -58,10 +60,11 @@
5860
import org.jspecify.annotations.Nullable;
5961

6062
/**
61-
* Implementation of {@link ResumableUploadFuture} responsible for the end-to-end management of a
62-
* resumable upload session.
63+
* Baseline implementation of {@link ResumableUploadFuture}.
6364
*
64-
* @param <ResponseT> the type of the final response message returned once the upload completes
65+
* <p>Coordinates session initiation, delegates chunk streaming to {@link
66+
* ResumableUploadChunkCoordinator}, and manages the lifecycle of the user-provided payload {@link
67+
* InputStream}.
6568
*/
6669
@NullMarked
6770
final class ResumableUploadFutureImpl<ResponseT> implements ResumableUploadFuture<ResponseT> {
@@ -79,10 +82,14 @@ final class ResumableUploadFutureImpl<ResponseT> implements ResumableUploadFutur
7982
private final ResumableUploadCallSettings settings;
8083
private final ApiCallContext callContext;
8184
private final ScheduledExecutorService executor;
85+
private final ResumableUploadProgressTracker progressTracker = new ResumableUploadProgressTracker();
8286
private final SettableApiFuture<ResponseT> resultFuture = SettableApiFuture.create();
8387

8488
private volatile @Nullable String uploadSessionUrl;
8589

90+
@GuardedBy("lock")
91+
private boolean done;
92+
8693
@GuardedBy("lock")
8794
private @Nullable ApiFuture<?> inFlightFuture;
8895

@@ -147,18 +154,25 @@ private void start() {
147154
new ApiFutureCallback<ResumableUploadSession>() {
148155
@Override
149156
public void onSuccess(ResumableUploadSession session) {
157+
synchronized (lock) {
158+
if (done) {
159+
return;
160+
}
161+
uploadSessionUrl = session.getUploadUrl();
162+
}
163+
progressTracker.onStarted(uploadSessionUrl);
150164
if (resultFuture.isDone()) {
151165
return;
152166
}
153-
uploadSessionUrl = session.getUploadUrl();
154167
ResumableUploadChunkCoordinator<ResponseT> coordinator =
155168
new ResumableUploadChunkCoordinator<>(
156169
uploadChunkCallable,
157170
queryStatusCallable,
158171
uploadSessionUrl,
159172
payload,
160173
settings.getChunkSize(),
161-
callContext);
174+
callContext,
175+
progressTracker);
162176
ApiFuture<ResponseT> uploadFuture;
163177
try {
164178
uploadFuture = coordinator.start();
@@ -168,7 +182,7 @@ public void onSuccess(ResumableUploadSession session) {
168182
}
169183
boolean alreadyDone = false;
170184
synchronized (lock) {
171-
if (resultFuture.isDone()) {
185+
if (done) {
172186
alreadyDone = true;
173187
} else {
174188
inFlightFuture = uploadFuture;
@@ -221,21 +235,31 @@ private void onTimeout() {
221235

222236
private void succeed(@Nullable ResponseT result) {
223237
synchronized (lock) {
238+
if (done) {
239+
return;
240+
}
241+
done = true;
224242
inFlightFuture = null;
225243
}
244+
progressTracker.onFinalized(progressTracker.getStatus().getBytesUploaded());
226245
closePayload();
227246
resultFuture.set(result);
228247
}
229248

230249
private void fail(Throwable t) {
231250
ApiFuture<?> inFlight;
232251
synchronized (lock) {
252+
if (done) {
253+
return;
254+
}
255+
done = true;
233256
inFlight = this.inFlightFuture;
234257
this.inFlightFuture = null;
235258
}
236259
if (inFlight != null) {
237260
inFlight.cancel(true);
238261
}
262+
progressTracker.onFailed();
239263
closePayload();
240264
resultFuture.setException(t);
241265
}
@@ -253,6 +277,16 @@ private void closePayload() {
253277
return uploadSessionUrl;
254278
}
255279

280+
@Override
281+
public void addProgressListener(ResumableUploadProgressListener listener, Executor executor) {
282+
progressTracker.addListener(listener, executor);
283+
}
284+
285+
@Override
286+
public ResumableUploadProgress getStatus() {
287+
return progressTracker.getStatus();
288+
}
289+
256290
@Override
257291
public void addListener(Runnable listener, Executor executor) {
258292
resultFuture.addListener(listener, executor);
@@ -263,13 +297,18 @@ public boolean cancel(boolean mayInterruptIfRunning) {
263297
boolean cancelled;
264298
ApiFuture<?> inFlight;
265299
synchronized (lock) {
300+
if (done) {
301+
return false;
302+
}
303+
done = true;
266304
cancelled = resultFuture.cancel(mayInterruptIfRunning);
267305
inFlight = this.inFlightFuture;
268306
this.inFlightFuture = null;
269307
}
270308
if (inFlight != null) {
271309
inFlight.cancel(mayInterruptIfRunning);
272310
}
311+
progressTracker.onFailed();
273312
closePayload();
274313
return cancelled;
275314
}

0 commit comments

Comments
 (0)