3838import com .google .api .core .SettableApiFuture ;
3939import com .google .api .gax .resumable .ChunkUploadRequest ;
4040import com .google .api .gax .resumable .ChunkUploadResponse ;
41+ import com .google .api .gax .resumable .QueryStatusRequest ;
42+ import com .google .api .gax .resumable .QueryStatusResponse ;
4143import com .google .api .gax .resumable .ResumableUploadStatus ;
42- import com .google .common . io . ByteStreams ;
44+ import com .google .api . gax . rpc . ResumableUploadErrorClassifier . Category ;
4345import com .google .common .util .concurrent .MoreExecutors ;
4446import java .io .IOException ;
4547import java .io .InputStream ;
46- import java .util .Arrays ;
4748import java .util .concurrent .CancellationException ;
4849import java .util .concurrent .Executor ;
4950import org .jspecify .annotations .NullMarked ;
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
5963final 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