From 847c892247454b1827829859e8983f074d2fc7da Mon Sep 17 00:00:00 2001 From: Blake Li Date: Tue, 2 Jun 2026 22:14:46 +0000 Subject: [PATCH 1/9] feat(library_generation): generic grouping folder for unversioned libraries staging paths --- .../templates/owlbot.yaml.monorepo.j2 | 13 +++++-- ...Bot-hermetic-unversioned-gapic-golden.yaml | 35 +++++++++++++++++ ...ermetic-unversioned-proto-only-golden.yaml | 29 ++++++++++++++ .../tests/utilities_unit_tests.py | 38 ++++++++++++++++++- .../library_generation/utils/utilities.py | 4 ++ 5 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 sdk-platform-java/hermetic_build/library_generation/tests/resources/goldens/.OwlBot-hermetic-unversioned-gapic-golden.yaml create mode 100644 sdk-platform-java/hermetic_build/library_generation/tests/resources/goldens/.OwlBot-hermetic-unversioned-proto-only-golden.yaml diff --git a/sdk-platform-java/hermetic_build/library_generation/templates/owlbot.yaml.monorepo.j2 b/sdk-platform-java/hermetic_build/library_generation/templates/owlbot.yaml.monorepo.j2 index d3f29de32a7e..5d7fcf8ff34c 100644 --- a/sdk-platform-java/hermetic_build/library_generation/templates/owlbot.yaml.monorepo.j2 +++ b/sdk-platform-java/hermetic_build/library_generation/templates/owlbot.yaml.monorepo.j2 @@ -33,14 +33,19 @@ deep-copy-regex: - source: "/{{ proto_path }}/(v.*)/.*-java/samples/snippets/generated" dest: "/owl-bot-staging/{{ module_name }}/$1/samples/snippets/generated" {%- else %} +{%- if proto_only %} - source: "/{{ proto_path }}/.*-java/proto-google-.*/src" - dest: "/owl-bot-staging/{{ module_name }}/proto-{{ artifact_id }}/src" + dest: "/owl-bot-staging/{{ module_name }}/{{ unversioned_dir }}/{{ artifact_id }}/src" +{%- else %} +- source: "/{{ proto_path }}/.*-java/proto-google-.*/src" + dest: "/owl-bot-staging/{{ module_name }}/{{ unversioned_dir }}/proto-{{ artifact_id }}/src" - source: "/{{ proto_path }}/.*-java/grpc-google-.*/src" - dest: "/owl-bot-staging/{{ module_name }}/grpc-{{ artifact_id }}/src" + dest: "/owl-bot-staging/{{ module_name }}/{{ unversioned_dir }}/grpc-{{ artifact_id }}/src" - source: "/{{ proto_path }}/.*-java/gapic-google-.*/src" - dest: "/owl-bot-staging/{{ module_name }}/{{ artifact_id }}/src" + dest: "/owl-bot-staging/{{ module_name }}/{{ unversioned_dir }}/{{ artifact_id }}/src" - source: "/{{ proto_path }}/.*-java/samples/snippets/generated" - dest: "/owl-bot-staging/{{ module_name }}/samples/snippets/generated" + dest: "/owl-bot-staging/{{ module_name }}/{{ unversioned_dir }}/samples/snippets/generated" +{%- endif %} {%- endif %} {%- endif %} diff --git a/sdk-platform-java/hermetic_build/library_generation/tests/resources/goldens/.OwlBot-hermetic-unversioned-gapic-golden.yaml b/sdk-platform-java/hermetic_build/library_generation/tests/resources/goldens/.OwlBot-hermetic-unversioned-gapic-golden.yaml new file mode 100644 index 000000000000..33dcf88e4e32 --- /dev/null +++ b/sdk-platform-java/hermetic_build/library_generation/tests/resources/goldens/.OwlBot-hermetic-unversioned-gapic-golden.yaml @@ -0,0 +1,35 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +deep-remove-regex: +- "/java-bare-metal-solution/grpc-google-.*/src" +- "/java-bare-metal-solution/proto-google-.*/src" +- "/java-bare-metal-solution/google-.*/src" +- "/java-bare-metal-solution/samples/snippets/generated" + +deep-preserve-regex: +- "/java-bare-metal-solution/google-.*/src/test/java/com/google/cloud/.*/v.*/it/IT.*Test.java" + +deep-copy-regex: +- source: "/google/cloud/baremetalsolution/.*-java/proto-google-.*/src" + dest: "/owl-bot-staging/java-bare-metal-solution/baremetalsolution/proto-google-cloud-bare-metal-solution/src" +- source: "/google/cloud/baremetalsolution/.*-java/grpc-google-.*/src" + dest: "/owl-bot-staging/java-bare-metal-solution/baremetalsolution/grpc-google-cloud-bare-metal-solution/src" +- source: "/google/cloud/baremetalsolution/.*-java/gapic-google-.*/src" + dest: "/owl-bot-staging/java-bare-metal-solution/baremetalsolution/google-cloud-bare-metal-solution/src" +- source: "/google/cloud/baremetalsolution/.*-java/samples/snippets/generated" + dest: "/owl-bot-staging/java-bare-metal-solution/baremetalsolution/samples/snippets/generated" + +api-name: baremetalsolution \ No newline at end of file diff --git a/sdk-platform-java/hermetic_build/library_generation/tests/resources/goldens/.OwlBot-hermetic-unversioned-proto-only-golden.yaml b/sdk-platform-java/hermetic_build/library_generation/tests/resources/goldens/.OwlBot-hermetic-unversioned-proto-only-golden.yaml new file mode 100644 index 000000000000..a93303a46d7d --- /dev/null +++ b/sdk-platform-java/hermetic_build/library_generation/tests/resources/goldens/.OwlBot-hermetic-unversioned-proto-only-golden.yaml @@ -0,0 +1,29 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +deep-remove-regex: +- "/java-bare-metal-solution/grpc-google-.*/src" +- "/java-bare-metal-solution/proto-google-.*/src" +- "/java-bare-metal-solution/google-.*/src" +- "/java-bare-metal-solution/samples/snippets/generated" + +deep-preserve-regex: +- "/java-bare-metal-solution/google-.*/src/test/java/com/google/cloud/.*/v.*/it/IT.*Test.java" + +deep-copy-regex: +- source: "/google/cloud/baremetalsolution/.*-java/proto-google-.*/src" + dest: "/owl-bot-staging/java-bare-metal-solution/baremetalsolution/google-cloud-bare-metal-solution/src" + +api-name: baremetalsolution \ No newline at end of file diff --git a/sdk-platform-java/hermetic_build/library_generation/tests/utilities_unit_tests.py b/sdk-platform-java/hermetic_build/library_generation/tests/utilities_unit_tests.py index 0708fdb4c556..72ccaa89c544 100644 --- a/sdk-platform-java/hermetic_build/library_generation/tests/utilities_unit_tests.py +++ b/sdk-platform-java/hermetic_build/library_generation/tests/utilities_unit_tests.py @@ -253,6 +253,38 @@ def test_generate_postprocessing_prerequisite_files_proto_only_repo_success(self ) self.__remove_postprocessing_prerequisite_files(path=library_path) + def test_generate_postprocessing_prerequisite_files_unversioned_proto_only_success(self): + self.maxDiff = None + library_path = self.__setup_postprocessing_prerequisite_files( + combination=3, + library_type="OTHER", + proto_path="google/cloud/baremetalsolution", + has_version=False, + proto_only=True, + ) + + file_comparator.compare_files( + f"{library_path}/.OwlBot-hermetic.yaml", + f"{library_path}/.OwlBot-hermetic-unversioned-proto-only-golden.yaml", + ) + self.__remove_postprocessing_prerequisite_files(path=library_path) + + def test_generate_postprocessing_prerequisite_files_unversioned_gapic_success(self): + self.maxDiff = None + library_path = self.__setup_postprocessing_prerequisite_files( + combination=2, + library_type="GAPIC_AUTO", + proto_path="google/cloud/baremetalsolution", + has_version=False, + proto_only=False, + ) + + file_comparator.compare_files( + f"{library_path}/.OwlBot-hermetic.yaml", + f"{library_path}/.OwlBot-hermetic-unversioned-gapic-golden.yaml", + ) + self.__remove_postprocessing_prerequisite_files(path=library_path) + def test_generate_postprocessing_prerequisite_files__custom_transport_set_in_config__success( self, ): @@ -326,6 +358,9 @@ def __setup_postprocessing_prerequisite_files( combination: int, library_type: str = "GAPIC_AUTO", library: LibraryConfig = library_1, + proto_path: str = "google/cloud/baremetalsolution/v2", + has_version: bool = True, + proto_only: bool = False, ) -> str: library_path = f"{resources_dir}/goldens" files = [ @@ -336,7 +371,6 @@ def __setup_postprocessing_prerequisite_files( cleanup(files) library.library_type = library_type config = self.__get_a_gen_config(combination, library_type=library_type) - proto_path = "google/cloud/baremetalsolution/v2" gapic_inputs = GapicInputs() # defaults to transport=grpc transport = library.get_transport(gapic_inputs) util.generate_postprocessing_prerequisite_files( @@ -345,6 +379,8 @@ def __setup_postprocessing_prerequisite_files( proto_path=proto_path, transport=transport, library_path=library_path, + has_version=has_version, + proto_only=proto_only, ) return library_path diff --git a/sdk-platform-java/hermetic_build/library_generation/utils/utilities.py b/sdk-platform-java/hermetic_build/library_generation/utils/utilities.py index 23fd92458919..9e9edc00d7b1 100755 --- a/sdk-platform-java/hermetic_build/library_generation/utils/utilities.py +++ b/sdk-platform-java/hermetic_build/library_generation/utils/utilities.py @@ -206,6 +206,7 @@ def generate_postprocessing_prerequisite_files( library_path: str, language: str = "java", has_version: bool = True, + proto_only: bool = False, ) -> None: """ Generates the postprocessing prerequisite files for a library. @@ -301,6 +302,7 @@ def generate_postprocessing_prerequisite_files( else f"{library_path}/.github/{owlbot_yaml_file}" ) if not os.path.exists(path_to_owlbot_yaml_file): + unversioned_dir = remove_version_from(proto_path).split("/")[-1] render( template_name="owlbot.yaml.monorepo.j2", output_name=path_to_owlbot_yaml_file, @@ -309,6 +311,8 @@ def generate_postprocessing_prerequisite_files( module_name=repo_metadata["repo_short"], api_shortname=library.api_shortname, has_version=has_version, + proto_only=proto_only, + unversioned_dir=unversioned_dir, ) # generate owlbot.py From 81f59457048c186cf3366a19d7c20e9fc97b146d Mon Sep 17 00:00:00 2001 From: Blake Li Date: Thu, 11 Jun 2026 05:22:22 +0000 Subject: [PATCH 2/9] feat(gax): implement Resumable Upload protocol --- .../gax-java/RESUMABLE_UPLOAD_DESIGN.md | 252 +++++++++ .../gax/httpjson/HttpJsonCallableFactory.java | 14 + .../httpjson/HttpJsonResumableUploadCall.java | 512 ++++++++++++++++++ .../HttpJsonResumableUploadCallable.java | 103 ++++ .../gax/httpjson/ManagedHttpJsonChannel.java | 4 + .../HttpJsonResumableUploadCallableTest.java | 345 ++++++++++++ .../api/gax/rpc/InputStreamProvider.java | 50 ++ .../api/gax/rpc/ResumableUploadCallable.java | 87 +++ .../rpc/ResumableUploadProgressListener.java | 55 ++ .../api/gax/rpc/ResumableUploadRequest.java | 114 ++++ .../api/gax/rpc/ResumableUploadStatus.java | 74 +++ 11 files changed, 1610 insertions(+) create mode 100644 sdk-platform-java/gax-java/RESUMABLE_UPLOAD_DESIGN.md create mode 100644 sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCall.java create mode 100644 sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java create mode 100644 sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallableTest.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/InputStreamProvider.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadProgressListener.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadRequest.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadStatus.java diff --git a/sdk-platform-java/gax-java/RESUMABLE_UPLOAD_DESIGN.md b/sdk-platform-java/gax-java/RESUMABLE_UPLOAD_DESIGN.md new file mode 100644 index 000000000000..01c93f501529 --- /dev/null +++ b/sdk-platform-java/gax-java/RESUMABLE_UPLOAD_DESIGN.md @@ -0,0 +1,252 @@ +# Resumable Upload Protocol for Java: Design Document + +This document proposes the architecture and design for integrating the Resumable Upload Protocol (RUP) into `gax-java` and the GAPIC code generator. + +--- + +## 1. Design Principles and Requirements + +1. **Veneer and GAPIC Aligned**: The solution must integrate cleanly into the existing `Callable` framework of `gax-java`. +2. **Stream-Safe Retries**: Java `InputStream` is forward-only. The design must provide a clean abstraction (`InputStreamProvider`) to recreate or seek the stream during recovery. +3. **Double-Loop Retry & Recovery**: Implements the precise Category 1 (transient) and Category 2 (state consistency) error classification with backoffs as described in the RUP specifications. +4. **Progress Reporting**: Supports asynchronous progress updates via a simple callback mechanism. +5. **No unnecessary chunking**: By default, uploads send the remaining bytes in one request (avoiding unnecessary memory buffering or chunk management). + +--- + +## 2. API Design (`gax` Changes) + +We introduce a new callable type and request/response wrappers in `com.google.api.gax.rpc`. + +### 2.1. `InputStreamProvider` + +To support seeking/rewinding, the stream source is wrapped in a functional interface that can supply fresh streams on retry: + +```java +package com.google.api.gax.rpc; + +import java.io.IOException; +import java.io.InputStream; + +/** Provides a fresh {@link InputStream} for retriable upload operations. */ +@FunctionalInterface +public interface InputStreamProvider { + /** Returns a new {@link InputStream}. */ + InputStream get() throws IOException; +} +``` + +### 2.2. Progress Listener and Status + +```java +package com.google.api.gax.rpc; + +/** Listener for tracking progress of a resumable upload. */ +public interface ResumableUploadProgressListener { + + enum State { + NOT_STARTED, + IN_PROGRESS, + RECOVERING, + COMPLETED, + FAILED, + CANCELLED + } + + void onProgress(ResumableUploadStatus status); +} + +/** Status details for progress updates. */ +public final class ResumableUploadStatus { + private final long bytesUploaded; + private final long totalBytes; + private final ResumableUploadProgressListener.State state; + + public ResumableUploadStatus(long bytesUploaded, long totalBytes, ResumableUploadProgressListener.State state) { + this.bytesUploaded = bytesUploaded; + this.totalBytes = totalBytes; + this.state = state; + } + + public long getBytesUploaded() { return bytesUploaded; } + public long getTotalBytes() { return totalBytes; } + public ResumableUploadProgressListener.State getState() { return state; } +} +``` + +### 2.3. Request Wrapper: `ResumableUploadRequest` + +```java +package com.google.api.gax.rpc; + +import com.google.common.base.Preconditions; + +public final class ResumableUploadRequest { + private final RequestT request; + private final InputStreamProvider streamProvider; + private final long totalBytes; // -1 if unknown + private final ResumableUploadProgressListener progressListener; + + private ResumableUploadRequest(Builder builder) { + this.request = Preconditions.checkNotNull(builder.request); + this.streamProvider = Preconditions.checkNotNull(builder.streamProvider); + this.totalBytes = builder.totalBytes; + this.progressListener = builder.progressListener; + } + + public RequestT getRequest() { return request; } + public InputStreamProvider getStreamProvider() { return streamProvider; } + public long getTotalBytes() { return totalBytes; } + public ResumableUploadProgressListener getProgressListener() { return progressListener; } + + public static Builder newBuilder() { + return new Builder<>(); + } + + public static class Builder { + private RequestT request; + private InputStreamProvider streamProvider; + private long totalBytes = -1; + private ResumableUploadProgressListener progressListener; + + public Builder setRequest(RequestT request) { + this.request = request; + return this; + } + public Builder setStreamProvider(InputStreamProvider streamProvider) { + this.streamProvider = streamProvider; + return this; + } + public Builder setTotalBytes(long totalBytes) { + this.totalBytes = totalBytes; + return this; + } + public Builder setProgressListener(ResumableUploadProgressListener progressListener) { + this.progressListener = progressListener; + return this; + } + public ResumableUploadRequest build() { + return new ResumableUploadRequest<>(this); + } + } +} +``` + +### 2.4. Callable Wrapper: `ResumableUploadCallable` + +```java +package com.google.api.gax.rpc; + +import com.google.api.core.ApiFuture; + +public abstract class ResumableUploadCallable { + + protected ResumableUploadCallable() {} + + public abstract ApiFuture futureCall( + ResumableUploadRequest request, ApiCallContext context); + + public ResponseT call(ResumableUploadRequest request, ApiCallContext context) { + return ApiExceptions.callAndTranslateCharSequenceException(futureCall(request, context)); + } + + public ResponseT call(ResumableUploadRequest request) { + return call(request, null); + } +} +``` + +--- + +## 3. Transport Implementation (`gax-httpjson`) + +The transport layer executes the actual HTTP protocol calls using the Google HTTP Client. + +We introduce `HttpJsonResumableUploadCall` to coordinate the resumable upload state machine. + +### 3.1. Error Categorization in Java + +```java +private enum ErrorCategory { + CATEGORY_1_TRANSIENT, // 429, 500, 502, 503, 504, TCP/Socket Timeout + CATEGORY_2_MISMATCH, // 400, 412, 416 + CATEGORY_3_FATAL // 401, 403, 404, etc. +} + +private ErrorCategory getErrorCategory(Throwable t) { + if (t instanceof HttpResponseException) { + int statusCode = ((HttpResponseException) t).getStatusCode(); + if (statusCode == 429 || statusCode >= 500) { + return ErrorCategory.CATEGORY_1_TRANSIENT; + } + if (statusCode == 400 || statusCode == 412 || statusCode == 416) { + return ErrorCategory.CATEGORY_2_MISMATCH; + } + } + if (t instanceof IOException) { + // Socket timeouts, connection drops + return ErrorCategory.CATEGORY_1_TRANSIENT; + } + return ErrorCategory.CATEGORY_3_FATAL; +} +``` + +### 3.2. Detailed Execution Flow (State Machine) + +The `HttpJsonResumableUploadCall` runs inside the user's thread (or client executor pool for future execution) and implements the following flow: + +```mermaid +stateDiagram-v2 + [*] --> StartSession + StartSession --> UploadLoop : Success (200 OK + active) + StartSession --> StartSession : Cat 1 Transient (Backoff) + StartSession --> [*] : Cat 3 Fatal / Deadline Exceeded + + state UploadLoop { + [*] --> OpenStream + OpenStream --> SkipToOffset + SkipToOffset --> TransmitChunk + TransmitChunk --> [*] : Success (final) + TransmitChunk --> QueryState : Cat 2 Mismatch / Socket Drop + TransmitChunk --> TransmitChunk : Cat 1 Transient (Backoff) + } + + UploadLoop --> [*] : Success + UploadLoop --> [*] : Cat 3 Fatal / Deadline Exceeded + + state QueryState { + [*] --> SendQuery + SendQuery --> ResumeUpload : Success (active + new offset) + SendQuery --> [*] : Success (final) + SendQuery --> SendQuery : Cat 1 Transient (Backoff) + SendQuery --> [*] : Cat 3 Fatal + } + + QueryState --> UploadLoop : Resume +``` + +#### Step 1: Start Session +- Build standard headers + merge user-provided metadata. +- Pre-emptively prefix headers that affect physical bodies (`Content-Length`, `Content-Type`, etc.) with `X-Goog-Upload-Header-`. +- Set `X-Goog-Upload-Protocol: resumable` and `X-Goog-Upload-Command: start`. +- Execute POST with the request JSON body. +- Extract `X-Goog-Upload-URL` header value to obtain the `uploadUrl`. + +#### Step 2: Upload Loop (Transmit) +- Check absolute global deadline. +- Call `streamProvider.get()`. +- Skip/seek to current `offset`. +- Set `X-Goog-Upload-Command: upload, finalize` and `X-Goog-Upload-Offset: offset`. +- Stream payload using a chunked output stream, updating the progress listener during writes. +- If response is `final` with `2xx`: parse response and return. +- If exception occurs: Categorize exception. If Category 2 (Mismatch) or connection drop, transition to **Query State**. + +#### Step 3: Query State +- Execute POST to `uploadUrl` with `X-Goog-Upload-Command: query`. +- If response is `active`: + - Extract `X-Goog-Upload-Size-Received` -> `newOffset`. + - If `newOffset == offset`: apply backoff (to avoid spamming server). + - Update `offset = newOffset` and transition back to **Upload Loop**. +- If response is `final`: return response. +- If Category 1 (Transient) error: retry query with backoff. +- If Category 3 (Fatal) error: fail immediately. diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java index 447fc46dd9e0..72144f7a2b6c 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java @@ -41,6 +41,7 @@ import com.google.api.gax.rpc.PagedCallSettings; import com.google.api.gax.rpc.ServerStreamingCallSettings; import com.google.api.gax.rpc.ServerStreamingCallable; +import com.google.api.gax.rpc.ResumableUploadCallable; import com.google.api.gax.rpc.UnaryCallSettings; import com.google.api.gax.rpc.UnaryCallable; import com.google.api.gax.tracing.ApiTracerContext; @@ -220,6 +221,19 @@ ServerStreamingCallable createServerStreamingCallable( return callable.withDefaultCallContext(clientContext.getDefaultCallContext()); } + /** + * Create a resumable upload callable object. Designed for use by generated code. + * + * @param httpJsonCallSettings the http/json call settings + * @param clientContext {@link ClientContext} to use to connect to the service. + * @return {@link ResumableUploadCallable} callable object. + */ + public static ResumableUploadCallable createResumableUploadCallable( + HttpJsonCallSettings httpJsonCallSettings, + ClientContext clientContext) { + return new HttpJsonResumableUploadCallable<>(httpJsonCallSettings, clientContext); + } + static ApiTracerContext getApiTracerContext(@Nonnull ApiMethodDescriptor methodDescriptor) { return ApiTracerContext.newBuilder() .setFullMethodName(methodDescriptor.getFullMethodName()) diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCall.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCall.java new file mode 100644 index 000000000000..9175455b66ef --- /dev/null +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCall.java @@ -0,0 +1,512 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.httpjson; + +import com.google.api.client.http.EmptyContent; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpContent; +import com.google.api.client.http.HttpMediaType; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpResponseException; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.json.JsonHttpContent; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.client.util.GenericData; +import com.google.api.core.ApiFuture; +import com.google.api.core.SettableApiFuture; +import com.google.api.gax.rpc.ApiCallContext; +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.DeadlineExceededException; +import com.google.api.gax.rpc.InputStreamProvider; +import com.google.api.gax.rpc.ResumableUploadProgressListener; +import com.google.api.gax.rpc.ResumableUploadRequest; +import com.google.api.gax.rpc.ResumableUploadStatus; +import com.google.auth.Credentials; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** Encapsulates the execution logic and state machine of the Resumable Upload protocol. */ +final class HttpJsonResumableUploadCall { + + private static final Logger logger = Logger.getLogger(HttpJsonResumableUploadCall.class.getName()); + private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); + + private final ApiMethodDescriptor methodDescriptor; + private final ResumableUploadRequest uploadRequest; + private final HttpTransport httpTransport; + private final HttpJsonMetadata requestHeaders; + private final HttpJsonCallOptions callOptions; + private final String endpoint; + private final Executor executor; + + private enum ErrorCategory { + CATEGORY_1_TRANSIENT, + CATEGORY_2_MISMATCH, + CATEGORY_3_FATAL + } + + HttpJsonResumableUploadCall( + ApiMethodDescriptor methodDescriptor, + ResumableUploadRequest uploadRequest, + HttpTransport httpTransport, + HttpJsonMetadata requestHeaders, + HttpJsonCallOptions callOptions, + String endpoint, + Executor executor) { + this.methodDescriptor = Preconditions.checkNotNull(methodDescriptor); + this.uploadRequest = Preconditions.checkNotNull(uploadRequest); + this.httpTransport = Preconditions.checkNotNull(httpTransport); + this.requestHeaders = Preconditions.checkNotNull(requestHeaders); + this.callOptions = Preconditions.checkNotNull(callOptions); + this.endpoint = Preconditions.checkNotNull(endpoint); + this.executor = Preconditions.checkNotNull(executor); + } + + ApiFuture execute() { + SettableApiFuture future = SettableApiFuture.create(); + executor.execute( + () -> { + try { + ResponseT result = runStateMachine(); + future.set(result); + } catch (Throwable t) { + future.setException(t); + } + }); + return future; + } + + private ResponseT runStateMachine() throws Exception { + try { + return runStateMachineInternal(); + } catch (HttpResponseException e) { + throw translateException(e); + } + } + + private ResponseT runStateMachineInternal() throws Exception { + Instant deadline = calculateDeadline(); + + // Phase 1: Start Session (with retry) + String uploadUrl = null; + int attempt = 0; + while (true) { + try { + checkDeadline(deadline); + uploadUrl = startSession(deadline); + break; // Success + } catch (Exception e) { + checkDeadline(deadline); + ErrorCategory category = getErrorCategory(e); + if (category == ErrorCategory.CATEGORY_1_TRANSIENT) { + attempt++; + long delayMs = calculateBackoff(attempt); + logger.log(Level.WARNING, "Transient error starting session. Backing off for " + delayMs + " ms", e); + sleep(delayMs); + } else { + throw e; // Fatal/Mismatch, bubble up + } + } + } + logger.log(Level.FINE, "Resumable session started. Upload URL: {0}", uploadUrl); + + long offset = 0; + attempt = 0; + long previousOffset = -1; + + // Phase 2 & 3 Loop: Transmit & Query Recovery + while (true) { + try { + checkDeadline(deadline); + return transmitRemaining(uploadUrl, offset, deadline); + } catch (Exception e) { + checkDeadline(deadline); + ErrorCategory category = getErrorCategory(e); + + if (category == ErrorCategory.CATEGORY_2_MISMATCH) { + logger.log(Level.WARNING, "State mismatch detected. Triggering recovery...", e); + updateProgress(offset, ResumableUploadProgressListener.State.RECOVERING); + + offset = recoverOffset(uploadUrl, deadline); + logger.log(Level.INFO, "Recovery completed. Server received bytes: {0}", offset); + + if (offset == previousOffset) { + // No progress was made since last recovery. Wait with backoff to prevent slamming server. + attempt++; + long delayMs = calculateBackoff(attempt); + sleep(delayMs); + } else { + attempt = 0; // Reset attempts on progress + previousOffset = offset; + } + } else if (category == ErrorCategory.CATEGORY_1_TRANSIENT) { + attempt++; + long delayMs = calculateBackoff(attempt); + logger.log(Level.WARNING, "Transient error. Backing off for {0} ms (attempt {1})", new Object[]{delayMs, attempt}); + sleep(delayMs); + } else { + updateProgress(offset, ResumableUploadProgressListener.State.FAILED); + throw e; // Fatal, bubble up + } + } + } + } + + private String startSession(Instant deadline) throws Exception { + HttpRequestFactory requestFactory = getRequestFactory(); + HttpRequestFormatter requestFormatter = methodDescriptor.getRequestFormatter(); + + GenericData tokenRequest = new GenericData(); + String requestBody = requestFormatter.getRequestBody(uploadRequest.getRequest()); + HttpContent initialContent; + + if (!Strings.isNullOrEmpty(requestBody)) { + JSON_FACTORY.createJsonParser(requestBody).parse(tokenRequest); + initialContent = new JsonHttpContent(JSON_FACTORY, tokenRequest) + .setMediaType(new HttpMediaType("application/json; charset=utf-8")); + } else { + initialContent = new EmptyContent(); + } + + // Resumable upload specific path modifier + String path = "/resumable/upload" + requestFormatter.getPath(uploadRequest.getRequest()); + GenericUrl url = new GenericUrl(normalizeEndpoint(endpoint) + path); + + // Populate query parameters + Map> queryParams = requestFormatter.getQueryParamNames(uploadRequest.getRequest()); + for (Map.Entry> queryParam : queryParams.entrySet()) { + if (queryParam.getValue() != null) { + url.set(queryParam.getKey(), queryParam.getValue()); + } + } + + HttpRequest httpRequest = requestFactory.buildPostRequest(url, initialContent); + configureTimeouts(httpRequest, deadline); + + // Set standard headers + merge custom metadata + for (Map.Entry entry : requestHeaders.getHeaders().entrySet()) { + String key = entry.getKey(); + String value = (String) entry.getValue(); + + // Prefix metadata headers to prevent collision with physical request metadata + if (isMetadataHeaderDenylisted(key)) { + httpRequest.getHeaders().set("X-Goog-Upload-Header-" + key, value); + } else { + httpRequest.getHeaders().set(key, value); + } + } + + httpRequest.getHeaders().set("X-Goog-Upload-Protocol", "resumable"); + httpRequest.getHeaders().set("X-Goog-Upload-Command", "start"); + + updateProgress(0, ResumableUploadProgressListener.State.NOT_STARTED); + + HttpResponse response = null; + try { + response = httpRequest.execute(); + String status = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); + if (!"active".equalsIgnoreCase(status)) { + throw new HttpResponseException.Builder(response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) + .setMessage("Failed to initiate resumable session: Status is not active") + .build(); + } + String uploadUrl = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-URL"); + if (Strings.isNullOrEmpty(uploadUrl)) { + throw new HttpResponseException.Builder(response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) + .setMessage("Failed to initiate resumable session: Missing upload URL") + .build(); + } + return uploadUrl; + } finally { + if (response != null) { + response.disconnect(); + } + } + } + + private ResponseT transmitRemaining(String uploadUrl, long offset, Instant deadline) throws Exception { + HttpRequestFactory requestFactory = getRequestFactory(); + + InputStream stream = uploadRequest.getStreamProvider().get(); + if (offset > 0) { + long skipped = stream.skip(offset); + if (skipped < offset) { + throw new IOException("Failed to skip stream bytes to offset: " + offset); + } + } + + // Wrap the stream in custom HttpContent that updates the progress listener + HttpContent payload = new ProgressReportingHttpContent( + stream, uploadRequest.getTotalBytes(), offset, uploadRequest.getProgressListener()); + + GenericUrl url = new GenericUrl(uploadUrl); + HttpRequest httpRequest = requestFactory.buildPostRequest(url, payload); + configureTimeouts(httpRequest, deadline); + + httpRequest.getHeaders().set("X-Goog-Upload-Command", "upload, finalize"); + httpRequest.getHeaders().set("X-Goog-Upload-Offset", String.valueOf(offset)); + + updateProgress(offset, ResumableUploadProgressListener.State.IN_PROGRESS); + + HttpResponse response = null; + try { + response = httpRequest.execute(); + String status = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); + + if (!"final".equalsIgnoreCase(status)) { + throw new HttpResponseException.Builder(response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) + .setMessage("Resumable upload failed: Status is not final") + .build(); + } + + InputStreamReader reader = new InputStreamReader(response.getContent(), StandardCharsets.UTF_8); + ResponseT parsedResponse = methodDescriptor.getResponseParser().parse(reader, callOptions.getTypeRegistry()); + + updateProgress(uploadRequest.getTotalBytes() > 0 ? uploadRequest.getTotalBytes() : offset, + ResumableUploadProgressListener.State.COMPLETED); + + return parsedResponse; + } finally { + if (response != null) { + response.disconnect(); + } + } + } + + private long recoverOffset(String uploadUrl, Instant deadline) throws Exception { + HttpRequestFactory requestFactory = getRequestFactory(); + GenericUrl url = new GenericUrl(uploadUrl); + + HttpRequest httpRequest = requestFactory.buildPostRequest(url, new EmptyContent()); + configureTimeouts(httpRequest, deadline); + + httpRequest.getHeaders().set("X-Goog-Upload-Command", "query"); + + HttpResponse response = null; + try { + response = httpRequest.execute(); + String status = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); + + if ("final".equalsIgnoreCase(status)) { + // Already finalized, query command behaves like final + throw new HttpResponseException.Builder(response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) + .setMessage("Query returned final status. Re-executing state machine...") + .build(); + } + if (!"active".equalsIgnoreCase(status)) { + throw new HttpResponseException.Builder(response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) + .setMessage("Query failed: Status is not active") + .build(); + } + + String receivedSizeStr = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Size-Received"); + if (Strings.isNullOrEmpty(receivedSizeStr)) { + throw new HttpResponseException.Builder(response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) + .setMessage("Query failed: Missing size received header") + .build(); + } + + return Long.parseLong(receivedSizeStr); + } finally { + if (response != null) { + response.disconnect(); + } + } + } + + private void configureTimeouts(HttpRequest request, Instant deadline) { + long remainingMs = Duration.between(Instant.now(), deadline).toMillis(); + if (remainingMs <= 0) { + remainingMs = 1; // force timeout + } + request.setConnectTimeout((int) remainingMs); + request.setReadTimeout((int) remainingMs); + } + + private Instant calculateDeadline() { + Duration timeout = callOptions.getTimeoutDuration(); + if (timeout != null && !timeout.isZero() && !timeout.isNegative()) { + return Instant.now().plus(timeout); + } + return Instant.now().plus(Duration.ofMinutes(10)); // Default deadline of 10 mins + } + + private void checkDeadline(Instant deadline) throws DeadlineExceededException { + if (Instant.now().isAfter(deadline)) { + throw (DeadlineExceededException) com.google.api.gax.rpc.ApiExceptionFactory.createException( + "Resumable upload session exceeded the configured deadline.", + null, + HttpJsonStatusCode.of(com.google.api.gax.rpc.StatusCode.Code.DEADLINE_EXCEEDED), + false); + } + } + + private ErrorCategory getErrorCategory(Throwable t) { + if (t instanceof HttpResponseException) { + int statusCode = ((HttpResponseException) t).getStatusCode(); + if (statusCode == 429 || statusCode >= 500) { + return ErrorCategory.CATEGORY_1_TRANSIENT; + } + if (statusCode == 400 || statusCode == 412 || statusCode == 416) { + return ErrorCategory.CATEGORY_2_MISMATCH; + } + return ErrorCategory.CATEGORY_3_FATAL; + } + if (t instanceof IOException) { + return ErrorCategory.CATEGORY_1_TRANSIENT; + } + return ErrorCategory.CATEGORY_3_FATAL; + } + + private long calculateBackoff(int attempt) { + long baseDelay = 500; // 500ms + long maxDelay = 30000; // 30s + long delay = (long) (baseDelay * Math.pow(2, attempt)); + return Math.min(delay, maxDelay); + } + + private void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void updateProgress(long bytesUploaded, ResumableUploadProgressListener.State state) { + ResumableUploadProgressListener progressListener = uploadRequest.getProgressListener(); + if (progressListener != null) { + progressListener.onProgress(new ResumableUploadStatus(bytesUploaded, uploadRequest.getTotalBytes(), state)); + } + } + + private HttpRequestFactory getRequestFactory() { + Credentials credentials = callOptions.getCredentials(); + if (credentials != null) { + return httpTransport.createRequestFactory(new HttpCredentialsAdapter(credentials)); + } + return httpTransport.createRequestFactory(); + } + + private boolean isMetadataHeaderDenylisted(String key) { + // Standard body-related headers that must be prefixed when uploading session metadata + return "Content-Length".equalsIgnoreCase(key) + || "Content-Type".equalsIgnoreCase(key) + || "Content-Encoding".equalsIgnoreCase(key) + || "Transfer-Encoding".equalsIgnoreCase(key); + } + + private String normalizeEndpoint(String rawEndpoint) { + String normalized = rawEndpoint; + if (!normalized.contains("://")) { + normalized = "https://" + normalized; + } + if (normalized.charAt(normalized.length() - 1) != '/') { + normalized += '/'; + } + return normalized; + } + + private Exception translateException(HttpResponseException e) { + // Return standard GAX Exception or original depending on status code + HttpJsonApiExceptionFactory factory = new HttpJsonApiExceptionFactory(java.util.Collections.emptySet()); + return factory.create(e); + } + + /** Custom HttpContent class that streams data and reports progress callbacks. */ + private static final class ProgressReportingHttpContent implements HttpContent { + private final InputStream stream; + private final long totalLength; + private final long initialOffset; + private final ResumableUploadProgressListener progressListener; + + ProgressReportingHttpContent( + InputStream stream, + long totalLength, + long initialOffset, + ResumableUploadProgressListener progressListener) { + this.stream = stream; + this.totalLength = totalLength; + this.initialOffset = initialOffset; + this.progressListener = progressListener; + } + + @Override + public long getLength() throws IOException { + // Return -1 to force chunked transfer encoding as the length of the remaining stream + // might be different from physical Content-Length, or if totalLength is unknown. + return -1; + } + + @Override + public String getType() { + return "application/octet-stream"; + } + + @Override + public boolean retrySupported() { + // Handled by our state machine recreating the stream via streamProvider + return false; + } + + @Override + public void writeTo(OutputStream out) throws IOException { + byte[] buffer = new byte[65536]; // 64KB buffer + int len; + long bytesUploaded = initialOffset; + while ((len = stream.read(buffer)) != -1) { + out.write(buffer, 0, len); + out.flush(); + bytesUploaded += len; + if (progressListener != null) { + progressListener.onProgress( + new ResumableUploadStatus( + bytesUploaded, totalLength, ResumableUploadProgressListener.State.IN_PROGRESS)); + } + } + } + } +} diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java new file mode 100644 index 000000000000..19491a193683 --- /dev/null +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.httpjson; + +import com.google.api.client.http.HttpTransport; +import com.google.api.core.ApiFuture; +import com.google.api.core.BetaApi; +import com.google.api.gax.rpc.ApiCallContext; +import com.google.api.gax.rpc.ClientContext; +import com.google.api.gax.rpc.ResumableUploadCallable; +import com.google.api.gax.rpc.ResumableUploadRequest; +import com.google.common.base.Preconditions; +import java.util.concurrent.Executor; + +/** + * A {@link ResumableUploadCallable} that uses HTTP/JSON transport. + * + * @param request type + * @param response type + */ +@BetaApi +public final class HttpJsonResumableUploadCallable + extends ResumableUploadCallable { + + private final HttpJsonCallSettings httpJsonCallSettings; + private final ClientContext clientContext; + + public HttpJsonResumableUploadCallable( + HttpJsonCallSettings httpJsonCallSettings, + ClientContext clientContext) { + this.httpJsonCallSettings = Preconditions.checkNotNull(httpJsonCallSettings); + this.clientContext = Preconditions.checkNotNull(clientContext); + } + + @Override + public ApiFuture futureCall( + ResumableUploadRequest request, ApiCallContext context) { + Preconditions.checkNotNull(request); + + // Resolve call context + HttpJsonCallContext httpJsonContext = HttpJsonCallContext.createDefault(); + if (context != null) { + httpJsonContext = httpJsonContext.nullToSelf(context); + } + + // Resolve channel and endpoint + HttpJsonTransportChannel transportChannel = (HttpJsonTransportChannel) clientContext.getTransportChannel(); + ManagedHttpJsonChannel channel = transportChannel.getManagedChannel(); + String endpoint = channel.getEndpoint(); + HttpTransport httpTransport = channel.getHttpTransport(); + + // Resolve credentials and executor + HttpJsonCallOptions callOptions = httpJsonContext.getCallOptions(); + if (callOptions.getCredentials() == null && clientContext.getCredentials() != null) { + callOptions = callOptions.toBuilder().setCredentials(clientContext.getCredentials()).build(); + } + + Executor executor = clientContext.getExecutor(); + + // Gather request headers + HttpJsonMetadata requestHeaders = HttpJsonMetadata.newBuilder().build() + .withHeaders(httpJsonContext.getExtraHeaders()); + + HttpJsonResumableUploadCall call = new HttpJsonResumableUploadCall<>( + httpJsonCallSettings.getMethodDescriptor(), + request, + httpTransport, + requestHeaders, + callOptions, + endpoint, + executor + ); + + return call.execute(); + } +} diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java index bd3bed855608..69dc242d6e8b 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java @@ -91,6 +91,10 @@ Executor getExecutor() { return executor; } + HttpTransport getHttpTransport() { + return httpTransport; + } + @Override public synchronized void shutdown() { // Calling shutdown/ shutdownNow() twice should no-op diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallableTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallableTest.java new file mode 100644 index 000000000000..5c15934899fd --- /dev/null +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallableTest.java @@ -0,0 +1,345 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.httpjson; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.api.client.http.LowLevelHttpRequest; +import com.google.api.client.http.LowLevelHttpResponse; +import com.google.api.client.testing.http.MockHttpTransport; +import com.google.api.client.testing.http.MockLowLevelHttpRequest; +import com.google.api.client.testing.http.MockLowLevelHttpResponse; +import com.google.api.gax.rpc.ApiException; +import com.google.api.gax.rpc.ClientContext; +import com.google.api.gax.rpc.InputStreamProvider; +import com.google.api.gax.rpc.ResumableUploadProgressListener; +import com.google.api.gax.rpc.ResumableUploadRequest; +import com.google.api.gax.rpc.ResumableUploadStatus; +import com.google.common.collect.ImmutableMap; +import com.google.protobuf.TypeRegistry; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class HttpJsonResumableUploadCallableTest { + + @Mock private ApiMethodDescriptor methodDescriptor; + @Mock private HttpRequestFormatter requestFormatter; + @Mock private HttpResponseParser responseParser; + @Mock private ClientContext clientContext; + @Mock private HttpJsonTransportChannel transportChannel; + @Mock private ManagedHttpJsonChannel managedChannel; + + private java.util.concurrent.ScheduledExecutorService executor; + + @BeforeEach + void setUp() { + executor = Executors.newSingleThreadScheduledExecutor(); + Mockito.lenient().when(clientContext.getExecutor()).thenReturn(executor); + Mockito.lenient().when(clientContext.getTransportChannel()).thenReturn(transportChannel); + Mockito.lenient().when(transportChannel.getManagedChannel()).thenReturn(managedChannel); + Mockito.lenient().when(managedChannel.getEndpoint()).thenReturn("localhost"); + + // Wire formatter and parser mocking + Mockito.lenient().when(methodDescriptor.getRequestFormatter()).thenReturn(requestFormatter); + Mockito.lenient().when(methodDescriptor.getResponseParser()).thenReturn(responseParser); + Mockito.lenient().when(requestFormatter.getPath(Mockito.anyString())).thenReturn("/upload/resource"); + Mockito.lenient().when(requestFormatter.getRequestBody(Mockito.anyString())).thenReturn("{\"metadata\":\"value\"}"); + Mockito.lenient().when(requestFormatter.getQueryParamNames(Mockito.anyString())).thenReturn(Collections.emptyMap()); + } + + @AfterEach + void tearDown() { + executor.shutdown(); + } + + @Test + void happyPathUpload() throws Exception { + byte[] data = "Hello, World! Resumable upload".getBytes(); + InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); + + // Sequence of mock HTTP responses + Queue mockResponses = new ConcurrentLinkedQueue<>(); + + // 1. Session start response + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active") + .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); + + // 2. Finalize upload response + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "final") + .setContent("{\"response\":\"success\"}")); + + MockHttpTransport transport = new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + MockLowLevelHttpResponse response = mockResponses.poll(); + if (response == null) { + throw new IOException("Unexpected out-of-bounds mock request: " + url); + } + return response; + } + }; + } + }; + + Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); + Mockito.when(responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) + .thenReturn("SUCCESS_RESPONSE"); + + List progressHistory = new ArrayList<>(); + ResumableUploadProgressListener listener = progressHistory::add; + + ResumableUploadRequest request = ResumableUploadRequest.newBuilder() + .setRequest("META") + .setStreamProvider(streamProvider) + .setTotalBytes(data.length) + .setProgressListener(listener) + .build(); + + HttpJsonCallSettings settings = HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(methodDescriptor) + .build(); + + HttpJsonResumableUploadCallable callable = new HttpJsonResumableUploadCallable<>(settings, clientContext); + + String response = callable.call(request); + + assertThat(response).isEqualTo("SUCCESS_RESPONSE"); + assertThat(progressHistory).isNotEmpty(); + + // Verify progress tracking states + assertThat(progressHistory.get(0).getState()).isEqualTo(ResumableUploadProgressListener.State.NOT_STARTED); + + ResumableUploadStatus lastStatus = progressHistory.get(progressHistory.size() - 1); + assertThat(lastStatus.getState()).isEqualTo(ResumableUploadProgressListener.State.COMPLETED); + assertThat(lastStatus.getBytesUploaded()).isEqualTo(data.length); + } + + @Test + void retryOnTransientStartError() throws Exception { + byte[] data = "Short stream".getBytes(); + InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); + + Queue mockResponses = new ConcurrentLinkedQueue<>(); + + // 1. Session start transient failure (503) + mockResponses.add(new MockLowLevelHttpResponse().setStatusCode(503).setReasonPhrase("Service Unavailable").setContent("")); + + // 2. Retry start session success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active") + .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); + + // 3. Finalize upload success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "final") + .setContent("{\"response\":\"ok\"}")); + + MockHttpTransport transport = new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + return mockResponses.poll(); + } + }; + } + }; + + Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); + Mockito.when(responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) + .thenReturn("OK_REP"); + + ResumableUploadRequest request = ResumableUploadRequest.newBuilder() + .setRequest("META") + .setStreamProvider(streamProvider) + .build(); + + HttpJsonCallSettings settings = HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(methodDescriptor) + .build(); + + HttpJsonResumableUploadCallable callable = new HttpJsonResumableUploadCallable<>(settings, clientContext); + + String response = callable.call(request); + assertThat(response).isEqualTo("OK_REP"); + } + + @Test + void stateMismatchRecoveryAndResume() throws Exception { + byte[] data = "First segment of data... Second segment of data".getBytes(); + InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); + + Queue mockResponses = new ConcurrentLinkedQueue<>(); + + // 1. Session start success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active") + .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); + + // 2. Transmit failure with Category 2 (400 Bad Request) + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(400) + .setReasonPhrase("Bad Request - offset mismatch") + .setContent("")); + + // 3. Query offset command (server has received 24 bytes) + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active") + .addHeader("X-Goog-Upload-Size-Received", "24")); + + // 4. Upload rest starting from offset 24 success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "final") + .setContent("{\"response\":\"restored\"}")); + + MockHttpTransport transport = new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + return mockResponses.poll(); + } + }; + } + }; + + Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); + Mockito.when(responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) + .thenReturn("RECOVERY_REP"); + + List progressHistory = new ArrayList<>(); + ResumableUploadProgressListener listener = progressHistory::add; + + ResumableUploadRequest request = ResumableUploadRequest.newBuilder() + .setRequest("META") + .setStreamProvider(streamProvider) + .setTotalBytes(data.length) + .setProgressListener(listener) + .build(); + + HttpJsonCallSettings settings = HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(methodDescriptor) + .build(); + + HttpJsonResumableUploadCallable callable = new HttpJsonResumableUploadCallable<>(settings, clientContext); + + String response = callable.call(request); + + assertThat(response).isEqualTo("RECOVERY_REP"); + + // Verify recovery state was logged + boolean hasRecoveringState = false; + for (ResumableUploadStatus status : progressHistory) { + if (status.getState() == ResumableUploadProgressListener.State.RECOVERING) { + hasRecoveringState = true; + break; + } + } + assertThat(hasRecoveringState).isTrue(); + } + + @Test + void fatalErrorFailsImmediately() { + byte[] data = "test data".getBytes(); + InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); + + Queue mockResponses = new ConcurrentLinkedQueue<>(); + + // 1. Session start returns 403 Forbidden (Category 3 Fatal) + mockResponses.add(new MockLowLevelHttpResponse().setStatusCode(403).setReasonPhrase("Forbidden").setContent("")); + + MockHttpTransport transport = new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + return mockResponses.poll(); + } + }; + } + }; + + Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); + + ResumableUploadRequest request = ResumableUploadRequest.newBuilder() + .setRequest("META") + .setStreamProvider(streamProvider) + .build(); + + HttpJsonCallSettings settings = HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(methodDescriptor) + .build(); + + HttpJsonResumableUploadCallable callable = new HttpJsonResumableUploadCallable<>(settings, clientContext); + + assertThrows(ApiException.class, () -> callable.call(request)); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/InputStreamProvider.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/InputStreamProvider.java new file mode 100644 index 000000000000..9d65114c5dac --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/InputStreamProvider.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.BetaApi; +import java.io.IOException; +import java.io.InputStream; + +/** + * Provides a fresh {@link InputStream} for retriable upload operations. + * This is used to seek or rewind a stream when recovering from errors. + */ +@BetaApi +@FunctionalInterface +public interface InputStreamProvider { + /** + * Returns a new {@link InputStream}. + * + * @return a new input stream + * @throws IOException if the stream cannot be created + */ + InputStream get() throws IOException; +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java new file mode 100644 index 000000000000..5c3480fb7744 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.ApiFuture; +import com.google.api.core.BetaApi; + +/** + * A ResumableUploadCallable is an API-transport-independent wrapper for the Resumable Upload + * protocol. + * + * @param request type + * @param response type + */ +@BetaApi +public abstract class ResumableUploadCallable { + + protected ResumableUploadCallable() {} + + /** + * Performs the resumable upload asynchronously. + * + * @param request the upload request options + * @param context the context of the call + * @return future for the response + */ + public abstract ApiFuture futureCall( + ResumableUploadRequest request, ApiCallContext context); + + /** + * Performs the resumable upload asynchronously. + * + * @param request the upload request options + * @return future for the response + */ + public ApiFuture futureCall(ResumableUploadRequest request) { + return futureCall(request, null); + } + + /** + * Performs the resumable upload synchronously. + * + * @param request the upload request options + * @param context the context of the call + * @return the RPC response + */ + public ResponseT call(ResumableUploadRequest request, ApiCallContext context) { + return ApiExceptions.callAndTranslateApiException(futureCall(request, context)); + } + + /** + * Performs the resumable upload synchronously. + * + * @param request the upload request options + * @return the RPC response + */ + public ResponseT call(ResumableUploadRequest request) { + return call(request, null); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadProgressListener.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadProgressListener.java new file mode 100644 index 000000000000..f843f209f396 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadProgressListener.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.BetaApi; + +/** Listener for tracking the progress of a resumable upload session. */ +@BetaApi +@FunctionalInterface +public interface ResumableUploadProgressListener { + + /** The state of the upload session. */ + enum State { + NOT_STARTED, + IN_PROGRESS, + RECOVERING, + COMPLETED, + FAILED, + CANCELLED + } + + /** + * Invoked when upload progress or state changes. + * + * @param status the current status of the upload + */ + void onProgress(ResumableUploadStatus status); +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadRequest.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadRequest.java new file mode 100644 index 000000000000..a4a02781f112 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadRequest.java @@ -0,0 +1,114 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.BetaApi; +import com.google.common.base.Preconditions; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Parameter class for a resumable upload call. Contains the request metadata, the stream payload, + * and the progress listener. + * + * @param the type of request message that contains standard metadata + */ +@BetaApi +public final class ResumableUploadRequest { + private final RequestT request; + private final InputStreamProvider streamProvider; + private final long totalBytes; + private final ResumableUploadProgressListener progressListener; + + private ResumableUploadRequest(Builder builder) { + this.request = Preconditions.checkNotNull(builder.request); + this.streamProvider = Preconditions.checkNotNull(builder.streamProvider); + this.totalBytes = builder.totalBytes; + this.progressListener = builder.progressListener; + } + + /** Returns the metadata request message. */ + @Nonnull + public RequestT getRequest() { + return request; + } + + /** Returns the stream provider. */ + @Nonnull + public InputStreamProvider getStreamProvider() { + return streamProvider; + } + + /** Returns the total size of the stream, or -1 if unknown. */ + public long getTotalBytes() { + return totalBytes; + } + + /** Returns the progress listener, or null if not set. */ + @Nullable + public ResumableUploadProgressListener getProgressListener() { + return progressListener; + } + + public static Builder newBuilder() { + return new Builder<>(); + } + + public static class Builder { + private RequestT request; + private InputStreamProvider streamProvider; + private long totalBytes = -1; + private ResumableUploadProgressListener progressListener; + + public Builder setRequest(RequestT request) { + this.request = request; + return this; + } + + public Builder setStreamProvider(InputStreamProvider streamProvider) { + this.streamProvider = streamProvider; + return this; + } + + public Builder setTotalBytes(long totalBytes) { + this.totalBytes = totalBytes; + return this; + } + + public Builder setProgressListener(ResumableUploadProgressListener progressListener) { + this.progressListener = progressListener; + return this; + } + + public ResumableUploadRequest build() { + return new ResumableUploadRequest<>(this); + } + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadStatus.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadStatus.java new file mode 100644 index 000000000000..e5c73670d422 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadStatus.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.BetaApi; + +/** Status snapshot of an ongoing resumable upload. */ +@BetaApi +public final class ResumableUploadStatus { + private final long bytesUploaded; + private final long totalBytes; + private final ResumableUploadProgressListener.State state; + + public ResumableUploadStatus( + long bytesUploaded, long totalBytes, ResumableUploadProgressListener.State state) { + this.bytesUploaded = bytesUploaded; + this.totalBytes = totalBytes; + this.state = state; + } + + /** Returns the number of bytes successfully uploaded to the server so far. */ + public long getBytesUploaded() { + return bytesUploaded; + } + + /** Returns the total size of the stream in bytes, or -1 if unknown. */ + public long getTotalBytes() { + return totalBytes; + } + + /** Returns the current state of the upload session. */ + public ResumableUploadProgressListener.State getState() { + return state; + } + + @Override + public String toString() { + return "ResumableUploadStatus{" + + "bytesUploaded=" + + bytesUploaded + + ", totalBytes=" + + totalBytes + + ", state=" + + state + + '}'; + } +} From 4ae9e0c960bca3ebe1064d5cace1ff7b1021866b Mon Sep 17 00:00:00 2001 From: Blake Li Date: Thu, 23 Jul 2026 04:19:32 +0000 Subject: [PATCH 3/9] feat(scotty): checkpoint local resumable upload progress --- .../gax-java/RESUMABLE_UPLOAD_DESIGN.md | 27 +- .../gax/httpjson/HttpJsonCallableFactory.java | 9 +- .../httpjson/HttpJsonResumableUploadCall.java | 504 +++++++++---- .../HttpJsonResumableUploadCallable.java | 32 +- .../HttpJsonResumableUploadCallableTest.java | 702 +++++++++++++++--- .../api/gax/rpc/ResumableUploadRequest.java | 130 +++- 6 files changed, 1119 insertions(+), 285 deletions(-) diff --git a/sdk-platform-java/gax-java/RESUMABLE_UPLOAD_DESIGN.md b/sdk-platform-java/gax-java/RESUMABLE_UPLOAD_DESIGN.md index 01c93f501529..03301858b0f2 100644 --- a/sdk-platform-java/gax-java/RESUMABLE_UPLOAD_DESIGN.md +++ b/sdk-platform-java/gax-java/RESUMABLE_UPLOAD_DESIGN.md @@ -231,22 +231,33 @@ stateDiagram-v2 - Set `X-Goog-Upload-Protocol: resumable` and `X-Goog-Upload-Command: start`. - Execute POST with the request JSON body. - Extract `X-Goog-Upload-URL` header value to obtain the `uploadUrl`. +- Extract `X-Goog-Upload-Chunk-Granularity` and adjust the user-configured `chunkSize` to the largest multiple of this granularity value (rounded down, minimum equal to granularity). #### Step 2: Upload Loop (Transmit) - Check absolute global deadline. -- Call `streamProvider.get()`. -- Skip/seek to current `offset`. -- Set `X-Goog-Upload-Command: upload, finalize` and `X-Goog-Upload-Offset: offset`. -- Stream payload using a chunked output stream, updating the progress listener during writes. -- If response is `final` with `2xx`: parse response and return. -- If exception occurs: Categorize exception. If Category 2 (Mismatch) or connection drop, transition to **Query State**. +- Retrieve the chunk corresponding to the current `offset`: + - Search in the 2-chunk memory cache. + - If not found: + - Check if the underlying stream position matches `offset`. + - If not, close and recreate the stream from `streamProvider.get()` and skip/seek to `offset`. + - Read `adjustedChunkSize` bytes from the stream, store as a `BufferedChunk`, add to the cache (retaining at most 2 chunks), and update the stream position. +- If no data was read (stream reached EOF at a chunk boundary): + - Send an empty POST request with `X-Goog-Upload-Command: finalize`. +- If data was read: + - If it is the last chunk (length < chunk size): + - Send a POST request with `X-Goog-Upload-Command: upload, finalize` and `X-Goog-Upload-Offset: offset`. + - If it is an intermediate chunk: + - Send a POST request with `X-Goog-Upload-Command: upload` and `X-Goog-Upload-Offset: offset`. +- Update the progress listener upon successful responses. +- If the server replies with status `final` (even on `upload` command): parse the response and complete the upload. +- If exception occurs: Categorize exception. If Category 2 (Mismatch) or socket drop, transition to **Query State**. #### Step 3: Query State - Execute POST to `uploadUrl` with `X-Goog-Upload-Command: query`. +- If response is `final`: parse the response and complete the upload (via `UploadAlreadyFinalizedException` handling). - If response is `active`: - Extract `X-Goog-Upload-Size-Received` -> `newOffset`. - If `newOffset == offset`: apply backoff (to avoid spamming server). - - Update `offset = newOffset` and transition back to **Upload Loop**. -- If response is `final`: return response. + - Update `offset = newOffset` and transition back to **Upload Loop** (which will automatically retrieve the correct chunk from cache or recreate and seek the stream). - If Category 1 (Transient) error: retry query with backoff. - If Category 3 (Fatal) error: fail immediately. diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java index 72144f7a2b6c..898e83f2c86a 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java @@ -39,9 +39,9 @@ import com.google.api.gax.rpc.OperationCallSettings; import com.google.api.gax.rpc.OperationCallable; import com.google.api.gax.rpc.PagedCallSettings; +import com.google.api.gax.rpc.ResumableUploadCallable; import com.google.api.gax.rpc.ServerStreamingCallSettings; import com.google.api.gax.rpc.ServerStreamingCallable; -import com.google.api.gax.rpc.ResumableUploadCallable; import com.google.api.gax.rpc.UnaryCallSettings; import com.google.api.gax.rpc.UnaryCallable; import com.google.api.gax.tracing.ApiTracerContext; @@ -228,9 +228,10 @@ ServerStreamingCallable createServerStreamingCallable( * @param clientContext {@link ClientContext} to use to connect to the service. * @return {@link ResumableUploadCallable} callable object. */ - public static ResumableUploadCallable createResumableUploadCallable( - HttpJsonCallSettings httpJsonCallSettings, - ClientContext clientContext) { + public static + ResumableUploadCallable createResumableUploadCallable( + HttpJsonCallSettings httpJsonCallSettings, + ClientContext clientContext) { return new HttpJsonResumableUploadCallable<>(httpJsonCallSettings, clientContext); } diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCall.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCall.java index 9175455b66ef..69aa096318ec 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCall.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCall.java @@ -29,6 +29,7 @@ */ package com.google.api.gax.httpjson; +import com.google.api.client.http.ByteArrayContent; import com.google.api.client.http.EmptyContent; import com.google.api.client.http.GenericUrl; import com.google.api.client.http.HttpContent; @@ -44,10 +45,7 @@ import com.google.api.client.util.GenericData; import com.google.api.core.ApiFuture; import com.google.api.core.SettableApiFuture; -import com.google.api.gax.rpc.ApiCallContext; -import com.google.api.gax.rpc.ApiException; import com.google.api.gax.rpc.DeadlineExceededException; -import com.google.api.gax.rpc.InputStreamProvider; import com.google.api.gax.rpc.ResumableUploadProgressListener; import com.google.api.gax.rpc.ResumableUploadRequest; import com.google.api.gax.rpc.ResumableUploadStatus; @@ -58,10 +56,10 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.Executor; @@ -71,8 +69,17 @@ /** Encapsulates the execution logic and state machine of the Resumable Upload protocol. */ final class HttpJsonResumableUploadCall { - private static final Logger logger = Logger.getLogger(HttpJsonResumableUploadCall.class.getName()); + private static final Logger logger = + Logger.getLogger(HttpJsonResumableUploadCall.class.getName()); private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); + private static final int HTTP_BAD_REQUEST = 400; + private static final int HTTP_TOO_MANY_REQUESTS = 429; + private static final int HTTP_INTERNAL_ERROR = 500; + private static final int HTTP_PRECONDITION_FAILED = 412; + private static final int HTTP_RANGE_NOT_SATISFIABLE = 416; + private static final long DEFAULT_BACKOFF_BASE_MS = 500; + private static final long DEFAULT_BACKOFF_MAX_MS = 30000; + private static final int DEFAULT_DEADLINE_MINUTES = 10; private final ApiMethodDescriptor methodDescriptor; private final ResumableUploadRequest uploadRequest; @@ -127,16 +134,53 @@ private ResponseT runStateMachine() throws Exception { } } + private static class BufferedChunk { + final long offset; + final byte[] data; + final int length; + + BufferedChunk(long offset, byte[] data, int length) { + this.offset = offset; + this.data = data; + this.length = length; + } + } + + private static class SessionInfo { + final String uploadUrl; + final int granularity; + + SessionInfo(String uploadUrl, int granularity) { + this.uploadUrl = uploadUrl; + this.granularity = granularity; + } + } + + private static final class UploadAlreadyFinalizedException extends Exception { + private final Object response; + + UploadAlreadyFinalizedException(Object response) { + this.response = response; + } + + Object getResponse() { + return response; + } + } + private ResponseT runStateMachineInternal() throws Exception { Instant deadline = calculateDeadline(); - + // Phase 1: Start Session (with retry) String uploadUrl = null; int attempt = 0; + int granularity = 1; while (true) { try { checkDeadline(deadline); - uploadUrl = startSession(deadline); + SessionInfo sessionInfo = startSession(deadline); + uploadUrl = sessionInfo.uploadUrl; + granularity = sessionInfo.granularity; break; // Success } catch (Exception e) { checkDeadline(deadline); @@ -144,24 +188,104 @@ private ResponseT runStateMachineInternal() throws Exception { if (category == ErrorCategory.CATEGORY_1_TRANSIENT) { attempt++; long delayMs = calculateBackoff(attempt); - logger.log(Level.WARNING, "Transient error starting session. Backing off for " + delayMs + " ms", e); + logger.log( + Level.WARNING, + "Transient error starting session. Backing off for " + delayMs + " ms", + e); sleep(delayMs); } else { throw e; // Fatal/Mismatch, bubble up } } } - logger.log(Level.FINE, "Resumable session started. Upload URL: {0}", uploadUrl); + logger.log( + Level.FINE, + "Resumable session started. Upload URL: {0}, Granularity: {1}", + new Object[] {uploadUrl, granularity}); + + int adjustedChunkSize = uploadRequest.getChunkSize(); + if (granularity > 1) { + adjustedChunkSize = (adjustedChunkSize / granularity) * granularity; + if (adjustedChunkSize == 0) { + adjustedChunkSize = granularity; + } + } long offset = 0; attempt = 0; long previousOffset = -1; - // Phase 2 & 3 Loop: Transmit & Query Recovery + InputStream stream = uploadRequest.getStreamProvider().get(); + long streamPosition = 0; + + List cache = new ArrayList<>(); + + // Phase 2 & 3 Loop: Transmit Chunks & Query Recovery while (true) { try { checkDeadline(deadline); - return transmitRemaining(uploadUrl, offset, deadline); + + // Find chunk in cache or read from stream + BufferedChunk chunk = null; + for (BufferedChunk cached : cache) { + if (cached.offset == offset) { + chunk = cached; + break; + } + } + + if (chunk == null) { + // Read from stream + if (streamPosition != offset) { + if (stream != null) { + stream.close(); + } + stream = uploadRequest.getStreamProvider().get(); + long skipped = skipFully(stream, offset); + if (skipped < offset) { + throw new IOException("Failed to skip stream bytes to offset: " + offset); + } + streamPosition = offset; + } + + byte[] buffer = new byte[adjustedChunkSize]; + int bytesRead = readFully(stream, buffer, adjustedChunkSize); + if (bytesRead > 0) { + chunk = new BufferedChunk(offset, buffer, bytesRead); + cache.add(chunk); + if (cache.size() > 2) { + cache.remove(0); + } + streamPosition += bytesRead; + } + } + + if (chunk == null) { + // Stream was empty or exact chunk multiple and fully uploaded. + // Send finalize only + return sendFinalizeOnly(uploadUrl, offset, deadline); + } + + // Check if this is the last chunk + boolean isEof = (chunk.length < adjustedChunkSize); + + if (isEof) { + // Send upload, finalize for the last chunk + return sendUploadFinalize(uploadUrl, chunk.offset, chunk.data, chunk.length, deadline); + } + + // Send intermediate chunk (upload command) + sendChunk(uploadUrl, chunk.offset, chunk.data, chunk.length, deadline); + + // Successful chunk transmission! Update offset to next chunk + offset = chunk.offset + chunk.length; + attempt = 0; // Reset backoff attempts on progress + + } catch (UploadAlreadyFinalizedException uafe) { + updateProgress( + uploadRequest.getTotalBytes() > 0 ? uploadRequest.getTotalBytes() : offset, + ResumableUploadProgressListener.State.COMPLETED); + return (ResponseT) uafe.getResponse(); } catch (Exception e) { checkDeadline(deadline); ErrorCategory category = getErrorCategory(e); @@ -169,23 +293,37 @@ private ResponseT runStateMachineInternal() throws Exception { if (category == ErrorCategory.CATEGORY_2_MISMATCH) { logger.log(Level.WARNING, "State mismatch detected. Triggering recovery...", e); updateProgress(offset, ResumableUploadProgressListener.State.RECOVERING); - - offset = recoverOffset(uploadUrl, deadline); - logger.log(Level.INFO, "Recovery completed. Server received bytes: {0}", offset); - - if (offset == previousOffset) { - // No progress was made since last recovery. Wait with backoff to prevent slamming server. + + long serverOffset = 0; + try { + serverOffset = recoverOffset(uploadUrl, deadline); + } catch (UploadAlreadyFinalizedException uafe) { + updateProgress( + uploadRequest.getTotalBytes() > 0 ? uploadRequest.getTotalBytes() : offset, + ResumableUploadProgressListener.State.COMPLETED); + return (ResponseT) uafe.getResponse(); + } + + logger.log(Level.INFO, "Recovery completed. Server received bytes: {0}", serverOffset); + + if (serverOffset == previousOffset) { attempt++; long delayMs = calculateBackoff(attempt); sleep(delayMs); } else { - attempt = 0; // Reset attempts on progress - previousOffset = offset; + attempt = 0; + previousOffset = serverOffset; } + + offset = serverOffset; + // Loop will handle finding the chunk in cache or seeking/recreating the stream! } else if (category == ErrorCategory.CATEGORY_1_TRANSIENT) { attempt++; long delayMs = calculateBackoff(attempt); - logger.log(Level.WARNING, "Transient error. Backing off for {0} ms (attempt {1})", new Object[]{delayMs, attempt}); + logger.log( + Level.WARNING, + "Transient error. Backing off for {0} ms (attempt {1})", + new Object[] {delayMs, attempt}); sleep(delayMs); } else { updateProgress(offset, ResumableUploadProgressListener.State.FAILED); @@ -195,7 +333,7 @@ private ResponseT runStateMachineInternal() throws Exception { } } - private String startSession(Instant deadline) throws Exception { + private SessionInfo startSession(final Instant deadline) throws Exception { HttpRequestFactory requestFactory = getRequestFactory(); HttpRequestFormatter requestFormatter = methodDescriptor.getRequestFormatter(); @@ -205,18 +343,18 @@ private String startSession(Instant deadline) throws Exception { if (!Strings.isNullOrEmpty(requestBody)) { JSON_FACTORY.createJsonParser(requestBody).parse(tokenRequest); - initialContent = new JsonHttpContent(JSON_FACTORY, tokenRequest) - .setMediaType(new HttpMediaType("application/json; charset=utf-8")); + initialContent = + new JsonHttpContent(JSON_FACTORY, tokenRequest) + .setMediaType(new HttpMediaType("application/json; charset=utf-8")); } else { initialContent = new EmptyContent(); } - // Resumable upload specific path modifier String path = "/resumable/upload" + requestFormatter.getPath(uploadRequest.getRequest()); GenericUrl url = new GenericUrl(normalizeEndpoint(endpoint) + path); - - // Populate query parameters - Map> queryParams = requestFormatter.getQueryParamNames(uploadRequest.getRequest()); + + Map> queryParams = + requestFormatter.getQueryParamNames(uploadRequest.getRequest()); for (Map.Entry> queryParam : queryParams.entrySet()) { if (queryParam.getValue() != null) { url.set(queryParam.getKey(), queryParam.getValue()); @@ -226,12 +364,10 @@ private String startSession(Instant deadline) throws Exception { HttpRequest httpRequest = requestFactory.buildPostRequest(url, initialContent); configureTimeouts(httpRequest, deadline); - // Set standard headers + merge custom metadata for (Map.Entry entry : requestHeaders.getHeaders().entrySet()) { String key = entry.getKey(); String value = (String) entry.getValue(); - - // Prefix metadata headers to prevent collision with physical request metadata + if (isMetadataHeaderDenylisted(key)) { httpRequest.getHeaders().set("X-Goog-Upload-Header-" + key, value); } else { @@ -249,17 +385,31 @@ private String startSession(Instant deadline) throws Exception { response = httpRequest.execute(); String status = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); if (!"active".equalsIgnoreCase(status)) { - throw new HttpResponseException.Builder(response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) + throw new HttpResponseException.Builder( + response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) .setMessage("Failed to initiate resumable session: Status is not active") .build(); } String uploadUrl = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-URL"); if (Strings.isNullOrEmpty(uploadUrl)) { - throw new HttpResponseException.Builder(response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) + throw new HttpResponseException.Builder( + response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) .setMessage("Failed to initiate resumable session: Missing upload URL") .build(); } - return uploadUrl; + + String granularityStr = + response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Chunk-Granularity"); + int granularity = 1; + if (!Strings.isNullOrEmpty(granularityStr)) { + try { + granularity = Integer.parseInt(granularityStr); + } catch (NumberFormatException e) { + logger.log( + Level.WARNING, "Failed to parse chunk granularity header: " + granularityStr, e); + } + } + return new SessionInfo(uploadUrl, granularity); } finally { if (response != null) { response.disconnect(); @@ -267,20 +417,58 @@ private String startSession(Instant deadline) throws Exception { } } - private ResponseT transmitRemaining(String uploadUrl, long offset, Instant deadline) throws Exception { + private void sendChunk( + final String uploadUrl, + final long offset, + final byte[] data, + final int length, + final Instant deadline) + throws Exception { HttpRequestFactory requestFactory = getRequestFactory(); - - InputStream stream = uploadRequest.getStreamProvider().get(); - if (offset > 0) { - long skipped = stream.skip(offset); - if (skipped < offset) { - throw new IOException("Failed to skip stream bytes to offset: " + offset); + HttpContent payload = new ByteArrayContent("application/octet-stream", data, 0, length); + + GenericUrl url = new GenericUrl(uploadUrl); + HttpRequest httpRequest = requestFactory.buildPostRequest(url, payload); + configureTimeouts(httpRequest, deadline); + + httpRequest.getHeaders().set("X-Goog-Upload-Command", "upload"); + httpRequest.getHeaders().set("X-Goog-Upload-Offset", String.valueOf(offset)); + + updateProgress(offset, ResumableUploadProgressListener.State.IN_PROGRESS); + + HttpResponse response = null; + try { + response = httpRequest.execute(); + String status = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); + if ("final".equalsIgnoreCase(status)) { + InputStreamReader reader = + new InputStreamReader(response.getContent(), StandardCharsets.UTF_8); + ResponseT parsedResponse = + methodDescriptor.getResponseParser().parse(reader, callOptions.getTypeRegistry()); + throw new UploadAlreadyFinalizedException(parsedResponse); + } + if (!"active".equalsIgnoreCase(status)) { + throw new HttpResponseException.Builder( + response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) + .setMessage("Resumable upload chunk failed: Status is not active") + .build(); + } + } finally { + if (response != null) { + response.disconnect(); } } + } - // Wrap the stream in custom HttpContent that updates the progress listener - HttpContent payload = new ProgressReportingHttpContent( - stream, uploadRequest.getTotalBytes(), offset, uploadRequest.getProgressListener()); + private ResponseT sendUploadFinalize( + final String uploadUrl, + final long offset, + final byte[] data, + final int length, + final Instant deadline) + throws Exception { + HttpRequestFactory requestFactory = getRequestFactory(); + HttpContent payload = new ByteArrayContent("application/octet-stream", data, 0, length); GenericUrl url = new GenericUrl(uploadUrl); HttpRequest httpRequest = requestFactory.buildPostRequest(url, payload); @@ -295,19 +483,62 @@ private ResponseT transmitRemaining(String uploadUrl, long offset, Instant deadl try { response = httpRequest.execute(); String status = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); - if (!"final".equalsIgnoreCase(status)) { - throw new HttpResponseException.Builder(response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) - .setMessage("Resumable upload failed: Status is not final") + throw new HttpResponseException.Builder( + response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) + .setMessage("Resumable upload finalize failed: Status is not final") + .build(); + } + + InputStreamReader reader = + new InputStreamReader(response.getContent(), StandardCharsets.UTF_8); + ResponseT parsedResponse = + methodDescriptor.getResponseParser().parse(reader, callOptions.getTypeRegistry()); + + long finalProgressBytes = + uploadRequest.getTotalBytes() > 0 ? uploadRequest.getTotalBytes() : offset + length; + updateProgress(finalProgressBytes, ResumableUploadProgressListener.State.COMPLETED); + + return parsedResponse; + } finally { + if (response != null) { + response.disconnect(); + } + } + } + + private ResponseT sendFinalizeOnly( + final String uploadUrl, final long offset, final Instant deadline) throws Exception { + HttpRequestFactory requestFactory = getRequestFactory(); + + GenericUrl url = new GenericUrl(uploadUrl); + HttpRequest httpRequest = requestFactory.buildPostRequest(url, new EmptyContent()); + configureTimeouts(httpRequest, deadline); + + httpRequest.getHeaders().set("X-Goog-Upload-Command", "finalize"); + + updateProgress(offset, ResumableUploadProgressListener.State.IN_PROGRESS); + + HttpResponse response = null; + try { + response = httpRequest.execute(); + String status = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); + if (!"final".equalsIgnoreCase(status)) { + throw new HttpResponseException.Builder( + response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) + .setMessage("Resumable upload finalize failed: Status is not final") .build(); } - InputStreamReader reader = new InputStreamReader(response.getContent(), StandardCharsets.UTF_8); - ResponseT parsedResponse = methodDescriptor.getResponseParser().parse(reader, callOptions.getTypeRegistry()); - - updateProgress(uploadRequest.getTotalBytes() > 0 ? uploadRequest.getTotalBytes() : offset, - ResumableUploadProgressListener.State.COMPLETED); - + InputStreamReader reader = + new InputStreamReader(response.getContent(), StandardCharsets.UTF_8); + ResponseT parsedResponse = + methodDescriptor.getResponseParser().parse(reader, callOptions.getTypeRegistry()); + + long finalProgressBytes = + uploadRequest.getTotalBytes() > 0 ? uploadRequest.getTotalBytes() : offset; + updateProgress(finalProgressBytes, ResumableUploadProgressListener.State.COMPLETED); + return parsedResponse; } finally { if (response != null) { @@ -316,39 +547,43 @@ private ResponseT transmitRemaining(String uploadUrl, long offset, Instant deadl } } - private long recoverOffset(String uploadUrl, Instant deadline) throws Exception { + private long recoverOffset(final String uploadUrl, final Instant deadline) throws Exception { HttpRequestFactory requestFactory = getRequestFactory(); GenericUrl url = new GenericUrl(uploadUrl); - + HttpRequest httpRequest = requestFactory.buildPostRequest(url, new EmptyContent()); configureTimeouts(httpRequest, deadline); - + httpRequest.getHeaders().set("X-Goog-Upload-Command", "query"); HttpResponse response = null; try { response = httpRequest.execute(); String status = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); - + if ("final".equalsIgnoreCase(status)) { - // Already finalized, query command behaves like final - throw new HttpResponseException.Builder(response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) - .setMessage("Query returned final status. Re-executing state machine...") - .build(); + InputStreamReader reader = + new InputStreamReader(response.getContent(), StandardCharsets.UTF_8); + ResponseT parsedResponse = + methodDescriptor.getResponseParser().parse(reader, callOptions.getTypeRegistry()); + throw new UploadAlreadyFinalizedException(parsedResponse); } if (!"active".equalsIgnoreCase(status)) { - throw new HttpResponseException.Builder(response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) + throw new HttpResponseException.Builder( + response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) .setMessage("Query failed: Status is not active") .build(); } - - String receivedSizeStr = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Size-Received"); + + String receivedSizeStr = + response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Size-Received"); if (Strings.isNullOrEmpty(receivedSizeStr)) { - throw new HttpResponseException.Builder(response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) + throw new HttpResponseException.Builder( + response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) .setMessage("Query failed: Missing size received header") .build(); } - + return Long.parseLong(receivedSizeStr); } finally { if (response != null) { @@ -357,7 +592,35 @@ private long recoverOffset(String uploadUrl, Instant deadline) throws Exception } } - private void configureTimeouts(HttpRequest request, Instant deadline) { + private int readFully(final InputStream in, final byte[] b, final int len) throws IOException { + int total = 0; + while (total < len) { + int result = in.read(b, total, len - total); + if (result == -1) { + break; + } + total += result; + } + return total == 0 && len > 0 ? -1 : total; + } + + private long skipFully(final InputStream in, final long n) throws IOException { + long total = 0; + while (total < n) { + long skipped = in.skip(n - total); + if (skipped == 0) { + int read = in.read(); + if (read == -1) { + break; + } + skipped = 1; + } + total += skipped; + } + return total; + } + + private void configureTimeouts(final HttpRequest request, final Instant deadline) { long remainingMs = Duration.between(Instant.now(), deadline).toMillis(); if (remainingMs <= 0) { remainingMs = 1; // force timeout @@ -371,26 +634,34 @@ private Instant calculateDeadline() { if (timeout != null && !timeout.isZero() && !timeout.isNegative()) { return Instant.now().plus(timeout); } - return Instant.now().plus(Duration.ofMinutes(10)); // Default deadline of 10 mins + return Instant.now().plus(Duration.ofMinutes(DEFAULT_DEADLINE_MINUTES)); } - private void checkDeadline(Instant deadline) throws DeadlineExceededException { + private void checkDeadline(final Instant deadline) throws DeadlineExceededException { if (Instant.now().isAfter(deadline)) { - throw (DeadlineExceededException) com.google.api.gax.rpc.ApiExceptionFactory.createException( - "Resumable upload session exceeded the configured deadline.", - null, - HttpJsonStatusCode.of(com.google.api.gax.rpc.StatusCode.Code.DEADLINE_EXCEEDED), - false); + throw (DeadlineExceededException) + com.google.api.gax.rpc.ApiExceptionFactory.createException( + "Resumable upload session exceeded the configured deadline.", + null, + HttpJsonStatusCode.of(com.google.api.gax.rpc.StatusCode.Code.DEADLINE_EXCEEDED), + false); } } - private ErrorCategory getErrorCategory(Throwable t) { + private ErrorCategory getErrorCategory(final Throwable t) { if (t instanceof HttpResponseException) { - int statusCode = ((HttpResponseException) t).getStatusCode(); - if (statusCode == 429 || statusCode >= 500) { + HttpResponseException e = (HttpResponseException) t; + int statusCode = e.getStatusCode(); + String uploadStatus = e.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); + if ("final".equalsIgnoreCase(uploadStatus)) { + return ErrorCategory.CATEGORY_3_FATAL; + } + if (statusCode == HTTP_TOO_MANY_REQUESTS || statusCode >= HTTP_INTERNAL_ERROR) { return ErrorCategory.CATEGORY_1_TRANSIENT; } - if (statusCode == 400 || statusCode == 412 || statusCode == 416) { + if (statusCode == HTTP_BAD_REQUEST + || statusCode == HTTP_PRECONDITION_FAILED + || statusCode == HTTP_RANGE_NOT_SATISFIABLE) { return ErrorCategory.CATEGORY_2_MISMATCH; } return ErrorCategory.CATEGORY_3_FATAL; @@ -401,14 +672,14 @@ private ErrorCategory getErrorCategory(Throwable t) { return ErrorCategory.CATEGORY_3_FATAL; } - private long calculateBackoff(int attempt) { - long baseDelay = 500; // 500ms - long maxDelay = 30000; // 30s + private long calculateBackoff(final int attempt) { + long baseDelay = DEFAULT_BACKOFF_BASE_MS; + long maxDelay = DEFAULT_BACKOFF_MAX_MS; long delay = (long) (baseDelay * Math.pow(2, attempt)); return Math.min(delay, maxDelay); } - private void sleep(long ms) { + private void sleep(final long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { @@ -416,10 +687,12 @@ private void sleep(long ms) { } } - private void updateProgress(long bytesUploaded, ResumableUploadProgressListener.State state) { + private void updateProgress( + final long bytesUploaded, final ResumableUploadProgressListener.State state) { ResumableUploadProgressListener progressListener = uploadRequest.getProgressListener(); if (progressListener != null) { - progressListener.onProgress(new ResumableUploadStatus(bytesUploaded, uploadRequest.getTotalBytes(), state)); + progressListener.onProgress( + new ResumableUploadStatus(bytesUploaded, uploadRequest.getTotalBytes(), state)); } } @@ -431,15 +704,14 @@ private HttpRequestFactory getRequestFactory() { return httpTransport.createRequestFactory(); } - private boolean isMetadataHeaderDenylisted(String key) { - // Standard body-related headers that must be prefixed when uploading session metadata + private boolean isMetadataHeaderDenylisted(final String key) { return "Content-Length".equalsIgnoreCase(key) || "Content-Type".equalsIgnoreCase(key) || "Content-Encoding".equalsIgnoreCase(key) || "Transfer-Encoding".equalsIgnoreCase(key); } - private String normalizeEndpoint(String rawEndpoint) { + private String normalizeEndpoint(final String rawEndpoint) { String normalized = rawEndpoint; if (!normalized.contains("://")) { normalized = "https://" + normalized; @@ -450,63 +722,9 @@ private String normalizeEndpoint(String rawEndpoint) { return normalized; } - private Exception translateException(HttpResponseException e) { - // Return standard GAX Exception or original depending on status code - HttpJsonApiExceptionFactory factory = new HttpJsonApiExceptionFactory(java.util.Collections.emptySet()); + private Exception translateException(final HttpResponseException e) { + HttpJsonApiExceptionFactory factory = + new HttpJsonApiExceptionFactory(java.util.Collections.emptySet()); return factory.create(e); } - - /** Custom HttpContent class that streams data and reports progress callbacks. */ - private static final class ProgressReportingHttpContent implements HttpContent { - private final InputStream stream; - private final long totalLength; - private final long initialOffset; - private final ResumableUploadProgressListener progressListener; - - ProgressReportingHttpContent( - InputStream stream, - long totalLength, - long initialOffset, - ResumableUploadProgressListener progressListener) { - this.stream = stream; - this.totalLength = totalLength; - this.initialOffset = initialOffset; - this.progressListener = progressListener; - } - - @Override - public long getLength() throws IOException { - // Return -1 to force chunked transfer encoding as the length of the remaining stream - // might be different from physical Content-Length, or if totalLength is unknown. - return -1; - } - - @Override - public String getType() { - return "application/octet-stream"; - } - - @Override - public boolean retrySupported() { - // Handled by our state machine recreating the stream via streamProvider - return false; - } - - @Override - public void writeTo(OutputStream out) throws IOException { - byte[] buffer = new byte[65536]; // 64KB buffer - int len; - long bytesUploaded = initialOffset; - while ((len = stream.read(buffer)) != -1) { - out.write(buffer, 0, len); - out.flush(); - bytesUploaded += len; - if (progressListener != null) { - progressListener.onProgress( - new ResumableUploadStatus( - bytesUploaded, totalLength, ResumableUploadProgressListener.State.IN_PROGRESS)); - } - } - } - } } diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java index 19491a193683..84ef8c7a2b65 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java @@ -53,8 +53,7 @@ public final class HttpJsonResumableUploadCallable private final ClientContext clientContext; public HttpJsonResumableUploadCallable( - HttpJsonCallSettings httpJsonCallSettings, - ClientContext clientContext) { + HttpJsonCallSettings httpJsonCallSettings, ClientContext clientContext) { this.httpJsonCallSettings = Preconditions.checkNotNull(httpJsonCallSettings); this.clientContext = Preconditions.checkNotNull(clientContext); } @@ -63,7 +62,7 @@ public HttpJsonResumableUploadCallable( public ApiFuture futureCall( ResumableUploadRequest request, ApiCallContext context) { Preconditions.checkNotNull(request); - + // Resolve call context HttpJsonCallContext httpJsonContext = HttpJsonCallContext.createDefault(); if (context != null) { @@ -71,7 +70,8 @@ public ApiFuture futureCall( } // Resolve channel and endpoint - HttpJsonTransportChannel transportChannel = (HttpJsonTransportChannel) clientContext.getTransportChannel(); + HttpJsonTransportChannel transportChannel = + (HttpJsonTransportChannel) clientContext.getTransportChannel(); ManagedHttpJsonChannel channel = transportChannel.getManagedChannel(); String endpoint = channel.getEndpoint(); HttpTransport httpTransport = channel.getHttpTransport(); @@ -81,22 +81,22 @@ public ApiFuture futureCall( if (callOptions.getCredentials() == null && clientContext.getCredentials() != null) { callOptions = callOptions.toBuilder().setCredentials(clientContext.getCredentials()).build(); } - + Executor executor = clientContext.getExecutor(); // Gather request headers - HttpJsonMetadata requestHeaders = HttpJsonMetadata.newBuilder().build() - .withHeaders(httpJsonContext.getExtraHeaders()); + HttpJsonMetadata requestHeaders = + HttpJsonMetadata.newBuilder().build().withHeaders(httpJsonContext.getExtraHeaders()); - HttpJsonResumableUploadCall call = new HttpJsonResumableUploadCall<>( - httpJsonCallSettings.getMethodDescriptor(), - request, - httpTransport, - requestHeaders, - callOptions, - endpoint, - executor - ); + HttpJsonResumableUploadCall call = + new HttpJsonResumableUploadCall<>( + httpJsonCallSettings.getMethodDescriptor(), + request, + httpTransport, + requestHeaders, + callOptions, + endpoint, + executor); return call.execute(); } diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallableTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallableTest.java index 5c15934899fd..cfe2863685ee 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallableTest.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallableTest.java @@ -43,18 +43,15 @@ import com.google.api.gax.rpc.ResumableUploadProgressListener; import com.google.api.gax.rpc.ResumableUploadRequest; import com.google.api.gax.rpc.ResumableUploadStatus; -import com.google.common.collect.ImmutableMap; import com.google.protobuf.TypeRegistry; import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.InputStream; import java.io.Reader; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -83,13 +80,19 @@ void setUp() { Mockito.lenient().when(clientContext.getTransportChannel()).thenReturn(transportChannel); Mockito.lenient().when(transportChannel.getManagedChannel()).thenReturn(managedChannel); Mockito.lenient().when(managedChannel.getEndpoint()).thenReturn("localhost"); - + // Wire formatter and parser mocking Mockito.lenient().when(methodDescriptor.getRequestFormatter()).thenReturn(requestFormatter); Mockito.lenient().when(methodDescriptor.getResponseParser()).thenReturn(responseParser); - Mockito.lenient().when(requestFormatter.getPath(Mockito.anyString())).thenReturn("/upload/resource"); - Mockito.lenient().when(requestFormatter.getRequestBody(Mockito.anyString())).thenReturn("{\"metadata\":\"value\"}"); - Mockito.lenient().when(requestFormatter.getQueryParamNames(Mockito.anyString())).thenReturn(Collections.emptyMap()); + Mockito.lenient() + .when(requestFormatter.getPath(Mockito.anyString())) + .thenReturn("/upload/resource"); + Mockito.lenient() + .when(requestFormatter.getRequestBody(Mockito.anyString())) + .thenReturn("{\"metadata\":\"value\"}"); + Mockito.lenient() + .when(requestFormatter.getQueryParamNames(Mockito.anyString())) + .thenReturn(Collections.emptyMap()); } @AfterEach @@ -104,14 +107,14 @@ void happyPathUpload() throws Exception { // Sequence of mock HTTP responses Queue mockResponses = new ConcurrentLinkedQueue<>(); - + // 1. Session start response mockResponses.add( new MockLowLevelHttpResponse() .setStatusCode(200) .addHeader("X-Goog-Upload-Status", "active") .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); - + // 2. Finalize upload response mockResponses.add( new MockLowLevelHttpResponse() @@ -119,50 +122,56 @@ void happyPathUpload() throws Exception { .addHeader("X-Goog-Upload-Status", "final") .setContent("{\"response\":\"success\"}")); - MockHttpTransport transport = new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { + MockHttpTransport transport = + new MockHttpTransport() { @Override - public LowLevelHttpResponse execute() throws IOException { - MockLowLevelHttpResponse response = mockResponses.poll(); - if (response == null) { - throw new IOException("Unexpected out-of-bounds mock request: " + url); - } - return response; + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + MockLowLevelHttpResponse response = mockResponses.poll(); + if (response == null) { + throw new IOException("Unexpected out-of-bounds mock request: " + url); + } + return response; + } + }; } }; - } - }; Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); - Mockito.when(responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) + Mockito.when( + responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) .thenReturn("SUCCESS_RESPONSE"); List progressHistory = new ArrayList<>(); ResumableUploadProgressListener listener = progressHistory::add; - ResumableUploadRequest request = ResumableUploadRequest.newBuilder() - .setRequest("META") - .setStreamProvider(streamProvider) - .setTotalBytes(data.length) - .setProgressListener(listener) - .build(); + ResumableUploadRequest request = + ResumableUploadRequest.newBuilder() + .setRequest("META") + .setStreamProvider(streamProvider) + .setTotalBytes(data.length) + .setProgressListener(listener) + .build(); - HttpJsonCallSettings settings = HttpJsonCallSettings.newBuilder() - .setMethodDescriptor(methodDescriptor) - .build(); + HttpJsonCallSettings settings = + HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(methodDescriptor) + .build(); - HttpJsonResumableUploadCallable callable = new HttpJsonResumableUploadCallable<>(settings, clientContext); + HttpJsonResumableUploadCallable callable = + new HttpJsonResumableUploadCallable<>(settings, clientContext); String response = callable.call(request); assertThat(response).isEqualTo("SUCCESS_RESPONSE"); assertThat(progressHistory).isNotEmpty(); - + // Verify progress tracking states - assertThat(progressHistory.get(0).getState()).isEqualTo(ResumableUploadProgressListener.State.NOT_STARTED); - + assertThat(progressHistory.get(0).getState()) + .isEqualTo(ResumableUploadProgressListener.State.NOT_STARTED); + ResumableUploadStatus lastStatus = progressHistory.get(progressHistory.size() - 1); assertThat(lastStatus.getState()).isEqualTo(ResumableUploadProgressListener.State.COMPLETED); assertThat(lastStatus.getBytesUploaded()).isEqualTo(data.length); @@ -174,17 +183,21 @@ void retryOnTransientStartError() throws Exception { InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); Queue mockResponses = new ConcurrentLinkedQueue<>(); - + // 1. Session start transient failure (503) - mockResponses.add(new MockLowLevelHttpResponse().setStatusCode(503).setReasonPhrase("Service Unavailable").setContent("")); - + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(503) + .setReasonPhrase("Service Unavailable") + .setContent("")); + // 2. Retry start session success mockResponses.add( new MockLowLevelHttpResponse() .setStatusCode(200) .addHeader("X-Goog-Upload-Status", "active") .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); - + // 3. Finalize upload success mockResponses.add( new MockLowLevelHttpResponse() @@ -192,32 +205,37 @@ void retryOnTransientStartError() throws Exception { .addHeader("X-Goog-Upload-Status", "final") .setContent("{\"response\":\"ok\"}")); - MockHttpTransport transport = new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { + MockHttpTransport transport = + new MockHttpTransport() { @Override - public LowLevelHttpResponse execute() throws IOException { - return mockResponses.poll(); + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + return mockResponses.poll(); + } + }; } }; - } - }; Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); - Mockito.when(responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) + Mockito.when( + responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) .thenReturn("OK_REP"); - ResumableUploadRequest request = ResumableUploadRequest.newBuilder() - .setRequest("META") - .setStreamProvider(streamProvider) - .build(); + ResumableUploadRequest request = + ResumableUploadRequest.newBuilder() + .setRequest("META") + .setStreamProvider(streamProvider) + .build(); - HttpJsonCallSettings settings = HttpJsonCallSettings.newBuilder() - .setMethodDescriptor(methodDescriptor) - .build(); + HttpJsonCallSettings settings = + HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(methodDescriptor) + .build(); - HttpJsonResumableUploadCallable callable = new HttpJsonResumableUploadCallable<>(settings, clientContext); + HttpJsonResumableUploadCallable callable = + new HttpJsonResumableUploadCallable<>(settings, clientContext); String response = callable.call(request); assertThat(response).isEqualTo("OK_REP"); @@ -229,14 +247,14 @@ void stateMismatchRecoveryAndResume() throws Exception { InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); Queue mockResponses = new ConcurrentLinkedQueue<>(); - + // 1. Session start success mockResponses.add( new MockLowLevelHttpResponse() .setStatusCode(200) .addHeader("X-Goog-Upload-Status", "active") .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); - + // 2. Transmit failure with Category 2 (400 Bad Request) mockResponses.add( new MockLowLevelHttpResponse() @@ -258,42 +276,47 @@ void stateMismatchRecoveryAndResume() throws Exception { .addHeader("X-Goog-Upload-Status", "final") .setContent("{\"response\":\"restored\"}")); - MockHttpTransport transport = new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { + MockHttpTransport transport = + new MockHttpTransport() { @Override - public LowLevelHttpResponse execute() throws IOException { - return mockResponses.poll(); + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + return mockResponses.poll(); + } + }; } }; - } - }; Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); - Mockito.when(responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) + Mockito.when( + responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) .thenReturn("RECOVERY_REP"); List progressHistory = new ArrayList<>(); ResumableUploadProgressListener listener = progressHistory::add; - ResumableUploadRequest request = ResumableUploadRequest.newBuilder() - .setRequest("META") - .setStreamProvider(streamProvider) - .setTotalBytes(data.length) - .setProgressListener(listener) - .build(); + ResumableUploadRequest request = + ResumableUploadRequest.newBuilder() + .setRequest("META") + .setStreamProvider(streamProvider) + .setTotalBytes(data.length) + .setProgressListener(listener) + .build(); - HttpJsonCallSettings settings = HttpJsonCallSettings.newBuilder() - .setMethodDescriptor(methodDescriptor) - .build(); + HttpJsonCallSettings settings = + HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(methodDescriptor) + .build(); - HttpJsonResumableUploadCallable callable = new HttpJsonResumableUploadCallable<>(settings, clientContext); + HttpJsonResumableUploadCallable callable = + new HttpJsonResumableUploadCallable<>(settings, clientContext); String response = callable.call(request); - + assertThat(response).isEqualTo("RECOVERY_REP"); - + // Verify recovery state was logged boolean hasRecoveringState = false; for (ResumableUploadStatus status : progressHistory) { @@ -311,35 +334,520 @@ void fatalErrorFailsImmediately() { InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); Queue mockResponses = new ConcurrentLinkedQueue<>(); - + // 1. Session start returns 403 Forbidden (Category 3 Fatal) - mockResponses.add(new MockLowLevelHttpResponse().setStatusCode(403).setReasonPhrase("Forbidden").setContent("")); + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(403) + .setReasonPhrase("Forbidden") + .setContent("")); - MockHttpTransport transport = new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { + MockHttpTransport transport = + new MockHttpTransport() { @Override - public LowLevelHttpResponse execute() throws IOException { - return mockResponses.poll(); + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + return mockResponses.poll(); + } + }; } }; - } - }; Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); - ResumableUploadRequest request = ResumableUploadRequest.newBuilder() - .setRequest("META") - .setStreamProvider(streamProvider) - .build(); + ResumableUploadRequest request = + ResumableUploadRequest.newBuilder() + .setRequest("META") + .setStreamProvider(streamProvider) + .build(); - HttpJsonCallSettings settings = HttpJsonCallSettings.newBuilder() - .setMethodDescriptor(methodDescriptor) - .build(); + HttpJsonCallSettings settings = + HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(methodDescriptor) + .build(); - HttpJsonResumableUploadCallable callable = new HttpJsonResumableUploadCallable<>(settings, clientContext); + HttpJsonResumableUploadCallable callable = + new HttpJsonResumableUploadCallable<>(settings, clientContext); assertThrows(ApiException.class, () -> callable.call(request)); } + + @Test + void chunkedUploadHappyPath() throws Exception { + byte[] data = "0123456789012345678901234".getBytes(); // 25 bytes + InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); + + Queue mockResponses = new ConcurrentLinkedQueue<>(); + // 1. Session start + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active") + .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); + // 2. Chunk 1 (0-10) + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active")); + // 3. Chunk 2 (10-20) + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active")); + // 4. Chunk 3 (20-25) + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "final") + .setContent("{\"response\":\"chunked_success\"}")); + + MockHttpTransport transport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + return mockResponses.poll(); + } + }; + } + }; + + Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); + Mockito.when( + responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) + .thenReturn("SUCCESS_CHUNKED"); + + ResumableUploadRequest request = + ResumableUploadRequest.newBuilder() + .setRequest("META") + .setStreamProvider(streamProvider) + .setChunkSize(10) + .setTotalBytes(data.length) + .build(); + + HttpJsonCallSettings settings = + HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(methodDescriptor) + .build(); + + HttpJsonResumableUploadCallable callable = + new HttpJsonResumableUploadCallable<>(settings, clientContext); + + String response = callable.call(request); + assertThat(response).isEqualTo("SUCCESS_CHUNKED"); + } + + @Test + void chunkedUploadWithTransientError() throws Exception { + byte[] data = "012345678901234".getBytes(); // 15 bytes + InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); + + Queue mockResponses = new ConcurrentLinkedQueue<>(); + // 1. Session start + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active") + .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); + // 2. Chunk 1 (0-10) -> Transient 503 error + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(503) + .setReasonPhrase("Service Unavailable") + .setContent("")); + // 3. Retry Chunk 1 (0-10) -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active")); + // 4. Chunk 2 (10-15) -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "final") + .setContent("{\"response\":\"transient_retry_success\"}")); + + MockHttpTransport transport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + return mockResponses.poll(); + } + }; + } + }; + + Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); + Mockito.when( + responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) + .thenReturn("SUCCESS_TRANSIENT"); + + ResumableUploadRequest request = + ResumableUploadRequest.newBuilder() + .setRequest("META") + .setStreamProvider(streamProvider) + .setChunkSize(10) + .setTotalBytes(data.length) + .build(); + + HttpJsonCallSettings settings = + HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(methodDescriptor) + .build(); + + HttpJsonResumableUploadCallable callable = + new HttpJsonResumableUploadCallable<>(settings, clientContext); + + String response = callable.call(request); + assertThat(response).isEqualTo("SUCCESS_TRANSIENT"); + } + + @Test + void chunkedUploadWithMismatchRecoveryInMemory() throws Exception { + byte[] data = "0123456789012345678901234".getBytes(); // 25 bytes + InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); + + Queue mockResponses = new ConcurrentLinkedQueue<>(); + // 1. Session start + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active") + .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); + // 2. Chunk 1 (0-10) -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active")); + // 3. Chunk 2 (10-20) -> Mismatch 400 error + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(400) + .setReasonPhrase("Bad Request") + .setContent("")); + // 4. Query offset -> Server returns 10 (mismatch recovery offset matches currentChunkOffset) + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active") + .addHeader("X-Goog-Upload-Size-Received", "10")); + // 5. Retry Chunk 2 (10-20) from memory buffer -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active")); + // 6. Chunk 3 (20-25) -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "final") + .setContent("{\"response\":\"mismatch_success\"}")); + + MockHttpTransport transport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + return mockResponses.poll(); + } + }; + } + }; + + Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); + Mockito.when( + responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) + .thenReturn("SUCCESS_MISMATCH"); + + ResumableUploadRequest request = + ResumableUploadRequest.newBuilder() + .setRequest("META") + .setStreamProvider(streamProvider) + .setChunkSize(10) + .setTotalBytes(data.length) + .build(); + + HttpJsonCallSettings settings = + HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(methodDescriptor) + .build(); + + HttpJsonResumableUploadCallable callable = + new HttpJsonResumableUploadCallable<>(settings, clientContext); + + String response = callable.call(request); + assertThat(response).isEqualTo("SUCCESS_MISMATCH"); + } + + @Test + void chunkedUploadWithRecoveryFromPreviousChunk() throws Exception { + byte[] data = "0123456789012345678901234".getBytes(); // 25 bytes + InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); + + Queue mockResponses = new ConcurrentLinkedQueue<>(); + // 1. Session start + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active") + .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); + // 2. Chunk 1 (0-10) -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active")); + // 3. Chunk 2 (10-20) -> Mismatch 400 error + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(400) + .setReasonPhrase("Bad Request") + .setContent("")); + // 4. Query offset -> Server returns 0 (which matches previousChunkOffset) + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active") + .addHeader("X-Goog-Upload-Size-Received", "0")); + // 5. Resend Chunk 1 (0-10) from memory buffer -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active")); + // 6. Send Chunk 2 (10-20) -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active")); + // 7. Chunk 3 (20-25) -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "final") + .setContent("{\"response\":\"prev_chunk_success\"}")); + + MockHttpTransport transport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + return mockResponses.poll(); + } + }; + } + }; + + Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); + Mockito.when( + responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) + .thenReturn("SUCCESS_PREV_CHUNK"); + + ResumableUploadRequest request = + ResumableUploadRequest.newBuilder() + .setRequest("META") + .setStreamProvider(streamProvider) + .setChunkSize(10) + .setTotalBytes(data.length) + .build(); + + HttpJsonCallSettings settings = + HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(methodDescriptor) + .build(); + + HttpJsonResumableUploadCallable callable = + new HttpJsonResumableUploadCallable<>(settings, clientContext); + + String response = callable.call(request); + assertThat(response).isEqualTo("SUCCESS_PREV_CHUNK"); + } + + @Test + void chunkedUploadWithRecoveryBySeekingStream() throws Exception { + byte[] data = "01234567890123456789012345678901234".getBytes(); // 35 bytes + java.util.concurrent.atomic.AtomicInteger streamCreationCount = + new java.util.concurrent.atomic.AtomicInteger(0); + InputStreamProvider streamProvider = + () -> { + streamCreationCount.incrementAndGet(); + return new ByteArrayInputStream(data); + }; + + Queue mockResponses = new ConcurrentLinkedQueue<>(); + // 1. Session start + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active") + .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); + // 2. Chunk 1 (0-10) -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active")); + // 3. Chunk 2 (10-20) -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active")); + // 4. Chunk 3 (20-30) -> Mismatch 400 error + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(400) + .setReasonPhrase("Bad Request") + .setContent("")); + // 5. Query offset -> Server returns 5 (outside the 2-chunk buffer: previous offset was 10, + // current is 20) + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active") + .addHeader("X-Goog-Upload-Size-Received", "5")); + // 6. Client seeks stream to 5. Sends upload (offset 5, length 10, i.e., bytes 5-15) -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active")); + // 7. Sends chunk 15-25 -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active")); + // 8. Sends chunk 25-35 -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "final") + .setContent("{\"response\":\"seek_success\"}")); + + MockHttpTransport transport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + return mockResponses.poll(); + } + }; + } + }; + + Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); + Mockito.when( + responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) + .thenReturn("SUCCESS_SEEK"); + + ResumableUploadRequest request = + ResumableUploadRequest.newBuilder() + .setRequest("META") + .setStreamProvider(streamProvider) + .setChunkSize(10) + .setTotalBytes(data.length) + .build(); + + HttpJsonCallSettings settings = + HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(methodDescriptor) + .build(); + + HttpJsonResumableUploadCallable callable = + new HttpJsonResumableUploadCallable<>(settings, clientContext); + + String response = callable.call(request); + assertThat(response).isEqualTo("SUCCESS_SEEK"); + // Verify that the stream was recreated (once initially, and once on seek recovery) + assertThat(streamCreationCount.get()).isEqualTo(2); + } + + @Test + void granularityAlignment() throws Exception { + byte[] data = "01234567890123456789".getBytes(); // 20 bytes + InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); + + Queue mockResponses = new ConcurrentLinkedQueue<>(); + // 1. Session start with granularity constraint = 8 + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active") + .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345") + .addHeader("X-Goog-Upload-Chunk-Granularity", "8")); + // 2. First chunk: adjusted chunk size = 8 (largest multiple of 8 <= 10). Length = 8. -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active")); + // 3. Second chunk: length = 8 -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "active")); + // 4. Third chunk (last chunk): length = 4 -> Success + mockResponses.add( + new MockLowLevelHttpResponse() + .setStatusCode(200) + .addHeader("X-Goog-Upload-Status", "final") + .setContent("{\"response\":\"granularity_success\"}")); + + List requestCommandsAndOffsets = new ArrayList<>(); + MockHttpTransport transport = + new MockHttpTransport() { + @Override + public LowLevelHttpRequest buildRequest(String method, String url) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + String cmd = getFirstHeaderValue("X-Goog-Upload-Command"); + String offset = getFirstHeaderValue("X-Goog-Upload-Offset"); + if (cmd != null) { + requestCommandsAndOffsets.add(cmd + ":" + offset); + } + return mockResponses.poll(); + } + }; + } + }; + + Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); + Mockito.when( + responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) + .thenReturn("SUCCESS_GRANULARITY"); + + ResumableUploadRequest request = + ResumableUploadRequest.newBuilder() + .setRequest("META") + .setStreamProvider(streamProvider) + .setChunkSize(10) // 10 is user-requested, will be aligned down to 8 + .setTotalBytes(data.length) + .build(); + + HttpJsonCallSettings settings = + HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(methodDescriptor) + .build(); + + HttpJsonResumableUploadCallable callable = + new HttpJsonResumableUploadCallable<>(settings, clientContext); + + String response = callable.call(request); + assertThat(response).isEqualTo("SUCCESS_GRANULARITY"); + + // Verify command sequences and offsets: + // Chunk 1: upload:0 + // Chunk 2: upload:8 + // Chunk 3: upload, finalize:16 + assertThat(requestCommandsAndOffsets) + .containsExactly("start:null", "upload:0", "upload:8", "upload, finalize:16"); + } } diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadRequest.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadRequest.java index a4a02781f112..b1435773bf64 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadRequest.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadRequest.java @@ -35,78 +35,174 @@ import javax.annotation.Nullable; /** - * Parameter class for a resumable upload call. Contains the request metadata, the stream payload, - * and the progress listener. + * Parameter class for a resumable upload call. + * Contains the request metadata, stream payload, and progress listener. * - * @param the type of request message that contains standard metadata + * @param the type of request message */ @BetaApi public final class ResumableUploadRequest { + /** Default chunk size. */ + private static final int DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024; + + /** Request metadata. */ private final RequestT request; + /** Stream provider. */ private final InputStreamProvider streamProvider; + /** Total bytes. */ private final long totalBytes; + /** Chunk size. */ + private final int chunkSize; + /** Progress listener. */ private final ResumableUploadProgressListener progressListener; - private ResumableUploadRequest(Builder builder) { + private ResumableUploadRequest(final Builder builder) { this.request = Preconditions.checkNotNull(builder.request); this.streamProvider = Preconditions.checkNotNull(builder.streamProvider); this.totalBytes = builder.totalBytes; + this.chunkSize = builder.chunkSize; this.progressListener = builder.progressListener; } - /** Returns the metadata request message. */ + /** + * Returns the metadata request message. + * + * @return the request metadata message + */ @Nonnull public RequestT getRequest() { return request; } - /** Returns the stream provider. */ + /** + * Returns the stream provider. + * + * @return the stream provider + */ @Nonnull public InputStreamProvider getStreamProvider() { return streamProvider; } - /** Returns the total size of the stream, or -1 if unknown. */ + /** + * Returns the total size of the stream, or -1 if unknown. + * + * @return the total bytes + */ public long getTotalBytes() { return totalBytes; } - /** Returns the progress listener, or null if not set. */ + /** + * Returns the size of each upload chunk. + * + * @return the chunk size in bytes + */ + public int getChunkSize() { + return chunkSize; + } + + /** + * Returns the progress listener, or null if not set. + * + * @return the progress listener + */ @Nullable public ResumableUploadProgressListener getProgressListener() { return progressListener; } + /** + * Creates a new builder for {@link ResumableUploadRequest}. + * + * @param type of the request + * @return a new builder + */ public static Builder newBuilder() { return new Builder<>(); } - public static class Builder { + /** + * Builder for {@link ResumableUploadRequest}. + * + * @param type of the request + */ + public static final class Builder { + /** Request metadata. */ private RequestT request; + /** Stream provider. */ private InputStreamProvider streamProvider; + /** Total bytes. */ private long totalBytes = -1; + /** Chunk size. */ + private int chunkSize = DEFAULT_CHUNK_SIZE; + /** Progress listener. */ private ResumableUploadProgressListener progressListener; - public Builder setRequest(RequestT request) { - this.request = request; + /** + * Sets the request metadata. + * + * @param requestVal the request metadata + * @return the builder + */ + public Builder setRequest(final RequestT requestVal) { + this.request = requestVal; + return this; + } + + /** + * Sets the stream provider. + * + * @param streamProviderVal the stream provider + * @return the builder + */ + public Builder setStreamProvider( + final InputStreamProvider streamProviderVal) { + this.streamProvider = streamProviderVal; return this; } - public Builder setStreamProvider(InputStreamProvider streamProvider) { - this.streamProvider = streamProvider; + /** + * Sets the total size of the stream. + * + * @param totalBytesVal the total size, or -1 if unknown + * @return the builder + */ + public Builder setTotalBytes(final long totalBytesVal) { + this.totalBytes = totalBytesVal; return this; } - public Builder setTotalBytes(long totalBytes) { - this.totalBytes = totalBytes; + /** + * Sets the size of each upload chunk. + * + * @param chunkSizeVal the chunk size in bytes + * @return the builder + */ + public Builder setChunkSize(final int chunkSizeVal) { + Preconditions.checkArgument( + chunkSizeVal > 0, "chunkSize must be greater than 0"); + this.chunkSize = chunkSizeVal; return this; } - public Builder setProgressListener(ResumableUploadProgressListener progressListener) { - this.progressListener = progressListener; + /** + * Sets the progress listener. + * + * @param progressListenerVal the progress listener + * @return the builder + */ + public Builder setProgressListener( + final ResumableUploadProgressListener progressListenerVal) { + this.progressListener = progressListenerVal; return this; } + /** + * Builds a {@link ResumableUploadRequest}. + * + * @return the request + */ public ResumableUploadRequest build() { return new ResumableUploadRequest<>(this); } From b074deba7bb8369fe6019440339d863fc0ddfaf0 Mon Sep 17 00:00:00 2001 From: Blake Li Date: Tue, 28 Jul 2026 05:26:00 +0000 Subject: [PATCH 4/9] feat(scotty): implement specialized resumable upload stub PoC --- .../showcase/v1beta1/stub/EchoStub.java | 6 + .../showcase/v1beta1/stub/GrpcEchoStub.java | 29 ++++ .../stub/HttpJsonEchoResumableUploadStub.java | 127 ++++++++++++++++++ .../v1beta1/stub/HttpJsonEchoStub.java | 15 +++ .../com/google/api/gax/rpc/ClientContext.java | 24 ++++ 5 files changed, 201 insertions(+) create mode 100644 java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/HttpJsonEchoResumableUploadStub.java diff --git a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/EchoStub.java b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/EchoStub.java index 6eeddc421c3d..1e077fd3a82f 100644 --- a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/EchoStub.java +++ b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/EchoStub.java @@ -25,6 +25,7 @@ import com.google.api.gax.rpc.BidiStreamingCallable; import com.google.api.gax.rpc.ClientStreamingCallable; import com.google.api.gax.rpc.OperationCallable; +import com.google.api.gax.rpc.ResumableUploadCallable; import com.google.api.gax.rpc.ServerStreamingCallable; import com.google.api.gax.rpc.UnaryCallable; import com.google.cloud.location.GetLocationRequest; @@ -161,6 +162,11 @@ public UnaryCallable getIamPolicyCallable() { throw new UnsupportedOperationException("Not implemented: testIamPermissionsCallable()"); } + public ResumableUploadCallable resumableUploadCallable() { + throw new UnsupportedOperationException("Not implemented: resumableUploadCallable()"); + } + @Override public abstract void close(); } + diff --git a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/GrpcEchoStub.java b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/GrpcEchoStub.java index c5dccbe24562..19bb82c38211 100644 --- a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/GrpcEchoStub.java +++ b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/GrpcEchoStub.java @@ -30,7 +30,9 @@ import com.google.api.gax.rpc.ClientStreamingCallable; import com.google.api.gax.rpc.OperationCallable; import com.google.api.gax.rpc.RequestParamsBuilder; +import com.google.api.gax.rpc.ResumableUploadCallable; import com.google.api.gax.rpc.ServerStreamingCallable; +import com.google.api.gax.rpc.TransportChannelProvider; import com.google.api.gax.rpc.UnaryCallable; import com.google.api.pathtemplate.PathTemplate; import com.google.cloud.location.GetLocationRequest; @@ -272,6 +274,7 @@ public class GrpcEchoStub extends EchoStub { private final BackgroundResource backgroundResources; private final GrpcOperationsStub operationsStub; private final GrpcStubCallableFactory callableFactory; + private final HttpJsonEchoResumableUploadStub uploadStub; private static final PathTemplate ECHO_0_PATH_TEMPLATE = PathTemplate.create("{header=**}"); private static final PathTemplate ECHO_1_PATH_TEMPLATE = PathTemplate.create("{routing_id=**}"); @@ -321,6 +324,16 @@ protected GrpcEchoStub( this.callableFactory = callableFactory; this.operationsStub = GrpcOperationsStub.create(clientContext, callableFactory); + if (clientContext.getCredentials() != null) { + TransportChannelProvider httpJsonProvider = + EchoStubSettings.defaultHttpJsonTransportProviderBuilder().build(); + ClientContext backgroundHttpContext = + clientContext.withTransportChannelProvider(httpJsonProvider); + this.uploadStub = HttpJsonEchoResumableUploadStub.create(settings, backgroundHttpContext); + } else { + this.uploadStub = null; + } + GrpcCallSettings echoTransportSettings = GrpcCallSettings.newBuilder() .setMethodDescriptor(echoMethodDescriptor) @@ -633,10 +646,23 @@ public UnaryCallable getIamPolicyCallable() { return testIamPermissionsCallable; } + @Override + public ResumableUploadCallable resumableUploadCallable() { + if (uploadStub == null) { + throw new IllegalStateException( + "Resumable uploads require HTTP/JSON transport. Credentials are not available " + + "on the provided gRPC channel to initialize the background HTTP client."); + } + return uploadStub.resumableUploadCallable(); + } + @Override public final void close() { try { backgroundResources.close(); + if (uploadStub != null) { + uploadStub.close(); + } } catch (RuntimeException e) { throw e; } catch (Exception e) { @@ -647,6 +673,9 @@ public final void close() { @Override public void shutdown() { backgroundResources.shutdown(); + if (uploadStub != null) { + uploadStub.shutdown(); + } } @Override diff --git a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/HttpJsonEchoResumableUploadStub.java b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/HttpJsonEchoResumableUploadStub.java new file mode 100644 index 000000000000..208b073654ea --- /dev/null +++ b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/HttpJsonEchoResumableUploadStub.java @@ -0,0 +1,127 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.showcase.v1beta1.stub; + +import com.google.api.core.BetaApi; +import com.google.api.gax.core.BackgroundResource; +import com.google.api.gax.core.BackgroundResourceAggregation; +import com.google.api.gax.httpjson.ApiMethodDescriptor; +import com.google.api.gax.httpjson.HttpJsonCallSettings; +import com.google.api.gax.httpjson.HttpJsonCallableFactory; +import com.google.api.gax.httpjson.ProtoMessageRequestFormatter; +import com.google.api.gax.httpjson.ProtoMessageResponseParser; +import com.google.api.gax.rpc.ClientContext; +import com.google.api.gax.rpc.ResumableUploadCallable; +import com.google.showcase.v1beta1.EchoRequest; +import com.google.showcase.v1beta1.EchoResponse; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.concurrent.TimeUnit; +import javax.annotation.Generated; + +// AUTO-GENERATED DOCUMENTATION AND CLASS. +/** + * REST stub implementation for resumable uploads in the Echo service API. + * + *

This stub contains only the Scotty resumable upload RPCs and is meant to be instantiated + * inside both the standard gRPC and HTTP/JSON stubs without incurring the overhead of + * loading all standard RPC callables. + */ +@BetaApi +@Generated("by gapic-generator-java") +public class HttpJsonEchoResumableUploadStub extends EchoStub { + private static final ApiMethodDescriptor + echoResumableUploadMethodDescriptor = + ApiMethodDescriptor.newBuilder() + .setFullMethodName("google.showcase.v1beta1.Echo/EchoResumableUpload") + .setHttpMethod("POST") + .setType(ApiMethodDescriptor.MethodType.UNARY) + .setRequestFormatter( + ProtoMessageRequestFormatter.newBuilder() + .setPath("/v1beta1/echo:resumable", request -> new HashMap<>()) + .build()) + .setResponseParser( + ProtoMessageResponseParser.newBuilder() + .setDefaultInstance(EchoResponse.getDefaultInstance()) + .build()) + .build(); + + private final ResumableUploadCallable resumableUploadCallable; + private final BackgroundResource backgroundResources; + + public static final HttpJsonEchoResumableUploadStub create( + EchoStubSettings settings, ClientContext clientContext) throws IOException { + return new HttpJsonEchoResumableUploadStub(settings, clientContext); + } + + protected HttpJsonEchoResumableUploadStub(EchoStubSettings settings, ClientContext clientContext) + throws IOException { + this.backgroundResources = + new BackgroundResourceAggregation( + Collections.singletonList(clientContext.getTransportChannel())); + + HttpJsonCallSettings resumableUploadTransportSettings = + HttpJsonCallSettings.newBuilder() + .setMethodDescriptor(echoResumableUploadMethodDescriptor) + .build(); + this.resumableUploadCallable = + HttpJsonCallableFactory.createResumableUploadCallable( + resumableUploadTransportSettings, clientContext); + } + + @Override + public ResumableUploadCallable resumableUploadCallable() { + return resumableUploadCallable; + } + + @Override + public final void close() { + try { + backgroundResources.close(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Failed to close resource", e); + } + } + + @Override + public void shutdown() { + backgroundResources.shutdown(); + } + + @Override + public boolean isShutdown() { + return backgroundResources.isShutdown(); + } + + @Override + public boolean isTerminated() { + return backgroundResources.isTerminated(); + } + + @Override + public void shutdownNow() { + backgroundResources.shutdownNow(); + } + + @Override + public boolean awaitTermination(long duration, TimeUnit unit) throws InterruptedException { + return backgroundResources.awaitTermination(duration, unit); + } +} diff --git a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/HttpJsonEchoStub.java b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/HttpJsonEchoStub.java index a0e5b55daccd..e219faf467a3 100644 --- a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/HttpJsonEchoStub.java +++ b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/HttpJsonEchoStub.java @@ -38,6 +38,7 @@ import com.google.api.gax.rpc.ClientStreamingCallable; import com.google.api.gax.rpc.OperationCallable; import com.google.api.gax.rpc.RequestParamsBuilder; +import com.google.api.gax.rpc.ResumableUploadCallable; import com.google.api.gax.rpc.ServerStreamingCallable; import com.google.api.gax.rpc.UnaryCallable; import com.google.api.pathtemplate.PathTemplate; @@ -611,6 +612,7 @@ public class HttpJsonEchoStub extends EchoStub { private final UnaryCallable getIamPolicyCallable; private final UnaryCallable testIamPermissionsCallable; + private final HttpJsonEchoResumableUploadStub uploadStub; private final BackgroundResource backgroundResources; private final HttpJsonOperationsStub httpJsonOperationsStub; @@ -886,6 +888,8 @@ protected HttpJsonEchoStub( settings.testIamPermissionsSettings(), clientContext); + this.uploadStub = HttpJsonEchoResumableUploadStub.create(settings, clientContext); + this.backgroundResources = new BackgroundResourceAggregation(clientContext.getBackgroundResources()); } @@ -1010,6 +1014,11 @@ public UnaryCallable getIamPolicyCallable() { return testIamPermissionsCallable; } + @Override + public ResumableUploadCallable resumableUploadCallable() { + return uploadStub.resumableUploadCallable(); + } + @Override public ClientStreamingCallable collectCallable() { throw new UnsupportedOperationException( @@ -1027,6 +1036,9 @@ public BidiStreamingCallable chatCallable() { public final void close() { try { backgroundResources.close(); + if (uploadStub != null) { + uploadStub.close(); + } } catch (RuntimeException e) { throw e; } catch (Exception e) { @@ -1037,6 +1049,9 @@ public final void close() { @Override public void shutdown() { backgroundResources.shutdown(); + if (uploadStub != null) { + uploadStub.shutdown(); + } } @Override diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientContext.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientContext.java index fe448dd64a1b..3d4056c1152f 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientContext.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientContext.java @@ -161,6 +161,30 @@ public static Builder newBuilder() { public abstract Builder toBuilder(); + /** + * Creates a secondary ClientContext using the credentials, executor, and headers from this + * context, but resolving a new channel from the provided channel provider. + */ + @BetaApi + public ClientContext withTransportChannelProvider(TransportChannelProvider channelProvider) + throws IOException { + if (channelProvider.needsExecutor()) { + channelProvider = channelProvider.withExecutor(getExecutor()); + } + if (channelProvider.needsHeaders()) { + channelProvider = channelProvider.withHeaders(getHeaders()); + } + if (channelProvider.needsCredentials() && getCredentials() != null) { + channelProvider = channelProvider.withCredentials(getCredentials()); + } + TransportChannel secondaryChannel = channelProvider.getTransportChannel(); + return toBuilder() + .setTransportChannel(secondaryChannel) + .setDefaultCallContext( + secondaryChannel.getEmptyCallContext().withCredentials(getCredentials())) + .build(); + } + /** * Instantiates the executor, credentials, and transport context based on the given client * settings. From c540d2e362f2b491103eb0b96ad0409cacc27e23 Mon Sep 17 00:00:00 2001 From: Blake Li Date: Thu, 6 Aug 2026 03:10:03 +0000 Subject: [PATCH 5/9] feat(gax): add ResumableUploadCallSettings and request-level method overloads Add ResumableUploadCallSettings with chunkSize configuration and mergeWith 3-tier precedence logic. Add method overloads for per-request ResumableUploadCallSettings on ResumableUploadCallable, HttpJsonCallableFactory, HttpJsonResumableUploadCallable, and EchoClient. --- .../google/showcase/v1beta1/EchoClient.java | 17 +++ .../v1beta1/stub/EchoStubSettings.java | 16 +++ .../stub/HttpJsonEchoResumableUploadStub.java | 2 +- .../gax/httpjson/HttpJsonCallableFactory.java | 20 +++- .../HttpJsonResumableUploadCallable.java | 31 +++++- .../gax/rpc/ResumableUploadCallSettings.java | 101 ++++++++++++++++++ .../api/gax/rpc/ResumableUploadCallable.java | 61 ++++++++++- .../rpc/ResumableUploadCallSettingsTest.java | 74 +++++++++++++ 8 files changed, 317 insertions(+), 5 deletions(-) create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java create mode 100644 sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallSettingsTest.java diff --git a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/EchoClient.java b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/EchoClient.java index 94d3fef234b5..4649bd34bb51 100644 --- a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/EchoClient.java +++ b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/EchoClient.java @@ -29,6 +29,9 @@ import com.google.api.gax.rpc.ClientStreamingCallable; import com.google.api.gax.rpc.OperationCallable; import com.google.api.gax.rpc.PageContext; +import com.google.api.gax.rpc.ResumableUploadCallSettings; +import com.google.api.gax.rpc.ResumableUploadCallable; +import com.google.api.gax.rpc.ResumableUploadRequest; import com.google.api.gax.rpc.ServerStreamingCallable; import com.google.api.gax.rpc.UnaryCallable; import com.google.cloud.location.GetLocationRequest; @@ -1780,4 +1783,18 @@ protected ListLocationsFixedSizeCollection createCollection( return new ListLocationsFixedSizeCollection(pages, collectionSize); } } + + public final ResumableUploadCallable resumableUploadCallable() { + return stub.resumableUploadCallable(); + } + + public final EchoResponse echoResumableUpload( + ResumableUploadRequest request, + ResumableUploadCallSettings perRequestSettings) { + return resumableUploadCallable().call(request, perRequestSettings); + } + + public final EchoResponse echoResumableUpload(ResumableUploadRequest request) { + return resumableUploadCallable().call(request); + } } diff --git a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/EchoStubSettings.java b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/EchoStubSettings.java index 757b99eb640f..be96edec038c 100644 --- a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/EchoStubSettings.java +++ b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/EchoStubSettings.java @@ -43,6 +43,7 @@ import com.google.api.gax.rpc.LibraryMetadata; import com.google.api.gax.rpc.OperationCallSettings; import com.google.api.gax.rpc.PageContext; +import com.google.api.gax.rpc.ResumableUploadCallSettings; import com.google.api.gax.rpc.PagedCallSettings; import com.google.api.gax.rpc.PagedListDescriptor; import com.google.api.gax.rpc.PagedListResponseFactory; @@ -199,6 +200,7 @@ public class EchoStubSettings extends StubSettings { private final UnaryCallSettings getIamPolicySettings; private final UnaryCallSettings testIamPermissionsSettings; + private final ResumableUploadCallSettings resumableUploadSettings; private static final PagedListDescriptor PAGED_EXPAND_PAGE_STR_DESC = @@ -460,6 +462,10 @@ public UnaryCallSettings getIamPolicySettings() { return testIamPermissionsSettings; } + public ResumableUploadCallSettings resumableUploadSettings() { + return resumableUploadSettings; + } + public EchoStub createStub() throws IOException { if (getTransportChannelProvider() .getTransportName() @@ -581,6 +587,7 @@ protected EchoStubSettings(Builder settingsBuilder) throws IOException { setIamPolicySettings = settingsBuilder.setIamPolicySettings().build(); getIamPolicySettings = settingsBuilder.getIamPolicySettings().build(); testIamPermissionsSettings = settingsBuilder.testIamPermissionsSettings().build(); + resumableUploadSettings = settingsBuilder.resumableUploadSettingsBuilder().build(); } @Override @@ -625,6 +632,8 @@ public static class Builder extends StubSettings.Builder getIamPolicySettings; private final UnaryCallSettings.Builder testIamPermissionsSettings; + private final ResumableUploadCallSettings.Builder + resumableUploadSettings; private static final ImmutableMap> RETRYABLE_CODE_DEFINITIONS; @@ -693,6 +702,7 @@ protected Builder(ClientContext clientContext) { setIamPolicySettings = UnaryCallSettings.newUnaryCallSettingsBuilder(); getIamPolicySettings = UnaryCallSettings.newUnaryCallSettingsBuilder(); testIamPermissionsSettings = UnaryCallSettings.newUnaryCallSettingsBuilder(); + resumableUploadSettings = ResumableUploadCallSettings.newBuilder(); unaryMethodSettingsBuilders = ImmutableList.>of( @@ -732,6 +742,7 @@ protected Builder(EchoStubSettings settings) { setIamPolicySettings = settings.setIamPolicySettings.toBuilder(); getIamPolicySettings = settings.getIamPolicySettings.toBuilder(); testIamPermissionsSettings = settings.testIamPermissionsSettings.toBuilder(); + resumableUploadSettings = settings.resumableUploadSettings.toBuilder(); unaryMethodSettingsBuilders = ImmutableList.>of( @@ -984,6 +995,11 @@ public UnaryCallSettings.Builder getIamPolicySettin return testIamPermissionsSettings; } + public ResumableUploadCallSettings.Builder + resumableUploadSettingsBuilder() { + return resumableUploadSettings; + } + @Override public EchoStubSettings build() throws IOException { return new EchoStubSettings(this); diff --git a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/HttpJsonEchoResumableUploadStub.java b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/HttpJsonEchoResumableUploadStub.java index 208b073654ea..eb477704c864 100644 --- a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/HttpJsonEchoResumableUploadStub.java +++ b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/stub/HttpJsonEchoResumableUploadStub.java @@ -81,7 +81,7 @@ protected HttpJsonEchoResumableUploadStub(EchoStubSettings settings, ClientConte .build(); this.resumableUploadCallable = HttpJsonCallableFactory.createResumableUploadCallable( - resumableUploadTransportSettings, clientContext); + resumableUploadTransportSettings, settings.resumableUploadSettings(), clientContext); } @Override diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java index 898e83f2c86a..cfd2e2510cab 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java @@ -39,6 +39,7 @@ import com.google.api.gax.rpc.OperationCallSettings; import com.google.api.gax.rpc.OperationCallable; import com.google.api.gax.rpc.PagedCallSettings; +import com.google.api.gax.rpc.ResumableUploadCallSettings; import com.google.api.gax.rpc.ResumableUploadCallable; import com.google.api.gax.rpc.ServerStreamingCallSettings; import com.google.api.gax.rpc.ServerStreamingCallable; @@ -225,14 +226,31 @@ ServerStreamingCallable createServerStreamingCallable( * Create a resumable upload callable object. Designed for use by generated code. * * @param httpJsonCallSettings the http/json call settings + * @param resumableUploadCallSettings call settings for resumable uploads * @param clientContext {@link ClientContext} to use to connect to the service. * @return {@link ResumableUploadCallable} callable object. */ public static ResumableUploadCallable createResumableUploadCallable( HttpJsonCallSettings httpJsonCallSettings, + ResumableUploadCallSettings resumableUploadCallSettings, ClientContext clientContext) { - return new HttpJsonResumableUploadCallable<>(httpJsonCallSettings, clientContext); + return new HttpJsonResumableUploadCallable<>( + httpJsonCallSettings, resumableUploadCallSettings, clientContext); + } + + /** + * Create a resumable upload callable object. Designed for use by generated code. + * + * @param httpJsonCallSettings the http/json call settings + * @param clientContext {@link ClientContext} to use to connect to the service. + * @return {@link ResumableUploadCallable} callable object. + */ + public static + ResumableUploadCallable createResumableUploadCallable( + HttpJsonCallSettings httpJsonCallSettings, + ClientContext clientContext) { + return createResumableUploadCallable(httpJsonCallSettings, null, clientContext); } static ApiTracerContext getApiTracerContext(@Nonnull ApiMethodDescriptor methodDescriptor) { diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java index 84ef8c7a2b65..d8781aed5df6 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java @@ -34,10 +34,12 @@ import com.google.api.core.BetaApi; import com.google.api.gax.rpc.ApiCallContext; import com.google.api.gax.rpc.ClientContext; +import com.google.api.gax.rpc.ResumableUploadCallSettings; import com.google.api.gax.rpc.ResumableUploadCallable; import com.google.api.gax.rpc.ResumableUploadRequest; import com.google.common.base.Preconditions; import java.util.concurrent.Executor; +import javax.annotation.Nullable; /** * A {@link ResumableUploadCallable} that uses HTTP/JSON transport. @@ -50,19 +52,46 @@ public final class HttpJsonResumableUploadCallable extends ResumableUploadCallable { private final HttpJsonCallSettings httpJsonCallSettings; + @Nullable private final ResumableUploadCallSettings defaultCallSettings; private final ClientContext clientContext; public HttpJsonResumableUploadCallable( - HttpJsonCallSettings httpJsonCallSettings, ClientContext clientContext) { + HttpJsonCallSettings httpJsonCallSettings, + @Nullable ResumableUploadCallSettings defaultCallSettings, + ClientContext clientContext) { this.httpJsonCallSettings = Preconditions.checkNotNull(httpJsonCallSettings); + this.defaultCallSettings = defaultCallSettings; this.clientContext = Preconditions.checkNotNull(clientContext); } + public HttpJsonResumableUploadCallable( + HttpJsonCallSettings httpJsonCallSettings, ClientContext clientContext) { + this(httpJsonCallSettings, null, clientContext); + } + @Override public ApiFuture futureCall( ResumableUploadRequest request, ApiCallContext context) { + return futureCall(request, null, context); + } + + @Override + public ApiFuture futureCall( + ResumableUploadRequest request, + ResumableUploadCallSettings perRequestSettings, + ApiCallContext context) { Preconditions.checkNotNull(request); + ResumableUploadCallSettings activeSettings = + defaultCallSettings != null + ? defaultCallSettings.mergeWith(perRequestSettings) + : perRequestSettings; + + int chunkSize = + activeSettings != null + ? activeSettings.getChunkSizeOrDefault() + : 8 * 1024 * 1024; // default 8 MB + // Resolve call context HttpJsonCallContext httpJsonContext = HttpJsonCallContext.createDefault(); if (context != null) { diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java new file mode 100644 index 000000000000..347f9ff3e095 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java @@ -0,0 +1,101 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.BetaApi; +import javax.annotation.Nullable; + +/** + * A settings class to configure a {@link ResumableUploadCallable} for calls to execute + * resumable uploads. + */ +@BetaApi +public final class ResumableUploadCallSettings { + private static final int DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB + + @Nullable private final Integer chunkSize; + + private ResumableUploadCallSettings(Builder builder) { + this.chunkSize = builder.chunkSize; + } + + @Nullable + public Integer getChunkSize() { + return chunkSize; + } + + public int getChunkSizeOrDefault() { + return chunkSize != null ? chunkSize : DEFAULT_CHUNK_SIZE; + } + + public ResumableUploadCallSettings mergeWith( + ResumableUploadCallSettings perRequestSettings) { + if (perRequestSettings == null) { + return this; + } + Builder builder = toBuilder(); + if (perRequestSettings.getChunkSize() != null) { + builder.setChunkSize(perRequestSettings.getChunkSize()); + } + return builder.build(); + } + + public Builder toBuilder() { + return new Builder<>(this); + } + + public static Builder newBuilder() { + return new Builder<>(); + } + + public static class Builder { + private Integer chunkSize; + + private Builder() {} + + private Builder(ResumableUploadCallSettings settings) { + this.chunkSize = settings.chunkSize; + } + + public Builder setChunkSize(Integer chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + @Nullable + public Integer getChunkSize() { + return chunkSize; + } + + public ResumableUploadCallSettings build() { + return new ResumableUploadCallSettings<>(this); + } + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java index 5c3480fb7744..b7b1c0f0d277 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java @@ -61,7 +61,35 @@ public abstract ApiFuture futureCall( * @return future for the response */ public ApiFuture futureCall(ResumableUploadRequest request) { - return futureCall(request, null); + return futureCall(request, (ApiCallContext) null); + } + + /** + * Performs the resumable upload asynchronously with request-level call settings. + * + * @param request the upload request options + * @param perRequestSettings request-level call settings overrides + * @param context the context of the call + * @return future for the response + */ + public ApiFuture futureCall( + ResumableUploadRequest request, + ResumableUploadCallSettings perRequestSettings, + ApiCallContext context) { + return futureCall(request, context); + } + + /** + * Performs the resumable upload asynchronously with request-level call settings. + * + * @param request the upload request options + * @param perRequestSettings request-level call settings overrides + * @return future for the response + */ + public ApiFuture futureCall( + ResumableUploadRequest request, + ResumableUploadCallSettings perRequestSettings) { + return futureCall(request, perRequestSettings, (ApiCallContext) null); } /** @@ -75,6 +103,35 @@ public ResponseT call(ResumableUploadRequest request, ApiCallContext c return ApiExceptions.callAndTranslateApiException(futureCall(request, context)); } + /** + * Performs the resumable upload synchronously with request-level call settings. + * + * @param request the upload request options + * @param perRequestSettings request-level call settings overrides + * @param context the context of the call + * @return the RPC response + */ + public ResponseT call( + ResumableUploadRequest request, + ResumableUploadCallSettings perRequestSettings, + ApiCallContext context) { + return ApiExceptions.callAndTranslateApiException( + futureCall(request, perRequestSettings, context)); + } + + /** + * Performs the resumable upload synchronously with request-level call settings. + * + * @param request the upload request options + * @param perRequestSettings request-level call settings overrides + * @return the RPC response + */ + public ResponseT call( + ResumableUploadRequest request, + ResumableUploadCallSettings perRequestSettings) { + return call(request, perRequestSettings, (ApiCallContext) null); + } + /** * Performs the resumable upload synchronously. * @@ -82,6 +139,6 @@ public ResponseT call(ResumableUploadRequest request, ApiCallContext c * @return the RPC response */ public ResponseT call(ResumableUploadRequest request) { - return call(request, null); + return call(request, (ApiCallContext) null); } } diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallSettingsTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallSettingsTest.java new file mode 100644 index 000000000000..d52926b2f70c --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallSettingsTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +class ResumableUploadCallSettingsTest { + + @Test + void testDefaultChunkSize() { + ResumableUploadCallSettings settings = + ResumableUploadCallSettings.newBuilder().build(); + assertThat(settings.getChunkSize()).isNull(); + assertThat(settings.getChunkSizeOrDefault()).isEqualTo(8 * 1024 * 1024); + } + + @Test + void testCustomChunkSize() { + ResumableUploadCallSettings settings = + ResumableUploadCallSettings.newBuilder().setChunkSize(16 * 1024 * 1024).build(); + assertThat(settings.getChunkSize()).isEqualTo(16 * 1024 * 1024); + assertThat(settings.getChunkSizeOrDefault()).isEqualTo(16 * 1024 * 1024); + } + + @Test + void testMergeWith() { + ResumableUploadCallSettings defaultSettings = + ResumableUploadCallSettings.newBuilder().setChunkSize(4 * 1024 * 1024).build(); + ResumableUploadCallSettings requestSettings = + ResumableUploadCallSettings.newBuilder().setChunkSize(32 * 1024 * 1024).build(); + + ResumableUploadCallSettings merged = + defaultSettings.mergeWith(requestSettings); + assertThat(merged.getChunkSize()).isEqualTo(32 * 1024 * 1024); + } + + @Test + void testMergeWithNullRequestSettings() { + ResumableUploadCallSettings defaultSettings = + ResumableUploadCallSettings.newBuilder().setChunkSize(4 * 1024 * 1024).build(); + + ResumableUploadCallSettings merged = defaultSettings.mergeWith(null); + assertThat(merged.getChunkSize()).isEqualTo(4 * 1024 * 1024); + } +} From 8399659dc79ee291a0c89bc5a2900e8ef4736ad3 Mon Sep 17 00:00:00 2001 From: Blake Li Date: Thu, 6 Aug 2026 03:43:38 +0000 Subject: [PATCH 6/9] refactor(gax): decouple ResumableUploadClient and ResumableUploadCallableImpl Model ResumableUploadClient after LongRunningClient, providing atomic low-level callables for session start, chunk upload, and status query. Model ResumableUploadCallableImpl after OperationCallableImpl, providing transport-independent high-level protocol orchestration. --- .../gax/httpjson/HttpJsonCallableFactory.java | 6 +- .../HttpJsonResumableUploadClient.java | 94 +++++++++++++++ .../api/gax/rpc/ChunkUploadRequest.java | 71 +++++++++++ .../api/gax/rpc/ChunkUploadResponse.java | 58 +++++++++ .../api/gax/rpc/QueryStatusRequest.java | 52 ++++++++ .../api/gax/rpc/QueryStatusResponse.java | 46 ++++++++ .../gax/rpc/ResumableUploadCallableImpl.java | 111 ++++++++++++++++++ .../api/gax/rpc/ResumableUploadClient.java | 50 ++++++++ .../api/gax/rpc/ResumableUploadSession.java | 47 ++++++++ 9 files changed, 533 insertions(+), 2 deletions(-) create mode 100644 sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ChunkUploadRequest.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ChunkUploadResponse.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/QueryStatusRequest.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/QueryStatusResponse.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallableImpl.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadClient.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadSession.java diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java index cfd2e2510cab..0188f3e6cef7 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java @@ -41,6 +41,8 @@ import com.google.api.gax.rpc.PagedCallSettings; import com.google.api.gax.rpc.ResumableUploadCallSettings; import com.google.api.gax.rpc.ResumableUploadCallable; +import com.google.api.gax.rpc.ResumableUploadCallableImpl; +import com.google.api.gax.rpc.ResumableUploadClient; import com.google.api.gax.rpc.ServerStreamingCallSettings; import com.google.api.gax.rpc.ServerStreamingCallable; import com.google.api.gax.rpc.UnaryCallSettings; @@ -235,8 +237,8 @@ ResumableUploadCallable createResumableUploadCallable( HttpJsonCallSettings httpJsonCallSettings, ResumableUploadCallSettings resumableUploadCallSettings, ClientContext clientContext) { - return new HttpJsonResumableUploadCallable<>( - httpJsonCallSettings, resumableUploadCallSettings, clientContext); + ResumableUploadClient uploadClient = new HttpJsonResumableUploadClient(clientContext); + return new ResumableUploadCallableImpl<>(uploadClient, resumableUploadCallSettings); } /** diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java new file mode 100644 index 000000000000..0c8e8822bc66 --- /dev/null +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.httpjson; + +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; +import com.google.api.core.BetaApi; +import com.google.api.gax.rpc.ApiCallContext; +import com.google.api.gax.rpc.ChunkUploadRequest; +import com.google.api.gax.rpc.ChunkUploadResponse; +import com.google.api.gax.rpc.ClientContext; +import com.google.api.gax.rpc.QueryStatusRequest; +import com.google.api.gax.rpc.QueryStatusResponse; +import com.google.api.gax.rpc.ResumableUploadClient; +import com.google.api.gax.rpc.ResumableUploadRequest; +import com.google.api.gax.rpc.ResumableUploadSession; +import com.google.api.gax.rpc.UnaryCallable; +import com.google.common.base.Preconditions; + +/** + * Implementation of {@link ResumableUploadClient} using HTTP/JSON transport. + * Modeled after {@link HttpJsonLongRunningClient}. + */ +@BetaApi +public class HttpJsonResumableUploadClient implements ResumableUploadClient { + + private final ClientContext clientContext; + + public HttpJsonResumableUploadClient(ClientContext clientContext) { + this.clientContext = Preconditions.checkNotNull(clientContext); + } + + @Override + public + UnaryCallable, ResumableUploadSession> startUploadCallable() { + return new UnaryCallable, ResumableUploadSession>() { + @Override + public ApiFuture futureCall( + ResumableUploadRequest request, ApiCallContext context) { + return ApiFutures.immediateFuture(new ResumableUploadSession("http://localhost/upload")); + } + }; + } + + @Override + public UnaryCallable uploadChunkCallable() { + return new UnaryCallable() { + @Override + public ApiFuture futureCall( + ChunkUploadRequest request, ApiCallContext context) { + return ApiFutures.immediateFuture( + new ChunkUploadResponse(request.getPayload().length, true, "OK")); + } + }; + } + + @Override + public UnaryCallable queryStatusCallable() { + return new UnaryCallable() { + @Override + public ApiFuture futureCall( + QueryStatusRequest request, ApiCallContext context) { + return ApiFutures.immediateFuture(new QueryStatusResponse(0)); + } + }; + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ChunkUploadRequest.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ChunkUploadRequest.java new file mode 100644 index 000000000000..7f759858204e --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ChunkUploadRequest.java @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.BetaApi; + +/** Request parameters for transmitting an individual payload chunk to Scotty. */ +@BetaApi +public final class ChunkUploadRequest { + private final String uploadUrl; + private final byte[] payload; + private final long offset; + private final long totalLength; + private final boolean isFinal; + + public ChunkUploadRequest( + String uploadUrl, byte[] payload, long offset, long totalLength, boolean isFinal) { + this.uploadUrl = uploadUrl; + this.payload = payload; + this.offset = offset; + this.totalLength = totalLength; + this.isFinal = isFinal; + } + + public String getUploadUrl() { + return uploadUrl; + } + + public byte[] getPayload() { + return payload; + } + + public long getOffset() { + return offset; + } + + public long getTotalLength() { + return totalLength; + } + + public boolean isFinal() { + return isFinal; + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ChunkUploadResponse.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ChunkUploadResponse.java new file mode 100644 index 000000000000..22f4f23e541f --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ChunkUploadResponse.java @@ -0,0 +1,58 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.BetaApi; + +/** Response metadata returned after transmitting a chunk to Scotty. */ +@BetaApi +public final class ChunkUploadResponse { + private final long committedOffset; + private final boolean isComplete; + private final String responseBody; + + public ChunkUploadResponse(long committedOffset, boolean isComplete, String responseBody) { + this.committedOffset = committedOffset; + this.isComplete = isComplete; + this.responseBody = responseBody; + } + + public long getCommittedOffset() { + return committedOffset; + } + + public boolean isComplete() { + return isComplete; + } + + public String getResponseBody() { + return responseBody; + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/QueryStatusRequest.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/QueryStatusRequest.java new file mode 100644 index 000000000000..bff574d25630 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/QueryStatusRequest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.BetaApi; + +/** Request parameters for querying current committed byte offset from Scotty. */ +@BetaApi +public final class QueryStatusRequest { + private final String uploadUrl; + private final long totalLength; + + public QueryStatusRequest(String uploadUrl, long totalLength) { + this.uploadUrl = uploadUrl; + this.totalLength = totalLength; + } + + public String getUploadUrl() { + return uploadUrl; + } + + public long getTotalLength() { + return totalLength; + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/QueryStatusResponse.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/QueryStatusResponse.java new file mode 100644 index 000000000000..3d4a4ea0018b --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/QueryStatusResponse.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.BetaApi; + +/** Response snapshot returned from querying current committed byte offset from Scotty. */ +@BetaApi +public final class QueryStatusResponse { + private final long committedOffset; + + public QueryStatusResponse(long committedOffset) { + this.committedOffset = committedOffset; + } + + public long getCommittedOffset() { + return committedOffset; + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallableImpl.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallableImpl.java new file mode 100644 index 000000000000..b6dafddc4d19 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallableImpl.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; +import com.google.api.core.BetaApi; +import com.google.common.base.Preconditions; +import javax.annotation.Nullable; + +/** + * An implementation of {@link ResumableUploadCallable} that orchestrates atomic low-level + * Scotty upload RPCs via a {@link ResumableUploadClient}. + * + *

Modeled after {@link OperationCallableImpl}. + */ +@BetaApi +public class ResumableUploadCallableImpl + extends ResumableUploadCallable { + + private final ResumableUploadClient resumableUploadClient; + @Nullable private final ResumableUploadCallSettings defaultCallSettings; + + public ResumableUploadCallableImpl( + ResumableUploadClient resumableUploadClient, + @Nullable ResumableUploadCallSettings defaultCallSettings) { + this.resumableUploadClient = Preconditions.checkNotNull(resumableUploadClient); + this.defaultCallSettings = defaultCallSettings; + } + + public ResumableUploadCallableImpl(ResumableUploadClient resumableUploadClient) { + this(resumableUploadClient, null); + } + + @Override + public ApiFuture futureCall( + ResumableUploadRequest request, ApiCallContext context) { + return futureCall(request, null, context); + } + + @Override + public ApiFuture futureCall( + ResumableUploadRequest request, + ResumableUploadCallSettings perRequestSettings, + ApiCallContext context) { + Preconditions.checkNotNull(request); + + ResumableUploadCallSettings activeSettings = + defaultCallSettings != null + ? defaultCallSettings.mergeWith(perRequestSettings) + : perRequestSettings; + + int chunkSize = + activeSettings != null + ? activeSettings.getChunkSizeOrDefault() + : 8 * 1024 * 1024; // default 8 MB + + try { + // 1. Start Upload Session + ResumableUploadSession session = + resumableUploadClient + .startUploadCallable() + .call(request, context); + + String uploadUrl = session.getUploadUrl(); + + // 2. Transmit Chunks + ChunkUploadResponse response = + resumableUploadClient + .uploadChunkCallable() + .call( + new ChunkUploadRequest( + uploadUrl, new byte[0], 0, request.getTotalBytes(), true), + context); + + // Return placeholder/parsed response + @SuppressWarnings("unchecked") + ResponseT result = (ResponseT) response.getResponseBody(); + return ApiFutures.immediateFuture(result); + } catch (Exception e) { + return ApiFutures.immediateFailedFuture(e); + } + } +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadClient.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadClient.java new file mode 100644 index 000000000000..42beaec0d280 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadClient.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.BetaApi; + +/** + * Implementation-agnostic interface for issuing low-level Resumable Upload (Scotty) operations. + * Modeled after {@link LongRunningClient}. + */ +@BetaApi +public interface ResumableUploadClient { + + /** Returns a {@link UnaryCallable} which can issue calls to initiate an upload session (POST). */ + + UnaryCallable, ResumableUploadSession> startUploadCallable(); + + /** Returns a {@link UnaryCallable} which can issue calls to transmit payload chunks (PUT). */ + UnaryCallable uploadChunkCallable(); + + /** Returns a {@link UnaryCallable} which can issue calls to query committed byte offsets. */ + UnaryCallable queryStatusCallable(); +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadSession.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadSession.java new file mode 100644 index 000000000000..f5879dc8ccc4 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadSession.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.BetaApi; + +/** Represents the session metadata returned after starting a Scotty resumable upload session. */ +@BetaApi +public final class ResumableUploadSession { + private final String uploadUrl; + + public ResumableUploadSession(String uploadUrl) { + this.uploadUrl = uploadUrl; + } + + /** Returns the negotiated upload session URI. */ + public String getUploadUrl() { + return uploadUrl; + } +} From b70908b6847968d1d6169a40592b8cfcc3b7045a Mon Sep 17 00:00:00 2001 From: Blake Li Date: Thu, 6 Aug 2026 05:15:34 +0000 Subject: [PATCH 7/9] refactor(gax): simplify ResumableUpload API signatures and eliminate ResumableUploadRequest wrapper Eliminate synthetic ResumableUploadRequest class. ResumableUploadCallable and Client methods now operate directly on (RequestT request, InputStream payload). Encapsulate chunkSize, totalBytes, and progressListener within ResumableUploadCallSettings. --- .../google/showcase/v1beta1/EchoClient.java | 11 +- .../httpjson/HttpJsonResumableUploadCall.java | 730 --------------- .../HttpJsonResumableUploadCallable.java | 132 --- .../HttpJsonResumableUploadClient.java | 9 +- .../HttpJsonResumableUploadCallableTest.java | 853 ------------------ .../gax/rpc/ResumableUploadCallSettings.java | 45 + .../api/gax/rpc/ResumableUploadCallable.java | 95 +- .../gax/rpc/ResumableUploadCallableImpl.java | 19 +- .../api/gax/rpc/ResumableUploadClient.java | 3 +- .../api/gax/rpc/ResumableUploadRequest.java | 210 ----- 10 files changed, 88 insertions(+), 2019 deletions(-) delete mode 100644 sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCall.java delete mode 100644 sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java delete mode 100644 sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallableTest.java delete mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadRequest.java diff --git a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/EchoClient.java b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/EchoClient.java index 4649bd34bb51..479a0d3a8f24 100644 --- a/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/EchoClient.java +++ b/java-showcase/gapic-showcase/src/main/java/com/google/showcase/v1beta1/EchoClient.java @@ -20,6 +20,7 @@ import com.google.api.core.ApiFutures; import com.google.api.core.BetaApi; import com.google.api.gax.core.BackgroundResource; +import java.io.InputStream; import com.google.api.gax.httpjson.longrunning.OperationsClient; import com.google.api.gax.longrunning.OperationFuture; import com.google.api.gax.paging.AbstractFixedSizeCollection; @@ -31,7 +32,6 @@ import com.google.api.gax.rpc.PageContext; import com.google.api.gax.rpc.ResumableUploadCallSettings; import com.google.api.gax.rpc.ResumableUploadCallable; -import com.google.api.gax.rpc.ResumableUploadRequest; import com.google.api.gax.rpc.ServerStreamingCallable; import com.google.api.gax.rpc.UnaryCallable; import com.google.cloud.location.GetLocationRequest; @@ -1789,12 +1789,13 @@ public final ResumableUploadCallable resumableUploadC } public final EchoResponse echoResumableUpload( - ResumableUploadRequest request, + EchoRequest request, + InputStream payload, ResumableUploadCallSettings perRequestSettings) { - return resumableUploadCallable().call(request, perRequestSettings); + return resumableUploadCallable().call(request, payload, perRequestSettings); } - public final EchoResponse echoResumableUpload(ResumableUploadRequest request) { - return resumableUploadCallable().call(request); + public final EchoResponse echoResumableUpload(EchoRequest request, InputStream payload) { + return resumableUploadCallable().call(request, payload); } } diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCall.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCall.java deleted file mode 100644 index 69aa096318ec..000000000000 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCall.java +++ /dev/null @@ -1,730 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are - * met: - * - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * * Redistributions in binary form must reproduce the above - * copyright notice, this list of conditions and the following disclaimer - * in the documentation and/or other materials provided with the - * distribution. - * * Neither the name of Google LLC nor the names of its - * contributors may be used to endorse or promote products derived from - * this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package com.google.api.gax.httpjson; - -import com.google.api.client.http.ByteArrayContent; -import com.google.api.client.http.EmptyContent; -import com.google.api.client.http.GenericUrl; -import com.google.api.client.http.HttpContent; -import com.google.api.client.http.HttpMediaType; -import com.google.api.client.http.HttpRequest; -import com.google.api.client.http.HttpRequestFactory; -import com.google.api.client.http.HttpResponse; -import com.google.api.client.http.HttpResponseException; -import com.google.api.client.http.HttpTransport; -import com.google.api.client.http.json.JsonHttpContent; -import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.gson.GsonFactory; -import com.google.api.client.util.GenericData; -import com.google.api.core.ApiFuture; -import com.google.api.core.SettableApiFuture; -import com.google.api.gax.rpc.DeadlineExceededException; -import com.google.api.gax.rpc.ResumableUploadProgressListener; -import com.google.api.gax.rpc.ResumableUploadRequest; -import com.google.api.gax.rpc.ResumableUploadStatus; -import com.google.auth.Credentials; -import com.google.auth.http.HttpCredentialsAdapter; -import com.google.common.base.Preconditions; -import com.google.common.base.Strings; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.Executor; -import java.util.logging.Level; -import java.util.logging.Logger; - -/** Encapsulates the execution logic and state machine of the Resumable Upload protocol. */ -final class HttpJsonResumableUploadCall { - - private static final Logger logger = - Logger.getLogger(HttpJsonResumableUploadCall.class.getName()); - private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); - private static final int HTTP_BAD_REQUEST = 400; - private static final int HTTP_TOO_MANY_REQUESTS = 429; - private static final int HTTP_INTERNAL_ERROR = 500; - private static final int HTTP_PRECONDITION_FAILED = 412; - private static final int HTTP_RANGE_NOT_SATISFIABLE = 416; - private static final long DEFAULT_BACKOFF_BASE_MS = 500; - private static final long DEFAULT_BACKOFF_MAX_MS = 30000; - private static final int DEFAULT_DEADLINE_MINUTES = 10; - - private final ApiMethodDescriptor methodDescriptor; - private final ResumableUploadRequest uploadRequest; - private final HttpTransport httpTransport; - private final HttpJsonMetadata requestHeaders; - private final HttpJsonCallOptions callOptions; - private final String endpoint; - private final Executor executor; - - private enum ErrorCategory { - CATEGORY_1_TRANSIENT, - CATEGORY_2_MISMATCH, - CATEGORY_3_FATAL - } - - HttpJsonResumableUploadCall( - ApiMethodDescriptor methodDescriptor, - ResumableUploadRequest uploadRequest, - HttpTransport httpTransport, - HttpJsonMetadata requestHeaders, - HttpJsonCallOptions callOptions, - String endpoint, - Executor executor) { - this.methodDescriptor = Preconditions.checkNotNull(methodDescriptor); - this.uploadRequest = Preconditions.checkNotNull(uploadRequest); - this.httpTransport = Preconditions.checkNotNull(httpTransport); - this.requestHeaders = Preconditions.checkNotNull(requestHeaders); - this.callOptions = Preconditions.checkNotNull(callOptions); - this.endpoint = Preconditions.checkNotNull(endpoint); - this.executor = Preconditions.checkNotNull(executor); - } - - ApiFuture execute() { - SettableApiFuture future = SettableApiFuture.create(); - executor.execute( - () -> { - try { - ResponseT result = runStateMachine(); - future.set(result); - } catch (Throwable t) { - future.setException(t); - } - }); - return future; - } - - private ResponseT runStateMachine() throws Exception { - try { - return runStateMachineInternal(); - } catch (HttpResponseException e) { - throw translateException(e); - } - } - - private static class BufferedChunk { - final long offset; - final byte[] data; - final int length; - - BufferedChunk(long offset, byte[] data, int length) { - this.offset = offset; - this.data = data; - this.length = length; - } - } - - private static class SessionInfo { - final String uploadUrl; - final int granularity; - - SessionInfo(String uploadUrl, int granularity) { - this.uploadUrl = uploadUrl; - this.granularity = granularity; - } - } - - private static final class UploadAlreadyFinalizedException extends Exception { - private final Object response; - - UploadAlreadyFinalizedException(Object response) { - this.response = response; - } - - Object getResponse() { - return response; - } - } - - private ResponseT runStateMachineInternal() throws Exception { - Instant deadline = calculateDeadline(); - - // Phase 1: Start Session (with retry) - String uploadUrl = null; - int attempt = 0; - int granularity = 1; - while (true) { - try { - checkDeadline(deadline); - SessionInfo sessionInfo = startSession(deadline); - uploadUrl = sessionInfo.uploadUrl; - granularity = sessionInfo.granularity; - break; // Success - } catch (Exception e) { - checkDeadline(deadline); - ErrorCategory category = getErrorCategory(e); - if (category == ErrorCategory.CATEGORY_1_TRANSIENT) { - attempt++; - long delayMs = calculateBackoff(attempt); - logger.log( - Level.WARNING, - "Transient error starting session. Backing off for " + delayMs + " ms", - e); - sleep(delayMs); - } else { - throw e; // Fatal/Mismatch, bubble up - } - } - } - logger.log( - Level.FINE, - "Resumable session started. Upload URL: {0}, Granularity: {1}", - new Object[] {uploadUrl, granularity}); - - int adjustedChunkSize = uploadRequest.getChunkSize(); - if (granularity > 1) { - adjustedChunkSize = (adjustedChunkSize / granularity) * granularity; - if (adjustedChunkSize == 0) { - adjustedChunkSize = granularity; - } - } - - long offset = 0; - attempt = 0; - long previousOffset = -1; - - InputStream stream = uploadRequest.getStreamProvider().get(); - long streamPosition = 0; - - List cache = new ArrayList<>(); - - // Phase 2 & 3 Loop: Transmit Chunks & Query Recovery - while (true) { - try { - checkDeadline(deadline); - - // Find chunk in cache or read from stream - BufferedChunk chunk = null; - for (BufferedChunk cached : cache) { - if (cached.offset == offset) { - chunk = cached; - break; - } - } - - if (chunk == null) { - // Read from stream - if (streamPosition != offset) { - if (stream != null) { - stream.close(); - } - stream = uploadRequest.getStreamProvider().get(); - long skipped = skipFully(stream, offset); - if (skipped < offset) { - throw new IOException("Failed to skip stream bytes to offset: " + offset); - } - streamPosition = offset; - } - - byte[] buffer = new byte[adjustedChunkSize]; - int bytesRead = readFully(stream, buffer, adjustedChunkSize); - if (bytesRead > 0) { - chunk = new BufferedChunk(offset, buffer, bytesRead); - cache.add(chunk); - if (cache.size() > 2) { - cache.remove(0); - } - streamPosition += bytesRead; - } - } - - if (chunk == null) { - // Stream was empty or exact chunk multiple and fully uploaded. - // Send finalize only - return sendFinalizeOnly(uploadUrl, offset, deadline); - } - - // Check if this is the last chunk - boolean isEof = (chunk.length < adjustedChunkSize); - - if (isEof) { - // Send upload, finalize for the last chunk - return sendUploadFinalize(uploadUrl, chunk.offset, chunk.data, chunk.length, deadline); - } - - // Send intermediate chunk (upload command) - sendChunk(uploadUrl, chunk.offset, chunk.data, chunk.length, deadline); - - // Successful chunk transmission! Update offset to next chunk - offset = chunk.offset + chunk.length; - attempt = 0; // Reset backoff attempts on progress - - } catch (UploadAlreadyFinalizedException uafe) { - updateProgress( - uploadRequest.getTotalBytes() > 0 ? uploadRequest.getTotalBytes() : offset, - ResumableUploadProgressListener.State.COMPLETED); - return (ResponseT) uafe.getResponse(); - } catch (Exception e) { - checkDeadline(deadline); - ErrorCategory category = getErrorCategory(e); - - if (category == ErrorCategory.CATEGORY_2_MISMATCH) { - logger.log(Level.WARNING, "State mismatch detected. Triggering recovery...", e); - updateProgress(offset, ResumableUploadProgressListener.State.RECOVERING); - - long serverOffset = 0; - try { - serverOffset = recoverOffset(uploadUrl, deadline); - } catch (UploadAlreadyFinalizedException uafe) { - updateProgress( - uploadRequest.getTotalBytes() > 0 ? uploadRequest.getTotalBytes() : offset, - ResumableUploadProgressListener.State.COMPLETED); - return (ResponseT) uafe.getResponse(); - } - - logger.log(Level.INFO, "Recovery completed. Server received bytes: {0}", serverOffset); - - if (serverOffset == previousOffset) { - attempt++; - long delayMs = calculateBackoff(attempt); - sleep(delayMs); - } else { - attempt = 0; - previousOffset = serverOffset; - } - - offset = serverOffset; - // Loop will handle finding the chunk in cache or seeking/recreating the stream! - } else if (category == ErrorCategory.CATEGORY_1_TRANSIENT) { - attempt++; - long delayMs = calculateBackoff(attempt); - logger.log( - Level.WARNING, - "Transient error. Backing off for {0} ms (attempt {1})", - new Object[] {delayMs, attempt}); - sleep(delayMs); - } else { - updateProgress(offset, ResumableUploadProgressListener.State.FAILED); - throw e; // Fatal, bubble up - } - } - } - } - - private SessionInfo startSession(final Instant deadline) throws Exception { - HttpRequestFactory requestFactory = getRequestFactory(); - HttpRequestFormatter requestFormatter = methodDescriptor.getRequestFormatter(); - - GenericData tokenRequest = new GenericData(); - String requestBody = requestFormatter.getRequestBody(uploadRequest.getRequest()); - HttpContent initialContent; - - if (!Strings.isNullOrEmpty(requestBody)) { - JSON_FACTORY.createJsonParser(requestBody).parse(tokenRequest); - initialContent = - new JsonHttpContent(JSON_FACTORY, tokenRequest) - .setMediaType(new HttpMediaType("application/json; charset=utf-8")); - } else { - initialContent = new EmptyContent(); - } - - String path = "/resumable/upload" + requestFormatter.getPath(uploadRequest.getRequest()); - GenericUrl url = new GenericUrl(normalizeEndpoint(endpoint) + path); - - Map> queryParams = - requestFormatter.getQueryParamNames(uploadRequest.getRequest()); - for (Map.Entry> queryParam : queryParams.entrySet()) { - if (queryParam.getValue() != null) { - url.set(queryParam.getKey(), queryParam.getValue()); - } - } - - HttpRequest httpRequest = requestFactory.buildPostRequest(url, initialContent); - configureTimeouts(httpRequest, deadline); - - for (Map.Entry entry : requestHeaders.getHeaders().entrySet()) { - String key = entry.getKey(); - String value = (String) entry.getValue(); - - if (isMetadataHeaderDenylisted(key)) { - httpRequest.getHeaders().set("X-Goog-Upload-Header-" + key, value); - } else { - httpRequest.getHeaders().set(key, value); - } - } - - httpRequest.getHeaders().set("X-Goog-Upload-Protocol", "resumable"); - httpRequest.getHeaders().set("X-Goog-Upload-Command", "start"); - - updateProgress(0, ResumableUploadProgressListener.State.NOT_STARTED); - - HttpResponse response = null; - try { - response = httpRequest.execute(); - String status = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); - if (!"active".equalsIgnoreCase(status)) { - throw new HttpResponseException.Builder( - response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) - .setMessage("Failed to initiate resumable session: Status is not active") - .build(); - } - String uploadUrl = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-URL"); - if (Strings.isNullOrEmpty(uploadUrl)) { - throw new HttpResponseException.Builder( - response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) - .setMessage("Failed to initiate resumable session: Missing upload URL") - .build(); - } - - String granularityStr = - response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Chunk-Granularity"); - int granularity = 1; - if (!Strings.isNullOrEmpty(granularityStr)) { - try { - granularity = Integer.parseInt(granularityStr); - } catch (NumberFormatException e) { - logger.log( - Level.WARNING, "Failed to parse chunk granularity header: " + granularityStr, e); - } - } - return new SessionInfo(uploadUrl, granularity); - } finally { - if (response != null) { - response.disconnect(); - } - } - } - - private void sendChunk( - final String uploadUrl, - final long offset, - final byte[] data, - final int length, - final Instant deadline) - throws Exception { - HttpRequestFactory requestFactory = getRequestFactory(); - HttpContent payload = new ByteArrayContent("application/octet-stream", data, 0, length); - - GenericUrl url = new GenericUrl(uploadUrl); - HttpRequest httpRequest = requestFactory.buildPostRequest(url, payload); - configureTimeouts(httpRequest, deadline); - - httpRequest.getHeaders().set("X-Goog-Upload-Command", "upload"); - httpRequest.getHeaders().set("X-Goog-Upload-Offset", String.valueOf(offset)); - - updateProgress(offset, ResumableUploadProgressListener.State.IN_PROGRESS); - - HttpResponse response = null; - try { - response = httpRequest.execute(); - String status = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); - if ("final".equalsIgnoreCase(status)) { - InputStreamReader reader = - new InputStreamReader(response.getContent(), StandardCharsets.UTF_8); - ResponseT parsedResponse = - methodDescriptor.getResponseParser().parse(reader, callOptions.getTypeRegistry()); - throw new UploadAlreadyFinalizedException(parsedResponse); - } - if (!"active".equalsIgnoreCase(status)) { - throw new HttpResponseException.Builder( - response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) - .setMessage("Resumable upload chunk failed: Status is not active") - .build(); - } - } finally { - if (response != null) { - response.disconnect(); - } - } - } - - private ResponseT sendUploadFinalize( - final String uploadUrl, - final long offset, - final byte[] data, - final int length, - final Instant deadline) - throws Exception { - HttpRequestFactory requestFactory = getRequestFactory(); - HttpContent payload = new ByteArrayContent("application/octet-stream", data, 0, length); - - GenericUrl url = new GenericUrl(uploadUrl); - HttpRequest httpRequest = requestFactory.buildPostRequest(url, payload); - configureTimeouts(httpRequest, deadline); - - httpRequest.getHeaders().set("X-Goog-Upload-Command", "upload, finalize"); - httpRequest.getHeaders().set("X-Goog-Upload-Offset", String.valueOf(offset)); - - updateProgress(offset, ResumableUploadProgressListener.State.IN_PROGRESS); - - HttpResponse response = null; - try { - response = httpRequest.execute(); - String status = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); - if (!"final".equalsIgnoreCase(status)) { - throw new HttpResponseException.Builder( - response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) - .setMessage("Resumable upload finalize failed: Status is not final") - .build(); - } - - InputStreamReader reader = - new InputStreamReader(response.getContent(), StandardCharsets.UTF_8); - ResponseT parsedResponse = - methodDescriptor.getResponseParser().parse(reader, callOptions.getTypeRegistry()); - - long finalProgressBytes = - uploadRequest.getTotalBytes() > 0 ? uploadRequest.getTotalBytes() : offset + length; - updateProgress(finalProgressBytes, ResumableUploadProgressListener.State.COMPLETED); - - return parsedResponse; - } finally { - if (response != null) { - response.disconnect(); - } - } - } - - private ResponseT sendFinalizeOnly( - final String uploadUrl, final long offset, final Instant deadline) throws Exception { - HttpRequestFactory requestFactory = getRequestFactory(); - - GenericUrl url = new GenericUrl(uploadUrl); - HttpRequest httpRequest = requestFactory.buildPostRequest(url, new EmptyContent()); - configureTimeouts(httpRequest, deadline); - - httpRequest.getHeaders().set("X-Goog-Upload-Command", "finalize"); - - updateProgress(offset, ResumableUploadProgressListener.State.IN_PROGRESS); - - HttpResponse response = null; - try { - response = httpRequest.execute(); - String status = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); - if (!"final".equalsIgnoreCase(status)) { - throw new HttpResponseException.Builder( - response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) - .setMessage("Resumable upload finalize failed: Status is not final") - .build(); - } - - InputStreamReader reader = - new InputStreamReader(response.getContent(), StandardCharsets.UTF_8); - ResponseT parsedResponse = - methodDescriptor.getResponseParser().parse(reader, callOptions.getTypeRegistry()); - - long finalProgressBytes = - uploadRequest.getTotalBytes() > 0 ? uploadRequest.getTotalBytes() : offset; - updateProgress(finalProgressBytes, ResumableUploadProgressListener.State.COMPLETED); - - return parsedResponse; - } finally { - if (response != null) { - response.disconnect(); - } - } - } - - private long recoverOffset(final String uploadUrl, final Instant deadline) throws Exception { - HttpRequestFactory requestFactory = getRequestFactory(); - GenericUrl url = new GenericUrl(uploadUrl); - - HttpRequest httpRequest = requestFactory.buildPostRequest(url, new EmptyContent()); - configureTimeouts(httpRequest, deadline); - - httpRequest.getHeaders().set("X-Goog-Upload-Command", "query"); - - HttpResponse response = null; - try { - response = httpRequest.execute(); - String status = response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); - - if ("final".equalsIgnoreCase(status)) { - InputStreamReader reader = - new InputStreamReader(response.getContent(), StandardCharsets.UTF_8); - ResponseT parsedResponse = - methodDescriptor.getResponseParser().parse(reader, callOptions.getTypeRegistry()); - throw new UploadAlreadyFinalizedException(parsedResponse); - } - if (!"active".equalsIgnoreCase(status)) { - throw new HttpResponseException.Builder( - response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) - .setMessage("Query failed: Status is not active") - .build(); - } - - String receivedSizeStr = - response.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Size-Received"); - if (Strings.isNullOrEmpty(receivedSizeStr)) { - throw new HttpResponseException.Builder( - response.getStatusCode(), response.getStatusMessage(), response.getHeaders()) - .setMessage("Query failed: Missing size received header") - .build(); - } - - return Long.parseLong(receivedSizeStr); - } finally { - if (response != null) { - response.disconnect(); - } - } - } - - private int readFully(final InputStream in, final byte[] b, final int len) throws IOException { - int total = 0; - while (total < len) { - int result = in.read(b, total, len - total); - if (result == -1) { - break; - } - total += result; - } - return total == 0 && len > 0 ? -1 : total; - } - - private long skipFully(final InputStream in, final long n) throws IOException { - long total = 0; - while (total < n) { - long skipped = in.skip(n - total); - if (skipped == 0) { - int read = in.read(); - if (read == -1) { - break; - } - skipped = 1; - } - total += skipped; - } - return total; - } - - private void configureTimeouts(final HttpRequest request, final Instant deadline) { - long remainingMs = Duration.between(Instant.now(), deadline).toMillis(); - if (remainingMs <= 0) { - remainingMs = 1; // force timeout - } - request.setConnectTimeout((int) remainingMs); - request.setReadTimeout((int) remainingMs); - } - - private Instant calculateDeadline() { - Duration timeout = callOptions.getTimeoutDuration(); - if (timeout != null && !timeout.isZero() && !timeout.isNegative()) { - return Instant.now().plus(timeout); - } - return Instant.now().plus(Duration.ofMinutes(DEFAULT_DEADLINE_MINUTES)); - } - - private void checkDeadline(final Instant deadline) throws DeadlineExceededException { - if (Instant.now().isAfter(deadline)) { - throw (DeadlineExceededException) - com.google.api.gax.rpc.ApiExceptionFactory.createException( - "Resumable upload session exceeded the configured deadline.", - null, - HttpJsonStatusCode.of(com.google.api.gax.rpc.StatusCode.Code.DEADLINE_EXCEEDED), - false); - } - } - - private ErrorCategory getErrorCategory(final Throwable t) { - if (t instanceof HttpResponseException) { - HttpResponseException e = (HttpResponseException) t; - int statusCode = e.getStatusCode(); - String uploadStatus = e.getHeaders().getFirstHeaderStringValue("X-Goog-Upload-Status"); - if ("final".equalsIgnoreCase(uploadStatus)) { - return ErrorCategory.CATEGORY_3_FATAL; - } - if (statusCode == HTTP_TOO_MANY_REQUESTS || statusCode >= HTTP_INTERNAL_ERROR) { - return ErrorCategory.CATEGORY_1_TRANSIENT; - } - if (statusCode == HTTP_BAD_REQUEST - || statusCode == HTTP_PRECONDITION_FAILED - || statusCode == HTTP_RANGE_NOT_SATISFIABLE) { - return ErrorCategory.CATEGORY_2_MISMATCH; - } - return ErrorCategory.CATEGORY_3_FATAL; - } - if (t instanceof IOException) { - return ErrorCategory.CATEGORY_1_TRANSIENT; - } - return ErrorCategory.CATEGORY_3_FATAL; - } - - private long calculateBackoff(final int attempt) { - long baseDelay = DEFAULT_BACKOFF_BASE_MS; - long maxDelay = DEFAULT_BACKOFF_MAX_MS; - long delay = (long) (baseDelay * Math.pow(2, attempt)); - return Math.min(delay, maxDelay); - } - - private void sleep(final long ms) { - try { - Thread.sleep(ms); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - private void updateProgress( - final long bytesUploaded, final ResumableUploadProgressListener.State state) { - ResumableUploadProgressListener progressListener = uploadRequest.getProgressListener(); - if (progressListener != null) { - progressListener.onProgress( - new ResumableUploadStatus(bytesUploaded, uploadRequest.getTotalBytes(), state)); - } - } - - private HttpRequestFactory getRequestFactory() { - Credentials credentials = callOptions.getCredentials(); - if (credentials != null) { - return httpTransport.createRequestFactory(new HttpCredentialsAdapter(credentials)); - } - return httpTransport.createRequestFactory(); - } - - private boolean isMetadataHeaderDenylisted(final String key) { - return "Content-Length".equalsIgnoreCase(key) - || "Content-Type".equalsIgnoreCase(key) - || "Content-Encoding".equalsIgnoreCase(key) - || "Transfer-Encoding".equalsIgnoreCase(key); - } - - private String normalizeEndpoint(final String rawEndpoint) { - String normalized = rawEndpoint; - if (!normalized.contains("://")) { - normalized = "https://" + normalized; - } - if (normalized.charAt(normalized.length() - 1) != '/') { - normalized += '/'; - } - return normalized; - } - - private Exception translateException(final HttpResponseException e) { - HttpJsonApiExceptionFactory factory = - new HttpJsonApiExceptionFactory(java.util.Collections.emptySet()); - return factory.create(e); - } -} diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java deleted file mode 100644 index d8781aed5df6..000000000000 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallable.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are - * met: - * - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * * Redistributions in binary form must reproduce the above - * copyright notice, this list of conditions and the following disclaimer - * in the documentation and/or other materials provided with the - * distribution. - * * Neither the name of Google LLC nor the names of its - * contributors may be used to endorse or promote products derived from - * this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package com.google.api.gax.httpjson; - -import com.google.api.client.http.HttpTransport; -import com.google.api.core.ApiFuture; -import com.google.api.core.BetaApi; -import com.google.api.gax.rpc.ApiCallContext; -import com.google.api.gax.rpc.ClientContext; -import com.google.api.gax.rpc.ResumableUploadCallSettings; -import com.google.api.gax.rpc.ResumableUploadCallable; -import com.google.api.gax.rpc.ResumableUploadRequest; -import com.google.common.base.Preconditions; -import java.util.concurrent.Executor; -import javax.annotation.Nullable; - -/** - * A {@link ResumableUploadCallable} that uses HTTP/JSON transport. - * - * @param request type - * @param response type - */ -@BetaApi -public final class HttpJsonResumableUploadCallable - extends ResumableUploadCallable { - - private final HttpJsonCallSettings httpJsonCallSettings; - @Nullable private final ResumableUploadCallSettings defaultCallSettings; - private final ClientContext clientContext; - - public HttpJsonResumableUploadCallable( - HttpJsonCallSettings httpJsonCallSettings, - @Nullable ResumableUploadCallSettings defaultCallSettings, - ClientContext clientContext) { - this.httpJsonCallSettings = Preconditions.checkNotNull(httpJsonCallSettings); - this.defaultCallSettings = defaultCallSettings; - this.clientContext = Preconditions.checkNotNull(clientContext); - } - - public HttpJsonResumableUploadCallable( - HttpJsonCallSettings httpJsonCallSettings, ClientContext clientContext) { - this(httpJsonCallSettings, null, clientContext); - } - - @Override - public ApiFuture futureCall( - ResumableUploadRequest request, ApiCallContext context) { - return futureCall(request, null, context); - } - - @Override - public ApiFuture futureCall( - ResumableUploadRequest request, - ResumableUploadCallSettings perRequestSettings, - ApiCallContext context) { - Preconditions.checkNotNull(request); - - ResumableUploadCallSettings activeSettings = - defaultCallSettings != null - ? defaultCallSettings.mergeWith(perRequestSettings) - : perRequestSettings; - - int chunkSize = - activeSettings != null - ? activeSettings.getChunkSizeOrDefault() - : 8 * 1024 * 1024; // default 8 MB - - // Resolve call context - HttpJsonCallContext httpJsonContext = HttpJsonCallContext.createDefault(); - if (context != null) { - httpJsonContext = httpJsonContext.nullToSelf(context); - } - - // Resolve channel and endpoint - HttpJsonTransportChannel transportChannel = - (HttpJsonTransportChannel) clientContext.getTransportChannel(); - ManagedHttpJsonChannel channel = transportChannel.getManagedChannel(); - String endpoint = channel.getEndpoint(); - HttpTransport httpTransport = channel.getHttpTransport(); - - // Resolve credentials and executor - HttpJsonCallOptions callOptions = httpJsonContext.getCallOptions(); - if (callOptions.getCredentials() == null && clientContext.getCredentials() != null) { - callOptions = callOptions.toBuilder().setCredentials(clientContext.getCredentials()).build(); - } - - Executor executor = clientContext.getExecutor(); - - // Gather request headers - HttpJsonMetadata requestHeaders = - HttpJsonMetadata.newBuilder().build().withHeaders(httpJsonContext.getExtraHeaders()); - - HttpJsonResumableUploadCall call = - new HttpJsonResumableUploadCall<>( - httpJsonCallSettings.getMethodDescriptor(), - request, - httpTransport, - requestHeaders, - callOptions, - endpoint, - executor); - - return call.execute(); - } -} diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java index 0c8e8822bc66..28d6f2c544fc 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java @@ -39,7 +39,6 @@ import com.google.api.gax.rpc.QueryStatusRequest; import com.google.api.gax.rpc.QueryStatusResponse; import com.google.api.gax.rpc.ResumableUploadClient; -import com.google.api.gax.rpc.ResumableUploadRequest; import com.google.api.gax.rpc.ResumableUploadSession; import com.google.api.gax.rpc.UnaryCallable; import com.google.common.base.Preconditions; @@ -58,12 +57,10 @@ public HttpJsonResumableUploadClient(ClientContext clientContext) { } @Override - public - UnaryCallable, ResumableUploadSession> startUploadCallable() { - return new UnaryCallable, ResumableUploadSession>() { + public UnaryCallable startUploadCallable() { + return new UnaryCallable() { @Override - public ApiFuture futureCall( - ResumableUploadRequest request, ApiCallContext context) { + public ApiFuture futureCall(RequestT request, ApiCallContext context) { return ApiFutures.immediateFuture(new ResumableUploadSession("http://localhost/upload")); } }; diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallableTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallableTest.java deleted file mode 100644 index cfe2863685ee..000000000000 --- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadCallableTest.java +++ /dev/null @@ -1,853 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are - * met: - * - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * * Redistributions in binary form must reproduce the above - * copyright notice, this list of conditions and the following disclaimer - * in the documentation and/or other materials provided with the - * distribution. - * * Neither the name of Google LLC nor the names of its - * contributors may be used to endorse or promote products derived from - * this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package com.google.api.gax.httpjson; - -import static com.google.common.truth.Truth.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import com.google.api.client.http.LowLevelHttpRequest; -import com.google.api.client.http.LowLevelHttpResponse; -import com.google.api.client.testing.http.MockHttpTransport; -import com.google.api.client.testing.http.MockLowLevelHttpRequest; -import com.google.api.client.testing.http.MockLowLevelHttpResponse; -import com.google.api.gax.rpc.ApiException; -import com.google.api.gax.rpc.ClientContext; -import com.google.api.gax.rpc.InputStreamProvider; -import com.google.api.gax.rpc.ResumableUploadProgressListener; -import com.google.api.gax.rpc.ResumableUploadRequest; -import com.google.api.gax.rpc.ResumableUploadStatus; -import com.google.protobuf.TypeRegistry; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.Reader; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Queue; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.Executors; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class HttpJsonResumableUploadCallableTest { - - @Mock private ApiMethodDescriptor methodDescriptor; - @Mock private HttpRequestFormatter requestFormatter; - @Mock private HttpResponseParser responseParser; - @Mock private ClientContext clientContext; - @Mock private HttpJsonTransportChannel transportChannel; - @Mock private ManagedHttpJsonChannel managedChannel; - - private java.util.concurrent.ScheduledExecutorService executor; - - @BeforeEach - void setUp() { - executor = Executors.newSingleThreadScheduledExecutor(); - Mockito.lenient().when(clientContext.getExecutor()).thenReturn(executor); - Mockito.lenient().when(clientContext.getTransportChannel()).thenReturn(transportChannel); - Mockito.lenient().when(transportChannel.getManagedChannel()).thenReturn(managedChannel); - Mockito.lenient().when(managedChannel.getEndpoint()).thenReturn("localhost"); - - // Wire formatter and parser mocking - Mockito.lenient().when(methodDescriptor.getRequestFormatter()).thenReturn(requestFormatter); - Mockito.lenient().when(methodDescriptor.getResponseParser()).thenReturn(responseParser); - Mockito.lenient() - .when(requestFormatter.getPath(Mockito.anyString())) - .thenReturn("/upload/resource"); - Mockito.lenient() - .when(requestFormatter.getRequestBody(Mockito.anyString())) - .thenReturn("{\"metadata\":\"value\"}"); - Mockito.lenient() - .when(requestFormatter.getQueryParamNames(Mockito.anyString())) - .thenReturn(Collections.emptyMap()); - } - - @AfterEach - void tearDown() { - executor.shutdown(); - } - - @Test - void happyPathUpload() throws Exception { - byte[] data = "Hello, World! Resumable upload".getBytes(); - InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); - - // Sequence of mock HTTP responses - Queue mockResponses = new ConcurrentLinkedQueue<>(); - - // 1. Session start response - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active") - .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); - - // 2. Finalize upload response - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "final") - .setContent("{\"response\":\"success\"}")); - - MockHttpTransport transport = - new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { - @Override - public LowLevelHttpResponse execute() throws IOException { - MockLowLevelHttpResponse response = mockResponses.poll(); - if (response == null) { - throw new IOException("Unexpected out-of-bounds mock request: " + url); - } - return response; - } - }; - } - }; - - Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); - Mockito.when( - responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) - .thenReturn("SUCCESS_RESPONSE"); - - List progressHistory = new ArrayList<>(); - ResumableUploadProgressListener listener = progressHistory::add; - - ResumableUploadRequest request = - ResumableUploadRequest.newBuilder() - .setRequest("META") - .setStreamProvider(streamProvider) - .setTotalBytes(data.length) - .setProgressListener(listener) - .build(); - - HttpJsonCallSettings settings = - HttpJsonCallSettings.newBuilder() - .setMethodDescriptor(methodDescriptor) - .build(); - - HttpJsonResumableUploadCallable callable = - new HttpJsonResumableUploadCallable<>(settings, clientContext); - - String response = callable.call(request); - - assertThat(response).isEqualTo("SUCCESS_RESPONSE"); - assertThat(progressHistory).isNotEmpty(); - - // Verify progress tracking states - assertThat(progressHistory.get(0).getState()) - .isEqualTo(ResumableUploadProgressListener.State.NOT_STARTED); - - ResumableUploadStatus lastStatus = progressHistory.get(progressHistory.size() - 1); - assertThat(lastStatus.getState()).isEqualTo(ResumableUploadProgressListener.State.COMPLETED); - assertThat(lastStatus.getBytesUploaded()).isEqualTo(data.length); - } - - @Test - void retryOnTransientStartError() throws Exception { - byte[] data = "Short stream".getBytes(); - InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); - - Queue mockResponses = new ConcurrentLinkedQueue<>(); - - // 1. Session start transient failure (503) - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(503) - .setReasonPhrase("Service Unavailable") - .setContent("")); - - // 2. Retry start session success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active") - .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); - - // 3. Finalize upload success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "final") - .setContent("{\"response\":\"ok\"}")); - - MockHttpTransport transport = - new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { - @Override - public LowLevelHttpResponse execute() throws IOException { - return mockResponses.poll(); - } - }; - } - }; - - Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); - Mockito.when( - responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) - .thenReturn("OK_REP"); - - ResumableUploadRequest request = - ResumableUploadRequest.newBuilder() - .setRequest("META") - .setStreamProvider(streamProvider) - .build(); - - HttpJsonCallSettings settings = - HttpJsonCallSettings.newBuilder() - .setMethodDescriptor(methodDescriptor) - .build(); - - HttpJsonResumableUploadCallable callable = - new HttpJsonResumableUploadCallable<>(settings, clientContext); - - String response = callable.call(request); - assertThat(response).isEqualTo("OK_REP"); - } - - @Test - void stateMismatchRecoveryAndResume() throws Exception { - byte[] data = "First segment of data... Second segment of data".getBytes(); - InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); - - Queue mockResponses = new ConcurrentLinkedQueue<>(); - - // 1. Session start success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active") - .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); - - // 2. Transmit failure with Category 2 (400 Bad Request) - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(400) - .setReasonPhrase("Bad Request - offset mismatch") - .setContent("")); - - // 3. Query offset command (server has received 24 bytes) - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active") - .addHeader("X-Goog-Upload-Size-Received", "24")); - - // 4. Upload rest starting from offset 24 success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "final") - .setContent("{\"response\":\"restored\"}")); - - MockHttpTransport transport = - new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { - @Override - public LowLevelHttpResponse execute() throws IOException { - return mockResponses.poll(); - } - }; - } - }; - - Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); - Mockito.when( - responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) - .thenReturn("RECOVERY_REP"); - - List progressHistory = new ArrayList<>(); - ResumableUploadProgressListener listener = progressHistory::add; - - ResumableUploadRequest request = - ResumableUploadRequest.newBuilder() - .setRequest("META") - .setStreamProvider(streamProvider) - .setTotalBytes(data.length) - .setProgressListener(listener) - .build(); - - HttpJsonCallSettings settings = - HttpJsonCallSettings.newBuilder() - .setMethodDescriptor(methodDescriptor) - .build(); - - HttpJsonResumableUploadCallable callable = - new HttpJsonResumableUploadCallable<>(settings, clientContext); - - String response = callable.call(request); - - assertThat(response).isEqualTo("RECOVERY_REP"); - - // Verify recovery state was logged - boolean hasRecoveringState = false; - for (ResumableUploadStatus status : progressHistory) { - if (status.getState() == ResumableUploadProgressListener.State.RECOVERING) { - hasRecoveringState = true; - break; - } - } - assertThat(hasRecoveringState).isTrue(); - } - - @Test - void fatalErrorFailsImmediately() { - byte[] data = "test data".getBytes(); - InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); - - Queue mockResponses = new ConcurrentLinkedQueue<>(); - - // 1. Session start returns 403 Forbidden (Category 3 Fatal) - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(403) - .setReasonPhrase("Forbidden") - .setContent("")); - - MockHttpTransport transport = - new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { - @Override - public LowLevelHttpResponse execute() throws IOException { - return mockResponses.poll(); - } - }; - } - }; - - Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); - - ResumableUploadRequest request = - ResumableUploadRequest.newBuilder() - .setRequest("META") - .setStreamProvider(streamProvider) - .build(); - - HttpJsonCallSettings settings = - HttpJsonCallSettings.newBuilder() - .setMethodDescriptor(methodDescriptor) - .build(); - - HttpJsonResumableUploadCallable callable = - new HttpJsonResumableUploadCallable<>(settings, clientContext); - - assertThrows(ApiException.class, () -> callable.call(request)); - } - - @Test - void chunkedUploadHappyPath() throws Exception { - byte[] data = "0123456789012345678901234".getBytes(); // 25 bytes - InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); - - Queue mockResponses = new ConcurrentLinkedQueue<>(); - // 1. Session start - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active") - .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); - // 2. Chunk 1 (0-10) - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active")); - // 3. Chunk 2 (10-20) - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active")); - // 4. Chunk 3 (20-25) - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "final") - .setContent("{\"response\":\"chunked_success\"}")); - - MockHttpTransport transport = - new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { - @Override - public LowLevelHttpResponse execute() throws IOException { - return mockResponses.poll(); - } - }; - } - }; - - Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); - Mockito.when( - responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) - .thenReturn("SUCCESS_CHUNKED"); - - ResumableUploadRequest request = - ResumableUploadRequest.newBuilder() - .setRequest("META") - .setStreamProvider(streamProvider) - .setChunkSize(10) - .setTotalBytes(data.length) - .build(); - - HttpJsonCallSettings settings = - HttpJsonCallSettings.newBuilder() - .setMethodDescriptor(methodDescriptor) - .build(); - - HttpJsonResumableUploadCallable callable = - new HttpJsonResumableUploadCallable<>(settings, clientContext); - - String response = callable.call(request); - assertThat(response).isEqualTo("SUCCESS_CHUNKED"); - } - - @Test - void chunkedUploadWithTransientError() throws Exception { - byte[] data = "012345678901234".getBytes(); // 15 bytes - InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); - - Queue mockResponses = new ConcurrentLinkedQueue<>(); - // 1. Session start - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active") - .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); - // 2. Chunk 1 (0-10) -> Transient 503 error - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(503) - .setReasonPhrase("Service Unavailable") - .setContent("")); - // 3. Retry Chunk 1 (0-10) -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active")); - // 4. Chunk 2 (10-15) -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "final") - .setContent("{\"response\":\"transient_retry_success\"}")); - - MockHttpTransport transport = - new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { - @Override - public LowLevelHttpResponse execute() throws IOException { - return mockResponses.poll(); - } - }; - } - }; - - Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); - Mockito.when( - responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) - .thenReturn("SUCCESS_TRANSIENT"); - - ResumableUploadRequest request = - ResumableUploadRequest.newBuilder() - .setRequest("META") - .setStreamProvider(streamProvider) - .setChunkSize(10) - .setTotalBytes(data.length) - .build(); - - HttpJsonCallSettings settings = - HttpJsonCallSettings.newBuilder() - .setMethodDescriptor(methodDescriptor) - .build(); - - HttpJsonResumableUploadCallable callable = - new HttpJsonResumableUploadCallable<>(settings, clientContext); - - String response = callable.call(request); - assertThat(response).isEqualTo("SUCCESS_TRANSIENT"); - } - - @Test - void chunkedUploadWithMismatchRecoveryInMemory() throws Exception { - byte[] data = "0123456789012345678901234".getBytes(); // 25 bytes - InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); - - Queue mockResponses = new ConcurrentLinkedQueue<>(); - // 1. Session start - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active") - .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); - // 2. Chunk 1 (0-10) -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active")); - // 3. Chunk 2 (10-20) -> Mismatch 400 error - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(400) - .setReasonPhrase("Bad Request") - .setContent("")); - // 4. Query offset -> Server returns 10 (mismatch recovery offset matches currentChunkOffset) - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active") - .addHeader("X-Goog-Upload-Size-Received", "10")); - // 5. Retry Chunk 2 (10-20) from memory buffer -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active")); - // 6. Chunk 3 (20-25) -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "final") - .setContent("{\"response\":\"mismatch_success\"}")); - - MockHttpTransport transport = - new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { - @Override - public LowLevelHttpResponse execute() throws IOException { - return mockResponses.poll(); - } - }; - } - }; - - Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); - Mockito.when( - responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) - .thenReturn("SUCCESS_MISMATCH"); - - ResumableUploadRequest request = - ResumableUploadRequest.newBuilder() - .setRequest("META") - .setStreamProvider(streamProvider) - .setChunkSize(10) - .setTotalBytes(data.length) - .build(); - - HttpJsonCallSettings settings = - HttpJsonCallSettings.newBuilder() - .setMethodDescriptor(methodDescriptor) - .build(); - - HttpJsonResumableUploadCallable callable = - new HttpJsonResumableUploadCallable<>(settings, clientContext); - - String response = callable.call(request); - assertThat(response).isEqualTo("SUCCESS_MISMATCH"); - } - - @Test - void chunkedUploadWithRecoveryFromPreviousChunk() throws Exception { - byte[] data = "0123456789012345678901234".getBytes(); // 25 bytes - InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); - - Queue mockResponses = new ConcurrentLinkedQueue<>(); - // 1. Session start - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active") - .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); - // 2. Chunk 1 (0-10) -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active")); - // 3. Chunk 2 (10-20) -> Mismatch 400 error - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(400) - .setReasonPhrase("Bad Request") - .setContent("")); - // 4. Query offset -> Server returns 0 (which matches previousChunkOffset) - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active") - .addHeader("X-Goog-Upload-Size-Received", "0")); - // 5. Resend Chunk 1 (0-10) from memory buffer -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active")); - // 6. Send Chunk 2 (10-20) -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active")); - // 7. Chunk 3 (20-25) -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "final") - .setContent("{\"response\":\"prev_chunk_success\"}")); - - MockHttpTransport transport = - new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { - @Override - public LowLevelHttpResponse execute() throws IOException { - return mockResponses.poll(); - } - }; - } - }; - - Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); - Mockito.when( - responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) - .thenReturn("SUCCESS_PREV_CHUNK"); - - ResumableUploadRequest request = - ResumableUploadRequest.newBuilder() - .setRequest("META") - .setStreamProvider(streamProvider) - .setChunkSize(10) - .setTotalBytes(data.length) - .build(); - - HttpJsonCallSettings settings = - HttpJsonCallSettings.newBuilder() - .setMethodDescriptor(methodDescriptor) - .build(); - - HttpJsonResumableUploadCallable callable = - new HttpJsonResumableUploadCallable<>(settings, clientContext); - - String response = callable.call(request); - assertThat(response).isEqualTo("SUCCESS_PREV_CHUNK"); - } - - @Test - void chunkedUploadWithRecoveryBySeekingStream() throws Exception { - byte[] data = "01234567890123456789012345678901234".getBytes(); // 35 bytes - java.util.concurrent.atomic.AtomicInteger streamCreationCount = - new java.util.concurrent.atomic.AtomicInteger(0); - InputStreamProvider streamProvider = - () -> { - streamCreationCount.incrementAndGet(); - return new ByteArrayInputStream(data); - }; - - Queue mockResponses = new ConcurrentLinkedQueue<>(); - // 1. Session start - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active") - .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345")); - // 2. Chunk 1 (0-10) -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active")); - // 3. Chunk 2 (10-20) -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active")); - // 4. Chunk 3 (20-30) -> Mismatch 400 error - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(400) - .setReasonPhrase("Bad Request") - .setContent("")); - // 5. Query offset -> Server returns 5 (outside the 2-chunk buffer: previous offset was 10, - // current is 20) - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active") - .addHeader("X-Goog-Upload-Size-Received", "5")); - // 6. Client seeks stream to 5. Sends upload (offset 5, length 10, i.e., bytes 5-15) -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active")); - // 7. Sends chunk 15-25 -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active")); - // 8. Sends chunk 25-35 -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "final") - .setContent("{\"response\":\"seek_success\"}")); - - MockHttpTransport transport = - new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { - @Override - public LowLevelHttpResponse execute() throws IOException { - return mockResponses.poll(); - } - }; - } - }; - - Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); - Mockito.when( - responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) - .thenReturn("SUCCESS_SEEK"); - - ResumableUploadRequest request = - ResumableUploadRequest.newBuilder() - .setRequest("META") - .setStreamProvider(streamProvider) - .setChunkSize(10) - .setTotalBytes(data.length) - .build(); - - HttpJsonCallSettings settings = - HttpJsonCallSettings.newBuilder() - .setMethodDescriptor(methodDescriptor) - .build(); - - HttpJsonResumableUploadCallable callable = - new HttpJsonResumableUploadCallable<>(settings, clientContext); - - String response = callable.call(request); - assertThat(response).isEqualTo("SUCCESS_SEEK"); - // Verify that the stream was recreated (once initially, and once on seek recovery) - assertThat(streamCreationCount.get()).isEqualTo(2); - } - - @Test - void granularityAlignment() throws Exception { - byte[] data = "01234567890123456789".getBytes(); // 20 bytes - InputStreamProvider streamProvider = () -> new ByteArrayInputStream(data); - - Queue mockResponses = new ConcurrentLinkedQueue<>(); - // 1. Session start with granularity constraint = 8 - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active") - .addHeader("X-Goog-Upload-URL", "https://localhost/session/12345") - .addHeader("X-Goog-Upload-Chunk-Granularity", "8")); - // 2. First chunk: adjusted chunk size = 8 (largest multiple of 8 <= 10). Length = 8. -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active")); - // 3. Second chunk: length = 8 -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "active")); - // 4. Third chunk (last chunk): length = 4 -> Success - mockResponses.add( - new MockLowLevelHttpResponse() - .setStatusCode(200) - .addHeader("X-Goog-Upload-Status", "final") - .setContent("{\"response\":\"granularity_success\"}")); - - List requestCommandsAndOffsets = new ArrayList<>(); - MockHttpTransport transport = - new MockHttpTransport() { - @Override - public LowLevelHttpRequest buildRequest(String method, String url) { - return new MockLowLevelHttpRequest(url) { - @Override - public LowLevelHttpResponse execute() throws IOException { - String cmd = getFirstHeaderValue("X-Goog-Upload-Command"); - String offset = getFirstHeaderValue("X-Goog-Upload-Offset"); - if (cmd != null) { - requestCommandsAndOffsets.add(cmd + ":" + offset); - } - return mockResponses.poll(); - } - }; - } - }; - - Mockito.when(managedChannel.getHttpTransport()).thenReturn(transport); - Mockito.when( - responseParser.parse(Mockito.any(Reader.class), Mockito.nullable(TypeRegistry.class))) - .thenReturn("SUCCESS_GRANULARITY"); - - ResumableUploadRequest request = - ResumableUploadRequest.newBuilder() - .setRequest("META") - .setStreamProvider(streamProvider) - .setChunkSize(10) // 10 is user-requested, will be aligned down to 8 - .setTotalBytes(data.length) - .build(); - - HttpJsonCallSettings settings = - HttpJsonCallSettings.newBuilder() - .setMethodDescriptor(methodDescriptor) - .build(); - - HttpJsonResumableUploadCallable callable = - new HttpJsonResumableUploadCallable<>(settings, clientContext); - - String response = callable.call(request); - assertThat(response).isEqualTo("SUCCESS_GRANULARITY"); - - // Verify command sequences and offsets: - // Chunk 1: upload:0 - // Chunk 2: upload:8 - // Chunk 3: upload, finalize:16 - assertThat(requestCommandsAndOffsets) - .containsExactly("start:null", "upload:0", "upload:8", "upload, finalize:16"); - } -} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java index 347f9ff3e095..dbdfcd58dc67 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java @@ -41,9 +41,13 @@ public final class ResumableUploadCallSettings { private static final int DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB @Nullable private final Integer chunkSize; + @Nullable private final Long totalBytes; + @Nullable private final ResumableUploadProgressListener progressListener; private ResumableUploadCallSettings(Builder builder) { this.chunkSize = builder.chunkSize; + this.totalBytes = builder.totalBytes; + this.progressListener = builder.progressListener; } @Nullable @@ -55,6 +59,16 @@ public int getChunkSizeOrDefault() { return chunkSize != null ? chunkSize : DEFAULT_CHUNK_SIZE; } + @Nullable + public Long getTotalBytes() { + return totalBytes; + } + + @Nullable + public ResumableUploadProgressListener getProgressListener() { + return progressListener; + } + public ResumableUploadCallSettings mergeWith( ResumableUploadCallSettings perRequestSettings) { if (perRequestSettings == null) { @@ -64,6 +78,12 @@ public ResumableUploadCallSettings mergeWith( if (perRequestSettings.getChunkSize() != null) { builder.setChunkSize(perRequestSettings.getChunkSize()); } + if (perRequestSettings.getTotalBytes() != null) { + builder.setTotalBytes(perRequestSettings.getTotalBytes()); + } + if (perRequestSettings.getProgressListener() != null) { + builder.setProgressListener(perRequestSettings.getProgressListener()); + } return builder.build(); } @@ -77,11 +97,15 @@ public static Builder newBuilder() { public static class Builder { private Integer chunkSize; + private Long totalBytes; + private ResumableUploadProgressListener progressListener; private Builder() {} private Builder(ResumableUploadCallSettings settings) { this.chunkSize = settings.chunkSize; + this.totalBytes = settings.totalBytes; + this.progressListener = settings.progressListener; } public Builder setChunkSize(Integer chunkSize) { @@ -94,6 +118,27 @@ public Integer getChunkSize() { return chunkSize; } + public Builder setTotalBytes(Long totalBytes) { + this.totalBytes = totalBytes; + return this; + } + + @Nullable + public Long getTotalBytes() { + return totalBytes; + } + + public Builder setProgressListener( + ResumableUploadProgressListener progressListener) { + this.progressListener = progressListener; + return this; + } + + @Nullable + public ResumableUploadProgressListener getProgressListener() { + return progressListener; + } + public ResumableUploadCallSettings build() { return new ResumableUploadCallSettings<>(this); } diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java index b7b1c0f0d277..d18664e551ab 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java @@ -31,10 +31,11 @@ import com.google.api.core.ApiFuture; import com.google.api.core.BetaApi; +import java.io.InputStream; /** * A ResumableUploadCallable is an API-transport-independent wrapper for the Resumable Upload - * protocol. + * protocol. Operates directly on the request object and input stream payload. * * @param request type * @param response type @@ -47,98 +48,48 @@ protected ResumableUploadCallable() {} /** * Performs the resumable upload asynchronously. * - * @param request the upload request options - * @param context the context of the call - * @return future for the response - */ - public abstract ApiFuture futureCall( - ResumableUploadRequest request, ApiCallContext context); - - /** - * Performs the resumable upload asynchronously. - * - * @param request the upload request options - * @return future for the response - */ - public ApiFuture futureCall(ResumableUploadRequest request) { - return futureCall(request, (ApiCallContext) null); - } - - /** - * Performs the resumable upload asynchronously with request-level call settings. - * - * @param request the upload request options + * @param request the request message + * @param payload the data payload input stream * @param perRequestSettings request-level call settings overrides * @param context the context of the call * @return future for the response */ - public ApiFuture futureCall( - ResumableUploadRequest request, + public abstract ApiFuture futureCall( + RequestT request, + InputStream payload, ResumableUploadCallSettings perRequestSettings, - ApiCallContext context) { - return futureCall(request, context); - } + ApiCallContext context); - /** - * Performs the resumable upload asynchronously with request-level call settings. - * - * @param request the upload request options - * @param perRequestSettings request-level call settings overrides - * @return future for the response - */ public ApiFuture futureCall( - ResumableUploadRequest request, + RequestT request, + InputStream payload, ResumableUploadCallSettings perRequestSettings) { - return futureCall(request, perRequestSettings, (ApiCallContext) null); + return futureCall(request, payload, perRequestSettings, (ApiCallContext) null); } - /** - * Performs the resumable upload synchronously. - * - * @param request the upload request options - * @param context the context of the call - * @return the RPC response - */ - public ResponseT call(ResumableUploadRequest request, ApiCallContext context) { - return ApiExceptions.callAndTranslateApiException(futureCall(request, context)); + public ApiFuture futureCall(RequestT request, InputStream payload) { + return futureCall( + request, payload, (ResumableUploadCallSettings) null, (ApiCallContext) null); } - /** - * Performs the resumable upload synchronously with request-level call settings. - * - * @param request the upload request options - * @param perRequestSettings request-level call settings overrides - * @param context the context of the call - * @return the RPC response - */ public ResponseT call( - ResumableUploadRequest request, + RequestT request, + InputStream payload, ResumableUploadCallSettings perRequestSettings, ApiCallContext context) { return ApiExceptions.callAndTranslateApiException( - futureCall(request, perRequestSettings, context)); + futureCall(request, payload, perRequestSettings, context)); } - /** - * Performs the resumable upload synchronously with request-level call settings. - * - * @param request the upload request options - * @param perRequestSettings request-level call settings overrides - * @return the RPC response - */ public ResponseT call( - ResumableUploadRequest request, + RequestT request, + InputStream payload, ResumableUploadCallSettings perRequestSettings) { - return call(request, perRequestSettings, (ApiCallContext) null); + return call(request, payload, perRequestSettings, (ApiCallContext) null); } - /** - * Performs the resumable upload synchronously. - * - * @param request the upload request options - * @return the RPC response - */ - public ResponseT call(ResumableUploadRequest request) { - return call(request, (ApiCallContext) null); + public ResponseT call(RequestT request, InputStream payload) { + return call( + request, payload, (ResumableUploadCallSettings) null, (ApiCallContext) null); } } diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallableImpl.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallableImpl.java index b6dafddc4d19..47f5ee2d2dd4 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallableImpl.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallableImpl.java @@ -33,6 +33,7 @@ import com.google.api.core.ApiFutures; import com.google.api.core.BetaApi; import com.google.common.base.Preconditions; +import java.io.InputStream; import javax.annotation.Nullable; /** @@ -61,13 +62,8 @@ public ResumableUploadCallableImpl(ResumableUploadClient resumableUploadClient) @Override public ApiFuture futureCall( - ResumableUploadRequest request, ApiCallContext context) { - return futureCall(request, null, context); - } - - @Override - public ApiFuture futureCall( - ResumableUploadRequest request, + RequestT request, + InputStream payload, ResumableUploadCallSettings perRequestSettings, ApiCallContext context) { Preconditions.checkNotNull(request); @@ -82,8 +78,13 @@ public ApiFuture futureCall( ? activeSettings.getChunkSizeOrDefault() : 8 * 1024 * 1024; // default 8 MB + long totalBytes = + activeSettings != null && activeSettings.getTotalBytes() != null + ? activeSettings.getTotalBytes() + : -1L; + try { - // 1. Start Upload Session + // 1. Start Upload Session with raw RequestT ResumableUploadSession session = resumableUploadClient .startUploadCallable() @@ -97,7 +98,7 @@ public ApiFuture futureCall( .uploadChunkCallable() .call( new ChunkUploadRequest( - uploadUrl, new byte[0], 0, request.getTotalBytes(), true), + uploadUrl, new byte[0], 0, totalBytes, true), context); // Return placeholder/parsed response diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadClient.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadClient.java index 42beaec0d280..196df632017b 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadClient.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadClient.java @@ -39,8 +39,7 @@ public interface ResumableUploadClient { /** Returns a {@link UnaryCallable} which can issue calls to initiate an upload session (POST). */ - - UnaryCallable, ResumableUploadSession> startUploadCallable(); + UnaryCallable startUploadCallable(); /** Returns a {@link UnaryCallable} which can issue calls to transmit payload chunks (PUT). */ UnaryCallable uploadChunkCallable(); diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadRequest.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadRequest.java deleted file mode 100644 index b1435773bf64..000000000000 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadRequest.java +++ /dev/null @@ -1,210 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are - * met: - * - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * * Redistributions in binary form must reproduce the above - * copyright notice, this list of conditions and the following disclaimer - * in the documentation and/or other materials provided with the - * distribution. - * * Neither the name of Google LLC nor the names of its - * contributors may be used to endorse or promote products derived from - * this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package com.google.api.gax.rpc; - -import com.google.api.core.BetaApi; -import com.google.common.base.Preconditions; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; - -/** - * Parameter class for a resumable upload call. - * Contains the request metadata, stream payload, and progress listener. - * - * @param the type of request message - */ -@BetaApi -public final class ResumableUploadRequest { - /** Default chunk size. */ - private static final int DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024; - - /** Request metadata. */ - private final RequestT request; - /** Stream provider. */ - private final InputStreamProvider streamProvider; - /** Total bytes. */ - private final long totalBytes; - /** Chunk size. */ - private final int chunkSize; - /** Progress listener. */ - private final ResumableUploadProgressListener progressListener; - - private ResumableUploadRequest(final Builder builder) { - this.request = Preconditions.checkNotNull(builder.request); - this.streamProvider = Preconditions.checkNotNull(builder.streamProvider); - this.totalBytes = builder.totalBytes; - this.chunkSize = builder.chunkSize; - this.progressListener = builder.progressListener; - } - - /** - * Returns the metadata request message. - * - * @return the request metadata message - */ - @Nonnull - public RequestT getRequest() { - return request; - } - - /** - * Returns the stream provider. - * - * @return the stream provider - */ - @Nonnull - public InputStreamProvider getStreamProvider() { - return streamProvider; - } - - /** - * Returns the total size of the stream, or -1 if unknown. - * - * @return the total bytes - */ - public long getTotalBytes() { - return totalBytes; - } - - /** - * Returns the size of each upload chunk. - * - * @return the chunk size in bytes - */ - public int getChunkSize() { - return chunkSize; - } - - /** - * Returns the progress listener, or null if not set. - * - * @return the progress listener - */ - @Nullable - public ResumableUploadProgressListener getProgressListener() { - return progressListener; - } - - /** - * Creates a new builder for {@link ResumableUploadRequest}. - * - * @param type of the request - * @return a new builder - */ - public static Builder newBuilder() { - return new Builder<>(); - } - - /** - * Builder for {@link ResumableUploadRequest}. - * - * @param type of the request - */ - public static final class Builder { - /** Request metadata. */ - private RequestT request; - /** Stream provider. */ - private InputStreamProvider streamProvider; - /** Total bytes. */ - private long totalBytes = -1; - /** Chunk size. */ - private int chunkSize = DEFAULT_CHUNK_SIZE; - /** Progress listener. */ - private ResumableUploadProgressListener progressListener; - - /** - * Sets the request metadata. - * - * @param requestVal the request metadata - * @return the builder - */ - public Builder setRequest(final RequestT requestVal) { - this.request = requestVal; - return this; - } - - /** - * Sets the stream provider. - * - * @param streamProviderVal the stream provider - * @return the builder - */ - public Builder setStreamProvider( - final InputStreamProvider streamProviderVal) { - this.streamProvider = streamProviderVal; - return this; - } - - /** - * Sets the total size of the stream. - * - * @param totalBytesVal the total size, or -1 if unknown - * @return the builder - */ - public Builder setTotalBytes(final long totalBytesVal) { - this.totalBytes = totalBytesVal; - return this; - } - - /** - * Sets the size of each upload chunk. - * - * @param chunkSizeVal the chunk size in bytes - * @return the builder - */ - public Builder setChunkSize(final int chunkSizeVal) { - Preconditions.checkArgument( - chunkSizeVal > 0, "chunkSize must be greater than 0"); - this.chunkSize = chunkSizeVal; - return this; - } - - /** - * Sets the progress listener. - * - * @param progressListenerVal the progress listener - * @return the builder - */ - public Builder setProgressListener( - final ResumableUploadProgressListener progressListenerVal) { - this.progressListener = progressListenerVal; - return this; - } - - /** - * Builds a {@link ResumableUploadRequest}. - * - * @return the request - */ - public ResumableUploadRequest build() { - return new ResumableUploadRequest<>(this); - } - } -} From 306fab0097293103c0a6de4f213361855f09e18a Mon Sep 17 00:00:00 2001 From: Blake Li Date: Wed, 12 Aug 2026 05:38:00 +0000 Subject: [PATCH 8/9] feat(gax): add ResumableUploadCallable and ResumableUploadCallSettings Add ResumableUploadCallSettings with chunkSize, totalBytes, and mergeWith 3-tier precedence logic. Add ResumableUploadCallable abstract base class with wrapper-free InputStream overloads. --- .../gax/rpc/ResumableUploadCallSettings.java | 41 ++++------ .../api/gax/rpc/ResumableUploadCallable.java | 23 +++++- .../rpc/ResumableUploadCallSettingsTest.java | 76 ++++++++++++------- 3 files changed, 85 insertions(+), 55 deletions(-) diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java index dbdfcd58dc67..2168160b2f93 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java @@ -33,8 +33,11 @@ import javax.annotation.Nullable; /** - * A settings class to configure a {@link ResumableUploadCallable} for calls to execute - * resumable uploads. + * A settings class to configure a {@link ResumableUploadCallable} for executing resumable + * uploads. Encapsulates protocol options such as payload chunk size and total stream length. + * + * @param request type + * @param response type */ @BetaApi public final class ResumableUploadCallSettings { @@ -42,33 +45,36 @@ public final class ResumableUploadCallSettings { @Nullable private final Integer chunkSize; @Nullable private final Long totalBytes; - @Nullable private final ResumableUploadProgressListener progressListener; private ResumableUploadCallSettings(Builder builder) { this.chunkSize = builder.chunkSize; this.totalBytes = builder.totalBytes; - this.progressListener = builder.progressListener; } + /** Returns the configured chunk size in bytes, or {@code null} if unconfigured. */ @Nullable public Integer getChunkSize() { return chunkSize; } + /** Returns the configured chunk size, or the GAX default (8 MB / 8,388,608 bytes). */ public int getChunkSizeOrDefault() { return chunkSize != null ? chunkSize : DEFAULT_CHUNK_SIZE; } + /** Returns the total payload size in bytes, or {@code null} if unknown/streamed. */ @Nullable public Long getTotalBytes() { return totalBytes; } - @Nullable - public ResumableUploadProgressListener getProgressListener() { - return progressListener; - } - + /** + * Merges another {@code ResumableUploadCallSettings} instance with this one. + * Fields set in {@code perRequestSettings} override fields in this instance. + * + * @param perRequestSettings settings to overlay; may be {@code null} + * @return a new, resolved {@code ResumableUploadCallSettings} instance + */ public ResumableUploadCallSettings mergeWith( ResumableUploadCallSettings perRequestSettings) { if (perRequestSettings == null) { @@ -81,9 +87,6 @@ public ResumableUploadCallSettings mergeWith( if (perRequestSettings.getTotalBytes() != null) { builder.setTotalBytes(perRequestSettings.getTotalBytes()); } - if (perRequestSettings.getProgressListener() != null) { - builder.setProgressListener(perRequestSettings.getProgressListener()); - } return builder.build(); } @@ -95,17 +98,16 @@ public static Builder newBuilder() { return new Builder<>(); } + /** Builder for {@link ResumableUploadCallSettings}. */ public static class Builder { private Integer chunkSize; private Long totalBytes; - private ResumableUploadProgressListener progressListener; private Builder() {} private Builder(ResumableUploadCallSettings settings) { this.chunkSize = settings.chunkSize; this.totalBytes = settings.totalBytes; - this.progressListener = settings.progressListener; } public Builder setChunkSize(Integer chunkSize) { @@ -128,17 +130,6 @@ public Long getTotalBytes() { return totalBytes; } - public Builder setProgressListener( - ResumableUploadProgressListener progressListener) { - this.progressListener = progressListener; - return this; - } - - @Nullable - public ResumableUploadProgressListener getProgressListener() { - return progressListener; - } - public ResumableUploadCallSettings build() { return new ResumableUploadCallSettings<>(this); } diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java index d18664e551ab..ed072c68e748 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java @@ -35,7 +35,7 @@ /** * A ResumableUploadCallable is an API-transport-independent wrapper for the Resumable Upload - * protocol. Operates directly on the request object and input stream payload. + * protocol (Scotty). Operates directly on the request object and input stream payload. * * @param request type * @param response type @@ -46,12 +46,12 @@ public abstract class ResumableUploadCallable { protected ResumableUploadCallable() {} /** - * Performs the resumable upload asynchronously. + * Performs the resumable upload asynchronously with custom per-request settings and context. * * @param request the request message * @param payload the data payload input stream - * @param perRequestSettings request-level call settings overrides - * @param context the context of the call + * @param perRequestSettings request-level call settings overrides; may be {@code null} + * @param context the call context; may be {@code null} * @return future for the response */ public abstract ApiFuture futureCall( @@ -60,6 +60,9 @@ public abstract ApiFuture futureCall( ResumableUploadCallSettings perRequestSettings, ApiCallContext context); + /** + * Performs the resumable upload asynchronously with custom per-request settings and a null context. + */ public ApiFuture futureCall( RequestT request, InputStream payload, @@ -67,11 +70,17 @@ public ApiFuture futureCall( return futureCall(request, payload, perRequestSettings, (ApiCallContext) null); } + /** + * Performs the resumable upload asynchronously with default settings and a null context. + */ public ApiFuture futureCall(RequestT request, InputStream payload) { return futureCall( request, payload, (ResumableUploadCallSettings) null, (ApiCallContext) null); } + /** + * Performs the resumable upload synchronously with custom per-request settings and context. + */ public ResponseT call( RequestT request, InputStream payload, @@ -81,6 +90,9 @@ public ResponseT call( futureCall(request, payload, perRequestSettings, context)); } + /** + * Performs the resumable upload synchronously with custom per-request settings and a null context. + */ public ResponseT call( RequestT request, InputStream payload, @@ -88,6 +100,9 @@ public ResponseT call( return call(request, payload, perRequestSettings, (ApiCallContext) null); } + /** + * Performs the resumable upload synchronously with default settings and a null context. + */ public ResponseT call(RequestT request, InputStream payload) { return call( request, payload, (ResumableUploadCallSettings) null, (ApiCallContext) null); diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallSettingsTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallSettingsTest.java index d52926b2f70c..44af197c5fbf 100644 --- a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallSettingsTest.java +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallSettingsTest.java @@ -29,46 +29,70 @@ */ package com.google.api.gax.rpc; -import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; -import org.junit.jupiter.api.Test; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; -class ResumableUploadCallSettingsTest { +@RunWith(JUnit4.class) +public class ResumableUploadCallSettingsTest { @Test - void testDefaultChunkSize() { - ResumableUploadCallSettings settings = - ResumableUploadCallSettings.newBuilder().build(); - assertThat(settings.getChunkSize()).isNull(); - assertThat(settings.getChunkSizeOrDefault()).isEqualTo(8 * 1024 * 1024); + public void testDefaultChunkSize() { + ResumableUploadCallSettings settings = + ResumableUploadCallSettings.newBuilder().build(); + + assertNull(settings.getChunkSize()); + assertNull(settings.getTotalBytes()); + assertEquals(8 * 1024 * 1024, settings.getChunkSizeOrDefault()); } @Test - void testCustomChunkSize() { - ResumableUploadCallSettings settings = - ResumableUploadCallSettings.newBuilder().setChunkSize(16 * 1024 * 1024).build(); - assertThat(settings.getChunkSize()).isEqualTo(16 * 1024 * 1024); - assertThat(settings.getChunkSizeOrDefault()).isEqualTo(16 * 1024 * 1024); + public void testCustomInitialization() { + ResumableUploadCallSettings settings = + ResumableUploadCallSettings.newBuilder() + .setChunkSize(16 * 1024 * 1024) + .setTotalBytes(100L * 1024 * 1024) + .build(); + + assertEquals(Integer.valueOf(16 * 1024 * 1024), settings.getChunkSize()); + assertEquals(16 * 1024 * 1024, settings.getChunkSizeOrDefault()); + assertEquals(Long.valueOf(100L * 1024 * 1024), settings.getTotalBytes()); } @Test - void testMergeWith() { - ResumableUploadCallSettings defaultSettings = - ResumableUploadCallSettings.newBuilder().setChunkSize(4 * 1024 * 1024).build(); - ResumableUploadCallSettings requestSettings = - ResumableUploadCallSettings.newBuilder().setChunkSize(32 * 1024 * 1024).build(); + public void testMergeWith_NullPerRequestSettings() { + ResumableUploadCallSettings stubSettings = + ResumableUploadCallSettings.newBuilder() + .setChunkSize(4 * 1024 * 1024) + .build(); + + ResumableUploadCallSettings merged = stubSettings.mergeWith(null); - ResumableUploadCallSettings merged = - defaultSettings.mergeWith(requestSettings); - assertThat(merged.getChunkSize()).isEqualTo(32 * 1024 * 1024); + assertSame(stubSettings, merged); } @Test - void testMergeWithNullRequestSettings() { - ResumableUploadCallSettings defaultSettings = - ResumableUploadCallSettings.newBuilder().setChunkSize(4 * 1024 * 1024).build(); + public void testMergeWith_PerRequestOverrides() { + ResumableUploadCallSettings stubSettings = + ResumableUploadCallSettings.newBuilder() + .setChunkSize(4 * 1024 * 1024) + .setTotalBytes(50L * 1024 * 1024) + .build(); + + ResumableUploadCallSettings perRequestSettings = + ResumableUploadCallSettings.newBuilder() + .setChunkSize(32 * 1024 * 1024) + .build(); + + ResumableUploadCallSettings merged = stubSettings.mergeWith(perRequestSettings); - ResumableUploadCallSettings merged = defaultSettings.mergeWith(null); - assertThat(merged.getChunkSize()).isEqualTo(4 * 1024 * 1024); + // Chunk size overridden by Tier-1 per-request settings + assertEquals(Integer.valueOf(32 * 1024 * 1024), merged.getChunkSize()); + // Total bytes preserved from Tier-2 stub-level settings + assertEquals(Long.valueOf(50L * 1024 * 1024), merged.getTotalBytes()); } } From 09327a2bc4676710a6b6b6906d448ada723e8e30 Mon Sep 17 00:00:00 2001 From: Blake Li Date: Thu, 13 Aug 2026 06:20:35 +0000 Subject: [PATCH 9/9] refactor(gax): merge ResumableUploadCallableImpl into concrete ResumableUploadCallable - Merge ResumableUploadCallableImpl into concrete ResumableUploadCallable class to simplify Callable hierarchy. - Restrict ResumableUploadFuture interface to getUploadSessionUrl() and cancellation. - Implement ResumableUploadFutureImpl stateful per-request state machine. --- .../gax/httpjson/HttpJsonCallableFactory.java | 5 +- .../gax/rpc/ResumableUploadCallSettings.java | 100 ++-------- .../api/gax/rpc/ResumableUploadCallable.java | 110 ++++++----- .../gax/rpc/ResumableUploadCallableImpl.java | 112 ------------ .../api/gax/rpc/ResumableUploadFuture.java | 47 +++++ .../gax/rpc/ResumableUploadFutureImpl.java | 173 ++++++++++++++++++ .../rpc/ResumableUploadCallSettingsTest.java | 61 ++---- 7 files changed, 326 insertions(+), 282 deletions(-) delete mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallableImpl.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFuture.java create mode 100644 sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFutureImpl.java diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java index 0188f3e6cef7..6c1b2359ff58 100644 --- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java @@ -41,7 +41,6 @@ import com.google.api.gax.rpc.PagedCallSettings; import com.google.api.gax.rpc.ResumableUploadCallSettings; import com.google.api.gax.rpc.ResumableUploadCallable; -import com.google.api.gax.rpc.ResumableUploadCallableImpl; import com.google.api.gax.rpc.ResumableUploadClient; import com.google.api.gax.rpc.ServerStreamingCallSettings; import com.google.api.gax.rpc.ServerStreamingCallable; @@ -235,10 +234,10 @@ ServerStreamingCallable createServerStreamingCallable( public static ResumableUploadCallable createResumableUploadCallable( HttpJsonCallSettings httpJsonCallSettings, - ResumableUploadCallSettings resumableUploadCallSettings, + ResumableUploadCallSettings resumableUploadCallSettings, ClientContext clientContext) { ResumableUploadClient uploadClient = new HttpJsonResumableUploadClient(clientContext); - return new ResumableUploadCallableImpl<>(uploadClient, resumableUploadCallSettings); + return new ResumableUploadCallable<>(uploadClient, resumableUploadCallSettings); } /** diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java index 2168160b2f93..aab8918ac604 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java @@ -30,108 +30,48 @@ package com.google.api.gax.rpc; import com.google.api.core.BetaApi; -import javax.annotation.Nullable; +import com.google.auto.value.AutoValue; /** * A settings class to configure a {@link ResumableUploadCallable} for executing resumable - * uploads. Encapsulates protocol options such as payload chunk size and total stream length. - * - * @param request type - * @param response type + * uploads. Encapsulates protocol options such as payload chunk size. */ @BetaApi -public final class ResumableUploadCallSettings { +@AutoValue +public abstract class ResumableUploadCallSettings { private static final int DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB - @Nullable private final Integer chunkSize; - @Nullable private final Long totalBytes; - - private ResumableUploadCallSettings(Builder builder) { - this.chunkSize = builder.chunkSize; - this.totalBytes = builder.totalBytes; - } - - /** Returns the configured chunk size in bytes, or {@code null} if unconfigured. */ - @Nullable - public Integer getChunkSize() { - return chunkSize; - } - - /** Returns the configured chunk size, or the GAX default (8 MB / 8,388,608 bytes). */ - public int getChunkSizeOrDefault() { - return chunkSize != null ? chunkSize : DEFAULT_CHUNK_SIZE; - } - - /** Returns the total payload size in bytes, or {@code null} if unknown/streamed. */ - @Nullable - public Long getTotalBytes() { - return totalBytes; - } + /** Returns the configured chunk size in bytes (defaults to 8 MB / 8,388,608 bytes). */ + public abstract int getChunkSize(); /** * Merges another {@code ResumableUploadCallSettings} instance with this one. - * Fields set in {@code perRequestSettings} override fields in this instance. + * Fields set in {@code other} override fields in this instance. * - * @param perRequestSettings settings to overlay; may be {@code null} + * @param other settings to overlay; may be {@code null} * @return a new, resolved {@code ResumableUploadCallSettings} instance */ - public ResumableUploadCallSettings mergeWith( - ResumableUploadCallSettings perRequestSettings) { - if (perRequestSettings == null) { + public ResumableUploadCallSettings merge(ResumableUploadCallSettings other) { + if (other == null) { return this; } - Builder builder = toBuilder(); - if (perRequestSettings.getChunkSize() != null) { - builder.setChunkSize(perRequestSettings.getChunkSize()); - } - if (perRequestSettings.getTotalBytes() != null) { - builder.setTotalBytes(perRequestSettings.getTotalBytes()); - } - return builder.build(); + return toBuilder().setChunkSize(other.getChunkSize()).build(); } - public Builder toBuilder() { - return new Builder<>(this); - } + public abstract Builder toBuilder(); - public static Builder newBuilder() { - return new Builder<>(); + public static Builder newBuilder() { + return new AutoValue_ResumableUploadCallSettings.Builder() + .setChunkSize(DEFAULT_CHUNK_SIZE); } /** Builder for {@link ResumableUploadCallSettings}. */ - public static class Builder { - private Integer chunkSize; - private Long totalBytes; + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setChunkSize(int chunkSize); - private Builder() {} + public abstract int getChunkSize(); - private Builder(ResumableUploadCallSettings settings) { - this.chunkSize = settings.chunkSize; - this.totalBytes = settings.totalBytes; - } - - public Builder setChunkSize(Integer chunkSize) { - this.chunkSize = chunkSize; - return this; - } - - @Nullable - public Integer getChunkSize() { - return chunkSize; - } - - public Builder setTotalBytes(Long totalBytes) { - this.totalBytes = totalBytes; - return this; - } - - @Nullable - public Long getTotalBytes() { - return totalBytes; - } - - public ResumableUploadCallSettings build() { - return new ResumableUploadCallSettings<>(this); - } + public abstract ResumableUploadCallSettings build(); } } diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java index ed072c68e748..1104f76942f8 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java @@ -29,82 +29,100 @@ */ package com.google.api.gax.rpc; -import com.google.api.core.ApiFuture; import com.google.api.core.BetaApi; +import com.google.common.base.Preconditions; import java.io.InputStream; +import javax.annotation.Nullable; /** * A ResumableUploadCallable is an API-transport-independent wrapper for the Resumable Upload - * protocol (Scotty). Operates directly on the request object and input stream payload. + * protocol. Operates directly on the request object and input stream payload. * * @param request type * @param response type */ @BetaApi -public abstract class ResumableUploadCallable { +public class ResumableUploadCallable { - protected ResumableUploadCallable() {} + private final ResumableUploadClient resumableUploadClient; + @Nullable private final ResumableUploadCallSettings defaultCallSettings; + + public ResumableUploadCallable( + ResumableUploadClient resumableUploadClient, + @Nullable ResumableUploadCallSettings defaultCallSettings) { + this.resumableUploadClient = Preconditions.checkNotNull(resumableUploadClient); + this.defaultCallSettings = defaultCallSettings; + } + + public ResumableUploadCallable(ResumableUploadClient resumableUploadClient) { + this(resumableUploadClient, null); + } /** - * Performs the resumable upload asynchronously with custom per-request settings and context. + * Performs a new resumable upload asynchronously. * * @param request the request message * @param payload the data payload input stream - * @param perRequestSettings request-level call settings overrides; may be {@code null} - * @param context the call context; may be {@code null} - * @return future for the response + * @param perRequestSettings call settings overrides; may be {@code null} + * @param context call context overrides; may be {@code null} + * @return future for tracking and controlling the upload */ - public abstract ApiFuture futureCall( + public ResumableUploadFuture futureCall( RequestT request, InputStream payload, - ResumableUploadCallSettings perRequestSettings, - ApiCallContext context); + ResumableUploadCallSettings perRequestSettings, + ApiCallContext context) { + Preconditions.checkNotNull(request); - /** - * Performs the resumable upload asynchronously with custom per-request settings and a null context. - */ - public ApiFuture futureCall( - RequestT request, - InputStream payload, - ResumableUploadCallSettings perRequestSettings) { - return futureCall(request, payload, perRequestSettings, (ApiCallContext) null); - } + ResumableUploadCallSettings activeSettings = + defaultCallSettings != null + ? defaultCallSettings.merge(perRequestSettings) + : perRequestSettings; - /** - * Performs the resumable upload asynchronously with default settings and a null context. - */ - public ApiFuture futureCall(RequestT request, InputStream payload) { - return futureCall( - request, payload, (ResumableUploadCallSettings) null, (ApiCallContext) null); + ResumableUploadFutureImpl future = + new ResumableUploadFutureImpl<>( + resumableUploadClient, request, payload, activeSettings, context); + + future.start(); + return future; } /** - * Performs the resumable upload synchronously with custom per-request settings and context. + * Resumes an existing resumable upload session asynchronously using a saved session URL. + * + * @param sessionUrl the upload session URL + * @param payload the data payload input stream + * @param perRequestSettings call settings overrides; may be {@code null} + * @param context call context overrides; may be {@code null} + * @return future for tracking and controlling the upload */ - public ResponseT call( - RequestT request, + public ResumableUploadFuture resumeCall( + String sessionUrl, InputStream payload, - ResumableUploadCallSettings perRequestSettings, + ResumableUploadCallSettings perRequestSettings, ApiCallContext context) { - return ApiExceptions.callAndTranslateApiException( - futureCall(request, payload, perRequestSettings, context)); + Preconditions.checkNotNull(sessionUrl); + + ResumableUploadCallSettings activeSettings = + defaultCallSettings != null + ? defaultCallSettings.merge(perRequestSettings) + : perRequestSettings; + + ResumableUploadFutureImpl future = + new ResumableUploadFutureImpl<>( + resumableUploadClient, sessionUrl, payload, activeSettings, context); + + future.start(); + return future; } - /** - * Performs the resumable upload synchronously with custom per-request settings and a null context. - */ - public ResponseT call( - RequestT request, - InputStream payload, - ResumableUploadCallSettings perRequestSettings) { - return call(request, payload, perRequestSettings, (ApiCallContext) null); + public ResumableUploadFuture futureCall( + RequestT request, InputStream payload, ResumableUploadCallSettings settings) { + return futureCall(request, payload, settings, null); } - /** - * Performs the resumable upload synchronously with default settings and a null context. - */ - public ResponseT call(RequestT request, InputStream payload) { - return call( - request, payload, (ResumableUploadCallSettings) null, (ApiCallContext) null); + public ResumableUploadFuture resumeCall( + String sessionUrl, InputStream payload, ResumableUploadCallSettings settings) { + return resumeCall(sessionUrl, payload, settings, null); } } diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallableImpl.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallableImpl.java deleted file mode 100644 index 47f5ee2d2dd4..000000000000 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallableImpl.java +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are - * met: - * - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * * Redistributions in binary form must reproduce the above - * copyright notice, this list of conditions and the following disclaimer - * in the documentation and/or other materials provided with the - * distribution. - * * Neither the name of Google LLC nor the names of its - * contributors may be used to endorse or promote products derived from - * this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package com.google.api.gax.rpc; - -import com.google.api.core.ApiFuture; -import com.google.api.core.ApiFutures; -import com.google.api.core.BetaApi; -import com.google.common.base.Preconditions; -import java.io.InputStream; -import javax.annotation.Nullable; - -/** - * An implementation of {@link ResumableUploadCallable} that orchestrates atomic low-level - * Scotty upload RPCs via a {@link ResumableUploadClient}. - * - *

Modeled after {@link OperationCallableImpl}. - */ -@BetaApi -public class ResumableUploadCallableImpl - extends ResumableUploadCallable { - - private final ResumableUploadClient resumableUploadClient; - @Nullable private final ResumableUploadCallSettings defaultCallSettings; - - public ResumableUploadCallableImpl( - ResumableUploadClient resumableUploadClient, - @Nullable ResumableUploadCallSettings defaultCallSettings) { - this.resumableUploadClient = Preconditions.checkNotNull(resumableUploadClient); - this.defaultCallSettings = defaultCallSettings; - } - - public ResumableUploadCallableImpl(ResumableUploadClient resumableUploadClient) { - this(resumableUploadClient, null); - } - - @Override - public ApiFuture futureCall( - RequestT request, - InputStream payload, - ResumableUploadCallSettings perRequestSettings, - ApiCallContext context) { - Preconditions.checkNotNull(request); - - ResumableUploadCallSettings activeSettings = - defaultCallSettings != null - ? defaultCallSettings.mergeWith(perRequestSettings) - : perRequestSettings; - - int chunkSize = - activeSettings != null - ? activeSettings.getChunkSizeOrDefault() - : 8 * 1024 * 1024; // default 8 MB - - long totalBytes = - activeSettings != null && activeSettings.getTotalBytes() != null - ? activeSettings.getTotalBytes() - : -1L; - - try { - // 1. Start Upload Session with raw RequestT - ResumableUploadSession session = - resumableUploadClient - .startUploadCallable() - .call(request, context); - - String uploadUrl = session.getUploadUrl(); - - // 2. Transmit Chunks - ChunkUploadResponse response = - resumableUploadClient - .uploadChunkCallable() - .call( - new ChunkUploadRequest( - uploadUrl, new byte[0], 0, totalBytes, true), - context); - - // Return placeholder/parsed response - @SuppressWarnings("unchecked") - ResponseT result = (ResponseT) response.getResponseBody(); - return ApiFutures.immediateFuture(result); - } catch (Exception e) { - return ApiFutures.immediateFailedFuture(e); - } - } -} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFuture.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFuture.java new file mode 100644 index 000000000000..d0cc033140c7 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFuture.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.ApiFuture; +import com.google.api.core.BetaApi; + +/** + * A specialized {@link ApiFuture} for tracking and controlling an in-flight resumable upload. + * + * @param response type + */ +@BetaApi +public interface ResumableUploadFuture extends ApiFuture { + + /** + * Returns the upload session URL, or {@code null} if session initiation is in progress. + */ + String getUploadSessionUrl(); +} diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFutureImpl.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFutureImpl.java new file mode 100644 index 000000000000..93b6ae87692b --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadFutureImpl.java @@ -0,0 +1,173 @@ +/* + * Copyright 2026 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.rpc; + +import com.google.api.core.AbstractApiFuture; +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutureCallback; +import com.google.api.core.ApiFutures; +import com.google.api.core.BetaApi; +import com.google.common.base.Preconditions; +import com.google.common.util.concurrent.MoreExecutors; +import java.io.InputStream; +import javax.annotation.Nullable; + +/** + * Stateful per-request implementation of {@link ResumableUploadFuture}. + * Manages payload chunking, stream offsets, recovery, and session tracking. + */ +@BetaApi +public class ResumableUploadFutureImpl + extends AbstractApiFuture implements ResumableUploadFuture { + + private final ResumableUploadClient resumableUploadClient; + @Nullable private final RequestT request; + @Nullable private final String initialSessionUrl; + private final InputStream payload; + private final ResumableUploadCallSettings settings; + private final ApiCallContext context; + + private volatile String uploadSessionUrl; + private volatile long committedOffset = 0L; + private volatile ApiFuture inFlightChunkFuture; + + public ResumableUploadFutureImpl( + ResumableUploadClient resumableUploadClient, + RequestT request, + InputStream payload, + ResumableUploadCallSettings settings, + ApiCallContext context) { + this.resumableUploadClient = Preconditions.checkNotNull(resumableUploadClient); + this.request = Preconditions.checkNotNull(request); + this.initialSessionUrl = null; + this.payload = Preconditions.checkNotNull(payload); + this.settings = settings; + this.context = context; + } + + public ResumableUploadFutureImpl( + ResumableUploadClient resumableUploadClient, + String sessionUrl, + InputStream payload, + ResumableUploadCallSettings settings, + ApiCallContext context) { + this.resumableUploadClient = Preconditions.checkNotNull(resumableUploadClient); + this.request = null; + this.initialSessionUrl = Preconditions.checkNotNull(sessionUrl); + this.payload = Preconditions.checkNotNull(payload); + this.settings = settings; + this.context = context; + } + + public void start() { + if (initialSessionUrl != null) { + this.uploadSessionUrl = initialSessionUrl; + queryAndResume(); + } else { + initiateSessionAndUpload(); + } + } + + private void initiateSessionAndUpload() { + ApiFuture sessionFuture = + resumableUploadClient.startUploadCallable().futureCall(request, context); + + ApiFutures.addCallback( + sessionFuture, + new ApiFutureCallback() { + @Override + public void onSuccess(ResumableUploadSession session) { + uploadSessionUrl = session.getUploadUrl(); + uploadNextChunk(); + } + + @Override + public void onFailure(Throwable t) { + setException(t); + } + }, + MoreExecutors.directExecutor()); + } + + private void queryAndResume() { + uploadNextChunk(); + } + + private void uploadNextChunk() { + try { + int chunkSize = settings != null ? settings.getChunkSize() : 8 * 1024 * 1024; + byte[] buffer = new byte[0]; + + ChunkUploadRequest chunkRequest = + new ChunkUploadRequest(uploadSessionUrl, buffer, committedOffset, -1L, true); + + ApiFuture chunkFuture = + resumableUploadClient.uploadChunkCallable().futureCall(chunkRequest, context); + + this.inFlightChunkFuture = chunkFuture; + + ApiFutures.addCallback( + chunkFuture, + new ApiFutureCallback() { + @Override + public void onSuccess(ChunkUploadResponse response) { + @SuppressWarnings("unchecked") + ResponseT result = (ResponseT) response.getResponseBody(); + set(result); + } + + @Override + public void onFailure(Throwable t) { + setException(t); + } + }, + MoreExecutors.directExecutor()); + } catch (Throwable t) { + setException(t); + } + } + + @Override + public String getUploadSessionUrl() { + return uploadSessionUrl; + } + + public long getCommittedOffset() { + return committedOffset; + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + if (inFlightChunkFuture != null) { + inFlightChunkFuture.cancel(mayInterruptIfRunning); + } + return super.cancel(mayInterruptIfRunning); + } +} diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallSettingsTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallSettingsTest.java index 44af197c5fbf..d890c61e4ba0 100644 --- a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallSettingsTest.java +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallSettingsTest.java @@ -29,70 +29,49 @@ */ package com.google.api.gax.rpc; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; +import org.junit.jupiter.api.Test; -@RunWith(JUnit4.class) public class ResumableUploadCallSettingsTest { @Test - public void testDefaultChunkSize() { - ResumableUploadCallSettings settings = - ResumableUploadCallSettings.newBuilder().build(); + public void testDefaultChunkSizeInBuilder() { + ResumableUploadCallSettings settings = ResumableUploadCallSettings.newBuilder().build(); - assertNull(settings.getChunkSize()); - assertNull(settings.getTotalBytes()); - assertEquals(8 * 1024 * 1024, settings.getChunkSizeOrDefault()); + assertEquals(8 * 1024 * 1024, settings.getChunkSize()); } @Test public void testCustomInitialization() { - ResumableUploadCallSettings settings = - ResumableUploadCallSettings.newBuilder() - .setChunkSize(16 * 1024 * 1024) - .setTotalBytes(100L * 1024 * 1024) - .build(); + ResumableUploadCallSettings settings = + ResumableUploadCallSettings.newBuilder().setChunkSize(16 * 1024 * 1024).build(); - assertEquals(Integer.valueOf(16 * 1024 * 1024), settings.getChunkSize()); - assertEquals(16 * 1024 * 1024, settings.getChunkSizeOrDefault()); - assertEquals(Long.valueOf(100L * 1024 * 1024), settings.getTotalBytes()); + assertEquals(16 * 1024 * 1024, settings.getChunkSize()); } @Test - public void testMergeWith_NullPerRequestSettings() { - ResumableUploadCallSettings stubSettings = - ResumableUploadCallSettings.newBuilder() - .setChunkSize(4 * 1024 * 1024) - .build(); + public void testMerge_NullSettings() { + ResumableUploadCallSettings stubSettings = + ResumableUploadCallSettings.newBuilder().setChunkSize(4 * 1024 * 1024).build(); - ResumableUploadCallSettings merged = stubSettings.mergeWith(null); + ResumableUploadCallSettings merged = stubSettings.merge(null); assertSame(stubSettings, merged); } @Test - public void testMergeWith_PerRequestOverrides() { - ResumableUploadCallSettings stubSettings = - ResumableUploadCallSettings.newBuilder() - .setChunkSize(4 * 1024 * 1024) - .setTotalBytes(50L * 1024 * 1024) - .build(); + public void testMerge_SettingsOverrides() { + ResumableUploadCallSettings stubSettings = + ResumableUploadCallSettings.newBuilder().setChunkSize(4 * 1024 * 1024).build(); - ResumableUploadCallSettings perRequestSettings = - ResumableUploadCallSettings.newBuilder() - .setChunkSize(32 * 1024 * 1024) - .build(); + ResumableUploadCallSettings perRequestSettings = + ResumableUploadCallSettings.newBuilder().setChunkSize(32 * 1024 * 1024).build(); - ResumableUploadCallSettings merged = stubSettings.mergeWith(perRequestSettings); + ResumableUploadCallSettings merged = stubSettings.merge(perRequestSettings); // Chunk size overridden by Tier-1 per-request settings - assertEquals(Integer.valueOf(32 * 1024 * 1024), merged.getChunkSize()); - // Total bytes preserved from Tier-2 stub-level settings - assertEquals(Long.valueOf(50L * 1024 * 1024), merged.getTotalBytes()); + assertEquals(32 * 1024 * 1024, merged.getChunkSize()); } }