Skip to content

Commit 715822d

Browse files
committed
feat(gax): add rewindable stream buffer for chunk recovery
Introduce RewindableStreamBuffer managing a single-chunk buffer over an InputStream, supporting forward compaction and topping up upon recovery realignment without mark()/reset(). Enforces boundaries by throwing FailedPreconditionException when a server offset is below the base offset or beyond the current buffer window. Use payloadLength in ChunkUploadRequest to avoid allocating temporary byte arrays for full-sized chunks while reusing a single backing array.
1 parent 506f1b7 commit 715822d

7 files changed

Lines changed: 657 additions & 64 deletions

File tree

‎sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ResumableUploadChunkCallable.java‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import java.io.ByteArrayInputStream;
4747
import java.io.InputStream;
4848
import java.nio.charset.StandardCharsets;
49+
import java.util.Arrays;
4950
import java.util.Collections;
5051
import java.util.List;
5152
import java.util.Map;
@@ -81,7 +82,12 @@ public Map<String, List<String>> getQueryParamNames(ChunkUploadRequest request)
8182

8283
@Override
8384
public byte[] getBinaryRequestBody(ChunkUploadRequest request) {
84-
return request.getPayload();
85+
int length = request.getPayloadLength();
86+
byte[] payload = request.getPayload();
87+
if (length == payload.length) {
88+
return payload;
89+
}
90+
return Arrays.copyOf(payload, length);
8591
}
8692

