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..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; @@ -29,6 +30,8 @@ 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.ServerStreamingCallable; import com.google.api.gax.rpc.UnaryCallable; import com.google.cloud.location.GetLocationRequest; @@ -1780,4 +1783,19 @@ protected ListLocationsFixedSizeCollection createCollection( return new ListLocationsFixedSizeCollection(pages, collectionSize); } } + + public final ResumableUploadCallable resumableUploadCallable() { + return stub.resumableUploadCallable(); + } + + public final EchoResponse echoResumableUpload( + EchoRequest request, + InputStream payload, + ResumableUploadCallSettings perRequestSettings) { + return resumableUploadCallable().call(request, payload, perRequestSettings); + } + + public final EchoResponse echoResumableUpload(EchoRequest request, InputStream payload) { + return resumableUploadCallable().call(request, payload); + } } 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/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/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..eb477704c864 --- /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, settings.resumableUploadSettings(), 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/RESUMABLE_UPLOAD_DESIGN.md b/sdk-platform-java/gax-java/RESUMABLE_UPLOAD_DESIGN.md new file mode 100644 index 000000000000..03301858b0f2 --- /dev/null +++ b/sdk-platform-java/gax-java/RESUMABLE_UPLOAD_DESIGN.md @@ -0,0 +1,263 @@ +# 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`. +- 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. +- 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** (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 447fc46dd9e0..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 @@ -39,6 +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.ResumableUploadCallSettings; +import com.google.api.gax.rpc.ResumableUploadCallable; +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; @@ -220,6 +223,37 @@ 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 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) { + ResumableUploadClient uploadClient = new HttpJsonResumableUploadClient(clientContext); + return new ResumableUploadCallable<>(uploadClient, resumableUploadCallSettings); + } + + /** + * 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) { return ApiTracerContext.newBuilder() .setFullMethodName(methodDescriptor.getFullMethodName()) 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..28d6f2c544fc --- /dev/null +++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java @@ -0,0 +1,91 @@ +/* + * 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.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 startUploadCallable() { + return new UnaryCallable() { + @Override + public ApiFuture futureCall(RequestT 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-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/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/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. 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/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/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..aab8918ac604 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallSettings.java @@ -0,0 +1,77 @@ +/* + * 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.auto.value.AutoValue; + +/** + * A settings class to configure a {@link ResumableUploadCallable} for executing resumable + * uploads. Encapsulates protocol options such as payload chunk size. + */ +@BetaApi +@AutoValue +public abstract class ResumableUploadCallSettings { + private static final int DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024; // 8 MB + + /** 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 other} override fields in this instance. + * + * @param other settings to overlay; may be {@code null} + * @return a new, resolved {@code ResumableUploadCallSettings} instance + */ + public ResumableUploadCallSettings merge(ResumableUploadCallSettings other) { + if (other == null) { + return this; + } + return toBuilder().setChunkSize(other.getChunkSize()).build(); + } + + public abstract Builder toBuilder(); + + public static Builder newBuilder() { + return new AutoValue_ResumableUploadCallSettings.Builder() + .setChunkSize(DEFAULT_CHUNK_SIZE); + } + + /** Builder for {@link ResumableUploadCallSettings}. */ + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setChunkSize(int chunkSize); + + public abstract int getChunkSize(); + + 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 new file mode 100644 index 000000000000..1104f76942f8 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadCallable.java @@ -0,0 +1,128 @@ +/* + * 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 java.io.InputStream; +import javax.annotation.Nullable; + +/** + * A ResumableUploadCallable is an API-transport-independent wrapper for the Resumable Upload + * protocol. Operates directly on the request object and input stream payload. + * + * @param request type + * @param response type + */ +@BetaApi +public class 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 a new resumable upload asynchronously. + * + * @param request the request message + * @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 ResumableUploadFuture futureCall( + RequestT request, + InputStream payload, + ResumableUploadCallSettings perRequestSettings, + ApiCallContext context) { + Preconditions.checkNotNull(request); + + ResumableUploadCallSettings activeSettings = + defaultCallSettings != null + ? defaultCallSettings.merge(perRequestSettings) + : perRequestSettings; + + ResumableUploadFutureImpl future = + new ResumableUploadFutureImpl<>( + resumableUploadClient, request, payload, activeSettings, context); + + future.start(); + return future; + } + + /** + * 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 ResumableUploadFuture resumeCall( + String sessionUrl, + InputStream payload, + ResumableUploadCallSettings perRequestSettings, + ApiCallContext 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; + } + + public ResumableUploadFuture futureCall( + RequestT request, InputStream payload, ResumableUploadCallSettings settings) { + return futureCall(request, payload, settings, 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/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..196df632017b --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ResumableUploadClient.java @@ -0,0 +1,49 @@ +/* + * 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 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/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/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/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; + } +} 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 + + '}'; + } +} 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..d890c61e4ba0 --- /dev/null +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/ResumableUploadCallSettingsTest.java @@ -0,0 +1,77 @@ +/* + * 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 org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import org.junit.jupiter.api.Test; + +public class ResumableUploadCallSettingsTest { + + @Test + public void testDefaultChunkSizeInBuilder() { + ResumableUploadCallSettings settings = ResumableUploadCallSettings.newBuilder().build(); + + assertEquals(8 * 1024 * 1024, settings.getChunkSize()); + } + + @Test + public void testCustomInitialization() { + ResumableUploadCallSettings settings = + ResumableUploadCallSettings.newBuilder().setChunkSize(16 * 1024 * 1024).build(); + + assertEquals(16 * 1024 * 1024, settings.getChunkSize()); + } + + @Test + public void testMerge_NullSettings() { + ResumableUploadCallSettings stubSettings = + ResumableUploadCallSettings.newBuilder().setChunkSize(4 * 1024 * 1024).build(); + + ResumableUploadCallSettings merged = stubSettings.merge(null); + + assertSame(stubSettings, merged); + } + + @Test + public void testMerge_SettingsOverrides() { + ResumableUploadCallSettings stubSettings = + ResumableUploadCallSettings.newBuilder().setChunkSize(4 * 1024 * 1024).build(); + + ResumableUploadCallSettings perRequestSettings = + ResumableUploadCallSettings.newBuilder().setChunkSize(32 * 1024 * 1024).build(); + + ResumableUploadCallSettings merged = stubSettings.merge(perRequestSettings); + + // Chunk size overridden by Tier-1 per-request settings + assertEquals(32 * 1024 * 1024, merged.getChunkSize()); + } +} 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