Skip to content

Commit 386ec37

Browse files
committed
feat(gax): implement resumable upload session resumption
1 parent 5616230 commit 386ec37

12 files changed

Lines changed: 421 additions & 333 deletions

File tree

‎java-showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITResumableUpload.java‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,66 @@ void testProgressListener(@TempDir Path tempDir) throws Exception {
359359
}
360360
}
361361

362+
@Test
363+
void testResumeUpload(@TempDir Path tempDir) throws Exception {
364+
int totalBytes = 600 * 1024;
365+
int unalignedChunkSize = 300 * 1024;
366+
Path file = createTempFile(tempDir, "it-resume.txt", totalBytes);
367+
UploadMediaRequest request = UploadMediaRequest.newBuilder().setName("it-resume.txt").build();
368+
ApiCallContext failSecondChunkContext = faultInjectionContext(403, 1, SHOWCASE_CHUNK_SIZE);
369+
370+
ResumableUploadFuture<UploadMediaResponse> firstAttemptFuture =
371+
client
372+
.uploadMediaCallable()
373+
.futureCall(
374+
request,
375+
() -> Files.newInputStream(file),
376+
failSecondChunkContext,
377+
ResumableUploadOptions.newBuilder().setChunkSize(unalignedChunkSize).build());
378+
379+
assertThrows(ExecutionException.class, () -> firstAttemptFuture.get(15, TimeUnit.SECONDS));
380+
String sessionUrl = firstAttemptFuture.getUploadSessionUrl();
381+
Integer effectiveChunkSize = firstAttemptFuture.getEffectiveChunkSize();
382+
assertThat(sessionUrl).isNotNull();
383+
assertThat(effectiveChunkSize).isEqualTo(SHOWCASE_CHUNK_SIZE);
384+
385+
List<ResumableUploadProgress> resumeProgress = new CopyOnWriteArrayList<>();
386+
CountDownLatch finalizedLatch = new CountDownLatch(1);
387+
ResumableUploadOptions resumeOptions =
388+
ResumableUploadOptions.newBuilder()
389+
.setChunkSize(effectiveChunkSize)
390+
.setProgressListener(
391+
status -> {
392+
resumeProgress.add(status);
393+
if ("FINALIZED".equals(status.getState())) {
394+
finalizedLatch.countDown();
395+
}
396+
})
397+
.build();
398+
399+
ResumableUploadFuture<UploadMediaResponse> secondAttemptFuture =
400+
client
401+
.uploadMediaCallable()
402+
.resumeCall(sessionUrl, () -> Files.newInputStream(file), resumeOptions);
403+
UploadMediaResponse response = secondAttemptFuture.get(30, TimeUnit.SECONDS);
404+
assertThat(finalizedLatch.await(10, TimeUnit.SECONDS)).isTrue();
405+
406+
assertThat(response.getName()).isEqualTo("it-resume.txt");
407+
assertThat(response.getSize()).isEqualTo(totalBytes);
408+
assertThat(
409+
resumeProgress.stream()
410+
.map(ResumableUploadProgress::getState)
411+
.collect(Collectors.toList()))
412+
.containsExactly("RECOVERING", "OFFSET_RECEIVED", "UPLOADING", "FINALIZED")
413+
.inOrder();
414+
assertThat(
415+
resumeProgress.stream()
416+
.map(ResumableUploadProgress::getBytesUploaded)
417+
.collect(Collectors.toList()))
418+
.containsExactly(0L, 262144L, 524288L, 614400L)
419+
.inOrder();
420+
}
421+
362422
private static Path createTempFile(Path dir, String fileName, byte[] data) throws IOException {
363423
Path path = dir.resolve(fileName);
364424
Files.write(path, data);

‎java-showcase/gapic-showcase/src/test/resources/compliance_suite.json‎

Lines changed: 0 additions & 240 deletions
This file was deleted.

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,14 @@
4343
public interface InputStreamSupplier {
4444

4545
/**
46-
* Opens or returns the {@link InputStream} to be read.
46+
* Opens or returns the {@link InputStream} to be read, positioned at the beginning of the
47+
* payload.
4748
*
4849
* <p>The returned stream is managed by the resumable upload and is closed automatically when the
4950
* upload succeeds, fails, times out, or is cancelled. Callers should not close the stream
5051
* themselves.
5152
*
52-
* @return a non-null input stream over the payload data
53+
* @return a non-null input stream positioned at byte {@code 0} of the payload data
5354
* @throws IOException if an I/O error occurs when opening or obtaining the stream
5455
*/
5556
InputStream get() throws IOException;

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ public abstract ResumableUploadFuture<ResponseT> futureCall(
8585
/**
8686
* Resumes an existing resumable upload session asynchronously using a saved session URL.
8787
*
88+
* <p>Resuming queries the server for the last committed byte offset and advances the stream to
89+
* that offset before resuming chunk transmission.
90+
*
91+
* <p>Because server chunk granularity is only returned in the initial {@code start} response,
92+
* callers resuming an upload should configure the initial session's effective chunk size (from
93+
* {@link ResumableUploadFuture#getEffectiveChunkSize()} or {@link
94+
* ResumableUploadProgress#getEffectiveChunkSize()}) via {@link
95+
* ResumableUploadOptions.Builder#setChunkSize(int)}.
96+
*
8897
* <p>Streams from the provided {@code payload} are consumed asynchronously by the returned {@link
8998
* ResumableUploadFuture} and will be closed automatically upon completion, failure, or
9099
* cancellation.

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
*/
3030
package com.google.api.gax.rpc;
3131

32+
import static com.google.common.base.Preconditions.checkArgument;
3233
import static com.google.common.base.Preconditions.checkNotNull;
3334

3435
import com.google.api.core.ApiFuture;
@@ -45,6 +46,7 @@
4546
import com.google.api.gax.retrying.RetryAlgorithm;
4647
import com.google.api.gax.retrying.RetrySettings;
4748
import com.google.api.gax.retrying.ScheduledRetryingExecutor;
49+
import com.google.common.base.Strings;
4850
import java.time.Duration;
4951
import org.jspecify.annotations.NullMarked;
5052
import org.jspecify.annotations.Nullable;
@@ -131,7 +133,18 @@ public ResumableUploadFuture<ResponseT> futureCall(
131133
@Override
132134
public ResumableUploadFuture<ResponseT> resumeCall(
133135
String sessionUrl, InputStreamSupplier payload, @Nullable ResumableUploadOptions options) {
134-
throw new UnsupportedOperationException("Session resumption is not yet implemented.");
136+
checkArgument(!Strings.isNullOrEmpty(sessionUrl), "sessionUrl must not be null or empty");
137+
checkNotNull(payload, "payload must not be null");
138+
ResumableUploadOptions effectiveOptions = defaultOptions.merge(options);
139+
140+
return ResumableUploadFutureImpl.createForResume(
141+
sessionUrl,
142+
retryingUploadChunkCallable,
143+
retryingQueryCallable,
144+
payload,
145+
effectiveOptions,
146+
clientContext,
147+
recoveryAlgorithm);
135148
}
136149

137150
private static <RequestT, ResponseT> UnaryCallable<RequestT, ResponseT> createRetryingCallable(

0 commit comments

Comments
 (0)