8793
@Override
@@ -110,7 +116,7 @@ private ResumableUploadChunkCallable(
110116
public ApiFuture<ChunkUploadResponse<ResponseT>> futureCall(
111117
ChunkUploadRequest request, @Nullable ApiCallContext inputContext) {
112118
Preconditions.checkNotNull(request);
113-
boolean isPayloadEmpty = request.getPayload().length == 0;
119+
boolean isPayloadEmpty = request.getPayloadLength() == 0;
114120
String command;
115121
if (request.isFinal()) {
116122
command = !isPayloadEmpty ? COMMAND_UPLOAD_FINALIZE : COMMAND_FINALIZE;

‎sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/resumable/ChunkUploadRequest.java‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import com.google.api.core.BetaApi;
3333
import com.google.api.core.InternalApi;
3434
import com.google.auto.value.AutoValue;
35+
import com.google.common.base.Preconditions;
3536
import org.jspecify.annotations.NullMarked;
3637

3738
/** Request value object for uploading a chunk to an active resumable upload session. */
@@ -48,6 +49,9 @@ public abstract class ChunkUploadRequest {
4849
@SuppressWarnings("mutable")
4950
public abstract byte[] getPayload();
5051

52+
/** The number of bytes within {@link #getPayload()} to upload. */
53+
public abstract int getPayloadLength();
54+
5155
/** The byte offset of this chunk in the overall stream. */
5256
public abstract long getOffset();
5357

@@ -56,8 +60,12 @@ public abstract class ChunkUploadRequest {
5660

5761
public abstract Builder toBuilder();
5862

63+
private static final int UNSET_PAYLOAD_LENGTH = Integer.MIN_VALUE;
64+
5965
public static Builder newBuilder() {
60-
return new AutoValue_ChunkUploadRequest.Builder().setFinal(false);
66+
return new AutoValue_ChunkUploadRequest.Builder()
67+
.setFinal(false)
68+
.setPayloadLength(UNSET_PAYLOAD_LENGTH);
6169
}
6270

6371
@AutoValue.Builder
@@ -66,10 +74,29 @@ public abstract static class Builder {
6674

6775
public abstract Builder setPayload(byte[] payload);
6876

77+
public abstract Builder setPayloadLength(int payloadLength);
78+
6979
public abstract Builder setOffset(long offset);
7080

7181
public abstract Builder setFinal(boolean isFinal);
7282

73-
public abstract ChunkUploadRequest build();
83+
abstract byte[] getPayload();
84+
85+
abstract int getPayloadLength();
86+
87+
abstract ChunkUploadRequest autoBuild();
88+
89+
public ChunkUploadRequest build() {
90+
if (getPayloadLength() == UNSET_PAYLOAD_LENGTH) {
91+
setPayloadLength(getPayload().length);
92+
}
93+
ChunkUploadRequest request = autoBuild();
94+
Preconditions.checkArgument(
95+
request.getPayloadLength() >= 0, "payloadLength must be non-negative");
96+
Preconditions.checkArgument(
97+
request.getPayloadLength() <= request.getPayload().length,
98+
"payloadLength exceeds payload array length");
99+
return request;
100+
}
74101
}
75102
}

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

Lines changed: 128 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,13 @@
3838
import com.google.api.core.SettableApiFuture;
3939
import com.google.api.gax.resumable.ChunkUploadRequest;
4040
import com.google.api.gax.resumable.ChunkUploadResponse;
41+
import com.google.api.gax.resumable.QueryStatusRequest;
42+
import com.google.api.gax.resumable.QueryStatusResponse;
4143
import com.google.api.gax.resumable.ResumableUploadStatus;
42-
import com.google.common.io.ByteStreams;
44+
import com.google.api.gax.rpc.ResumableUploadErrorClassifier.Category;
4345
import com.google.common.util.concurrent.MoreExecutors;
4446
import java.io.IOException;
4547
import java.io.InputStream;
46-
import java.util.Arrays;
4748
import java.util.concurrent.CancellationException;
4849
import java.util.concurrent.Executor;
4950
import org.jspecify.annotations.NullMarked;
@@ -52,42 +53,45 @@
5253
/**
5354
* Coordinates chunk transmission steps of a resumable upload session.
5455
*
56+
* <p>Expects {@code uploadChunkCallable} and {@code queryStatusCallable} to be pre-wrapped in
57+
* retrying callables that handle transient errors.
58+
*
5559
* @param <ResponseT> the type of the final response message returned once the upload completes
5660
*/
5761
@InternalApi
5862
@NullMarked
5963
final class ResumableUploadChunkCoordinator<ResponseT> {
6064

61-
private static final byte[] EMPTY_PAYLOAD = new byte[0];
62-
6365
// Serializes buffer mutations across multiple threads (i.e. from retry/recovery)
6466
private final Executor chunkExecutor =
6567
MoreExecutors.newSequentialExecutor(MoreExecutors.directExecutor());
6668

6769
private final UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>>
6870
uploadChunkCallable;
71+
private final UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>>
72+
queryStatusCallable;
6973
private final String uploadUrl;
70-
private final InputStream payload;
71-
private final byte[] buffer;
72-
private final int chunkSize;
74+
private final RewindableStreamBuffer buffer;
7375
private final ApiCallContext callContext;
7476
private final SettableApiFuture<ResponseT> result = SettableApiFuture.create();
7577
private volatile @Nullable ApiFuture<?> currentChunkFuture;
7678

7779
ResumableUploadChunkCoordinator(
7880
UnaryCallable<ChunkUploadRequest, ChunkUploadResponse<ResponseT>> uploadChunkCallable,
81+
UnaryCallable<QueryStatusRequest, QueryStatusResponse<ResponseT>> queryStatusCallable,
7982
String uploadUrl,
8083
InputStream payload,
8184
int chunkSize,
8285
ClientContext clientContext) {
8386
this.uploadChunkCallable =
8487
checkNotNull(uploadChunkCallable, "uploadChunkCallable must not be null");
88+
this.queryStatusCallable =
89+
checkNotNull(queryStatusCallable, "queryStatusCallable must not be null");
8590
this.uploadUrl = checkNotNull(uploadUrl, "uploadUrl must not be null");
86-
this.payload = checkNotNull(payload, "payload must not be null");
87-
this.chunkSize = chunkSize;
91+
checkNotNull(payload, "payload must not be null");
8892
checkNotNull(clientContext, "clientContext must not be null");
8993
this.callContext = clientContext.getDefaultCallContext();
90-
this.buffer = new byte[chunkSize];
94+
this.buffer = new RewindableStreamBuffer(payload, chunkSize, uploadUrl);
9195
}
9296

9397
ApiFuture<ResponseT> start() {
@@ -104,75 +108,39 @@ ApiFuture<ResponseT> start() {
104108
}
105109

106110
private void transmitChunk(long currentOffset) {
107-
// Abort if the session was already completed or canceled.
108111
if (result.isDone()) {
109112
return;
110113
}
111-
112-
// Read the next chunk slice from the payload stream.
113-
int bytesRead;
114114
try {
115-
bytesRead = ByteStreams.read(payload, buffer, 0, chunkSize);
116-
} catch (IOException e) {
117-
result.setException(e);
118-
return;
115+
buffer.fill(currentOffset);
116+
dispatchCurrentChunk();
117+
} catch (Throwable t) {
118+
result.setException(t);
119119
}
120+
}
120121

121-
// Determine if this is the final chunk and build the chunk request.
122-
boolean isFinal = bytesRead < chunkSize;
123-
byte[] chunkPayload;
124-
if (bytesRead == chunkSize) {
125-
chunkPayload = buffer;
126-
} else if (bytesRead == 0) {
127-
chunkPayload = EMPTY_PAYLOAD;
128-
} else {
129-
chunkPayload = Arrays.copyOf(buffer, bytesRead);
122+
private void dispatchCurrentChunk() {
123+
if (result.isDone()) {
124+
return;
130125
}
131-
132-
ChunkUploadRequest chunkRequest =
133-
ChunkUploadRequest.newBuilder()
134-
.setUploadUrl(uploadUrl)
135-
.setPayload(chunkPayload)
136-
.setOffset(currentOffset)
137-
.setFinal(isFinal)
138-
.build();
139-
140-
// Dispatch the chunk upload call and register the in-flight future for cancellation.
141-
long chunkLength = chunkPayload.length;
142126
try {
143-
ApiFuture<ChunkUploadResponse<ResponseT>> chunkFuture =
144-
uploadChunkCallable.futureCall(chunkRequest, callContext);
127+
ApiFuture<ChunkUploadResponse<ResponseT>> chunkFuture = executeChunkWithRecovery();
145128
this.currentChunkFuture = chunkFuture;
146129
if (result.isCancelled()) {
147130
chunkFuture.cancel(true);
148131
return;
149132
}
150-
151133
ApiFutures.addCallback(
152134
chunkFuture,
153135
new ApiFutureCallback<ChunkUploadResponse<ResponseT>>() {
154136
@Override
155137
public void onSuccess(ChunkUploadResponse<ResponseT> response) {
156-
if (result.isDone()) {
157-
return;
158-
}
159-
long nextOffset = currentOffset + chunkLength;
160-
if (response.getUploadStatus() == ResumableUploadStatus.FINAL) {
161-
result.set(response.getResponse());
162-
} else if (isFinal) {
163-
result.setException(
164-
new IllegalStateException(
165-
"Upload stream ended and final chunk was transmitted, but server returned"
166-
+ " incomplete status for upload URL: "
167-
+ uploadUrl));
168-
} else {
169-
chunkExecutor.execute(() -> transmitChunk(nextOffset));
170-
}
138+
chunkExecutor.execute(() -> onChunkUploaded(response));
171139
}
172140

173141
@Override
174142
public void onFailure(Throwable t) {
175-
if (t instanceof CancellationException || result.isDone()) {
143+
if (t instanceof CancellationException) {
176144
return;
177145
}
178146
result.setException(t);
@@ -183,4 +151,107 @@ public void onFailure(Throwable t) {
183151
result.setException(t);
184152
}
185153
}
154+
155+
private ApiFuture<ChunkUploadResponse<ResponseT>> executeChunkWithRecovery() {
156+
ApiFuture<ChunkUploadResponse<ResponseT>> transmitted =
157+
ApiFutures.catchingAsync(
158+
uploadChunkCallable.futureCall(buildCurrentChunkRequest(), callContext),
159+
Throwable.class,
160+
t -> {
161+
Category category =
162+
ResumableUploadErrorClassifier.classify(t, ResumableUploadCommand.UPLOAD);
163+
if (category == Category.RECOVERABLE) {
164+
return recover();
165+
}
166+
// Category.TRANSIENT errors reaching here have already exhausted their retry budget
167+
// in the underlying RetryingCallable and become fatal per protocol specification.
168+
return ApiFutures.immediateFailedFuture(t);
169+
},
170+
chunkExecutor);
171+
return ApiFutures.transformAsync(
172+
transmitted,
173+
response -> {
174+
if (response.getUploadStatus() == ResumableUploadStatus.UNKNOWN) {
175+
return recover();
176+
}
177+
return ApiFutures.immediateFuture(response);
178+
},
179+
chunkExecutor);
180+
}
181+
182+
private ApiFuture<ChunkUploadResponse<ResponseT>> recover() {
183+
return ApiFutures.transformAsync(
184+
queryStatusCallable.futureCall(QueryStatusRequest.create(uploadUrl), callContext),
185+
this::resumeFrom,
186+
chunkExecutor);
187+
}
188+
189+
private ApiFuture<ChunkUploadResponse<ResponseT>> resumeFrom(
190+
QueryStatusResponse<ResponseT> queryResponse) throws IOException {
191+
if (queryResponse.getUploadStatus() == ResumableUploadStatus.UNKNOWN) {
192+
throw protocolViolation(
193+
"Query status response missing X-Goog-Upload-Status header for upload URL: " + uploadUrl);
194+
}
195+
if (queryResponse.getUploadStatus() == ResumableUploadStatus.FINAL) {
196+
return ApiFutures.immediateFuture(
197+
ChunkUploadResponse.create(ResumableUploadStatus.FINAL, queryResponse.getResponse()));
198+
}
199+
Long committedOffset = queryResponse.getCommittedOffset();
200+
if (committedOffset == null) {
201+
throw protocolViolation(
202+
"Incomplete query status response did not include a committed offset for upload URL: "
203+
+ uploadUrl);
204+
}
205+
buffer.realignTo(committedOffset);
206+
return executeChunkWithRecovery();
207+
}
208+
209+
private void onChunkUploaded(ChunkUploadResponse<ResponseT> response) {
210+
if (result.isDone()) {
211+
return;
212+
}
213+
try {
214+
if (response.getUploadStatus() == ResumableUploadStatus.FINAL) {
215+
result.set(response.getResponse());
216+
} else if (buffer.isFinal()) {
217+
throw new IllegalStateException(
218+
"Upload stream ended and final chunk was transmitted, but server returned incomplete"
219+
+ " status for upload URL: "
220+
+ uploadUrl);
221+
} else {
222+
long nextOffset = buffer.getBufferBaseOffset() + buffer.getPayloadLength();
223+
transmitChunk(nextOffset);
224+
}
225+
} catch (Throwable t) {
226+
result.setException(t);
227+
}
228+
}
229+
230+
private ChunkUploadRequest buildCurrentChunkRequest() {
231+
return ChunkUploadRequest.newBuilder()
232+
.setUploadUrl(uploadUrl)
233+
.setPayload(buffer.getBuffer())
234+
.setPayloadLength(buffer.getPayloadLength())
235+
.setOffset(buffer.getBufferBaseOffset())
236+
.setFinal(buffer.isFinal())
237+
.build();
238+
}
239+
240+
private static FailedPreconditionException protocolViolation(String message) {
241+
return new FailedPreconditionException(
242+
message,
243+
null,
244+
new StatusCode() {
245+
@Override
246+
public StatusCode.Code getCode() {
247+
return StatusCode.Code.FAILED_PRECONDITION;
248+
}
249+
250+
@Override
251+
public @Nullable Object getTransportCode() {
252+
return null;
253+
}
254+
},
255+
false);
256+
}
186257
}

0 commit comments

Comments
 (0)