-
Notifications
You must be signed in to change notification settings - Fork 1.2k
POC Option 1: Background REST Stub #13869
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
847c892
81f5945
4ae9e0c
0e3a135
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 HttpJsonEchoStub httpJsonStub; | ||
|
|
||
| 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.httpJsonStub = new HttpJsonEchoStub(settings, backgroundHttpContext); | ||
| } else { | ||
| this.httpJsonStub = null; | ||
| } | ||
|
|
||
| GrpcCallSettings<EchoRequest, EchoResponse> echoTransportSettings = | ||
| GrpcCallSettings.<EchoRequest, EchoResponse>newBuilder() | ||
| .setMethodDescriptor(echoMethodDescriptor) | ||
|
|
@@ -633,10 +646,23 @@ public UnaryCallable<GetIamPolicyRequest, Policy> getIamPolicyCallable() { | |
| return testIamPermissionsCallable; | ||
| } | ||
|
|
||
| @Override | ||
| public ResumableUploadCallable<EchoRequest, EchoResponse> resumableUploadCallable() { | ||
| if (httpJsonStub == 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 httpJsonStub.resumableUploadCallable(); | ||
| } | ||
|
|
||
| @Override | ||
| public final void close() { | ||
| try { | ||
| backgroundResources.close(); | ||
| if (httpJsonStub != null) { | ||
| httpJsonStub.close(); | ||
| } | ||
| } catch (RuntimeException e) { | ||
| throw e; | ||
| } catch (Exception e) { | ||
|
|
@@ -647,6 +673,9 @@ public final void close() { | |
| @Override | ||
| public void shutdown() { | ||
| backgroundResources.shutdown(); | ||
| if (httpJsonStub != null) { | ||
| httpJsonStub.shutdown(); | ||
| } | ||
| } | ||
|
Comment on lines
673
to
679
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When managing a collection of closeable resources, ensure they are shut down in the reverse order of their creation (LIFO) and in an exception-safe manner. Since @Override
public void shutdown() {
try {
if (httpJsonStub != null) {
httpJsonStub.shutdown();
}
} finally {
backgroundResources.shutdown();
}
}References
|
||
|
|
||
| @Override | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<RequestT> { | ||
| private final RequestT request; | ||
| private final InputStreamProvider streamProvider; | ||
| private final long totalBytes; // -1 if unknown | ||
| private final ResumableUploadProgressListener progressListener; | ||
|
|
||
| private ResumableUploadRequest(Builder<RequestT> 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 <RequestT> Builder<RequestT> newBuilder() { | ||
| return new Builder<>(); | ||
| } | ||
|
|
||
| public static class Builder<RequestT> { | ||
| private RequestT request; | ||
| private InputStreamProvider streamProvider; | ||
| private long totalBytes = -1; | ||
| private ResumableUploadProgressListener progressListener; | ||
|
|
||
| public Builder<RequestT> setRequest(RequestT request) { | ||
| this.request = request; | ||
| return this; | ||
| } | ||
| public Builder<RequestT> setStreamProvider(InputStreamProvider streamProvider) { | ||
| this.streamProvider = streamProvider; | ||
| return this; | ||
| } | ||
| public Builder<RequestT> setTotalBytes(long totalBytes) { | ||
| this.totalBytes = totalBytes; | ||
| return this; | ||
| } | ||
| public Builder<RequestT> setProgressListener(ResumableUploadProgressListener progressListener) { | ||
| this.progressListener = progressListener; | ||
| return this; | ||
| } | ||
| public ResumableUploadRequest<RequestT> 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<RequestT, ResponseT> { | ||
|
|
||
| protected ResumableUploadCallable() {} | ||
|
|
||
| public abstract ApiFuture<ResponseT> futureCall( | ||
| ResumableUploadRequest<RequestT> request, ApiCallContext context); | ||
|
|
||
| public ResponseT call(ResumableUploadRequest<RequestT> request, ApiCallContext context) { | ||
| return ApiExceptions.callAndTranslateCharSequenceException(futureCall(request, context)); | ||
| } | ||
|
|
||
| public ResponseT call(ResumableUploadRequest<RequestT> 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The background HTTP/JSON stub is initialized using the default transport provider builder without applying any custom endpoint configured in
settings. If a custom endpoint (such as a local emulator) is used, the background stub will incorrectly attempt to connect to the default production endpoint. Apply the custom endpoint to the provider builder.