Skip to content

Commit 469ce0d

Browse files
committed
feat(gax): wire progress listener into coordinator and attempt lifecycle
1 parent 75ba10c commit 469ce0d

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: 46 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,15 @@ 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 =
86+
new ResumableUploadProgressTracker();
8287
private final SettableApiFuture<ResponseT> resultFuture = SettableApiFuture.create();
8388

8489
private volatile @Nullable String uploadSessionUrl;
8590

91+
@GuardedBy("lock")
92+
private boolean done;
93+
8694
@GuardedBy("lock")
8795
private @Nullable ApiFuture<?> inFlightFuture;
8896

@@ -147,18 +155,25 @@ private void start() {
147155
new ApiFutureCallback<ResumableUploadSession>() {
148156
@Override
149157
public void onSuccess(ResumableUploadSession session) {
158+
synchronized (lock) {
159+
if (done) {
160+
return;
161+
}
162+
uploadSessionUrl = session.getUploadUrl();
163+
}
164+
progressTracker.onStarted(uploadSessionUrl);
150165
if (resultFuture.isDone()) {
151166
return;
152167
}
153-
uploadSessionUrl = session.getUploadUrl();
154168
ResumableUploadChunkCoordinator<ResponseT> coordinator =
155169
new ResumableUploadChunkCoordinator<>(
156170
uploadChunkCallable,
157171
queryStatusCallable,
158172
uploadSessionUrl,
159173
payload,
160174
settings.getChunkSize(),
161-
callContext);
175+
callContext,
176+
progressTracker);
162177
ApiFuture<ResponseT> uploadFuture;
163178
try {
164179
uploadFuture = coordinator.start();
@@ -168,7 +183,7 @@ public void onSuccess(ResumableUploadSession session) {
168183
}
169184
boolean alreadyDone = false;
170185
synchronized (lock) {
171-
if (resultFuture.isDone()) {
186+
if (done) {
172187
alreadyDone = true;
173188
} else {
174189
inFlightFuture = uploadFuture;
@@ -221,21 +236,31 @@ private void onTimeout() {
221236

222237
private void succeed(@Nullable ResponseT result) {
223238
synchronized (lock) {
239+
if (done) {
240+
return;
241+
}
242+
done = true;
224243
inFlightFuture = null;
225244
}
245+
progressTracker.onFinalized(progressTracker.getStatus().getBytesUploaded());
226246
closePayload();
227247
resultFuture.set(result);
228248
}
229249

230250
private void fail(Throwable t) {
231251
ApiFuture<?> inFlight;
232252
synchronized (lock) {
253+
if (done) {
254+
return;
255+
}
256+
done = true;
233257
inFlight = this.inFlightFuture;
234258
this.inFlightFuture = null;
235259
}
236260
if (inFlight != null) {
237261
inFlight.cancel(true);
238262
}
263+
progressTracker.onFailed();
239264
closePayload();
240265
resultFuture.setException(t);
241266
}
@@ -253,6 +278,16 @@ private void closePayload() {
253278
return uploadSessionUrl;
254279
}
255280

281+
@Override
282+
public void addProgressListener(ResumableUploadProgressListener listener, Executor executor) {
283+
progressTracker.addListener(listener, executor);
284+
}
285+
286+
@Override
287+
public ResumableUploadProgress getStatus() {
288+
return progressTracker.getStatus();
289+
}
290+
256291
@Override
257292
public void addListener(Runnable listener, Executor executor) {
258293
resultFuture.addListener(listener, executor);
@@ -263,13 +298,18 @@ public boolean cancel(boolean mayInterruptIfRunning) {
263298
boolean cancelled;
264299
ApiFuture<?> inFlight;
265300
synchronized (lock) {
301+
if (done) {
302+
return false;
303+
}
304+
done = true;
266305
cancelled = resultFuture.cancel(mayInterruptIfRunning);
267306
inFlight = this.inFlightFuture;
268307
this.inFlightFuture = null;
269308
}
270309
if (inFlight != null) {
271310
inFlight.cancel(mayInterruptIfRunning);
272311
}
312+
progressTracker.onFailed();
273313
closePayload();
274314
return cancelled;
275315
}

0 commit comments

Comments
 (0)