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 ;
44+ import com .google .api .gax .rpc .ResumableUploadErrorClassifier .Category ;
4245import com .google .common .util .concurrent .MoreExecutors ;
4346import java .io .IOException ;
4447import java .io .InputStream ;
5053/**
5154 * Coordinates chunk transmission steps of a resumable upload session.
5255 *
56+ * <p>Expects {@code uploadChunkCallable} and {@code queryStatusCallable} to be pre-wrapped in
57+ * retrying callables that handle transient errors.
58+ *
5359 * @param <ResponseT> the type of the final response message returned once the upload completes
5460 */
5561@ InternalApi
@@ -65,6 +71,8 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
6571
6672 private final UnaryCallable <ChunkUploadRequest , ChunkUploadResponse <ResponseT >>
6773 uploadChunkCallable ;
74+ private final UnaryCallable <QueryStatusRequest , QueryStatusResponse <ResponseT >>
75+ queryStatusCallable ;
6876 private final String uploadUrl ;
6977 private final RewindableStreamBuffer buffer ;
7078 private final ApiCallContext callContext ;
@@ -73,12 +81,15 @@ final class ResumableUploadChunkCoordinator<ResponseT> {
7381
7482 ResumableUploadChunkCoordinator (
7583 UnaryCallable <ChunkUploadRequest , ChunkUploadResponse <ResponseT >> uploadChunkCallable ,
84+ UnaryCallable <QueryStatusRequest , QueryStatusResponse <ResponseT >> queryStatusCallable ,
7685 String uploadUrl ,
7786 InputStream payload ,
7887 int chunkSize ,
7988 ClientContext clientContext ) {
8089 this .uploadChunkCallable =
8190 checkNotNull (uploadChunkCallable , "uploadChunkCallable must not be null" );
91+ this .queryStatusCallable =
92+ checkNotNull (queryStatusCallable , "queryStatusCallable must not be null" );
8293 this .uploadUrl = checkNotNull (uploadUrl , "uploadUrl must not be null" );
8394 checkNotNull (payload , "payload must not be null" );
8495 checkNotNull (clientContext , "clientContext must not be null" );
@@ -113,48 +124,24 @@ private void transmitChunk(long currentOffset) {
113124 return ;
114125 }
115126
116- // Determine if this is the final chunk and build the chunk request.
117- ChunkUploadRequest chunkRequest =
118- ChunkUploadRequest .newBuilder ()
119- .setUploadUrl (uploadUrl )
120- .setPayload (buffer .getBuffer ())
121- .setPayloadLength (buffer .getPayloadLength ())
122- .setOffset (buffer .getBufferBaseOffset ())
123- .setFinal (buffer .isFinal ())
124- .build ();
127+ dispatchCurrentChunk ();
128+ }
125129
130+ private void dispatchCurrentChunk () {
126131 // Dispatch the chunk upload call and register the in-flight future for cancellation.
127- long chunkLength = chunkRequest .getPayloadLength ();
128- boolean isFinal = chunkRequest .isFinal ();
129132 try {
130- ApiFuture <ChunkUploadResponse <ResponseT >> chunkFuture =
131- uploadChunkCallable .futureCall (chunkRequest , callContext );
133+ ApiFuture <ChunkUploadResponse <ResponseT >> chunkFuture = executeChunkWithRecovery ();
132134 this .currentChunkFuture = chunkFuture ;
133135 if (result .isCancelled ()) {
134136 chunkFuture .cancel (true );
135137 return ;
136138 }
137-
138139 ApiFutures .addCallback (
139140 chunkFuture ,
140141 new ApiFutureCallback <ChunkUploadResponse <ResponseT >>() {
141142 @ Override
142143 public void onSuccess (ChunkUploadResponse <ResponseT > response ) {
143- if (result .isDone ()) {
144- return ;
145- }
146- long nextOffset = currentOffset + chunkLength ;
147- if (response .getUploadStatus () == ResumableUploadStatus .FINAL ) {
148- result .set (response .getResponse ());
149- } else if (isFinal ) {
150- result .setException (
151- new IllegalStateException (
152- "Upload stream ended and final chunk was transmitted, but server returned"
153- + " incomplete status for upload URL: "
154- + uploadUrl ));
155- } else {
156- chunkExecutor .execute (() -> transmitChunk (nextOffset ));
157- }
144+ chunkExecutor .execute (() -> onChunkUploaded (response ));
158145 }
159146
160147 @ Override
@@ -170,4 +157,104 @@ public void onFailure(Throwable t) {
170157 result .setException (t );
171158 }
172159 }
160+
161+ private ApiFuture <ChunkUploadResponse <ResponseT >> executeChunkWithRecovery () {
162+ ApiFuture <ChunkUploadResponse <ResponseT >> transmitted =
163+ ApiFutures .catchingAsync (
164+ uploadChunkCallable .futureCall (buildCurrentChunkRequest (), callContext ),
165+ Throwable .class ,
166+ t -> {
167+ Category category =
168+ ResumableUploadErrorClassifier .classify (t , ResumableUploadCommand .UPLOAD );
169+ if (category == Category .RECOVERABLE ) {
170+ return recover ();
171+ }
172+ // Category.TRANSIENT errors reaching here have already exhausted their retry budget
173+ // in the underlying RetryingCallable and become fatal per protocol specification.
174+ return ApiFutures .immediateFailedFuture (t );
175+ },
176+ chunkExecutor );
177+ return ApiFutures .transformAsync (
178+ transmitted ,
179+ response -> {
180+ if (response .getUploadStatus () == ResumableUploadStatus .UNKNOWN ) {
181+ return recover ();
182+ }
183+ return ApiFutures .immediateFuture (response );
184+ },
185+ chunkExecutor );
186+ }
187+
188+ private ApiFuture <ChunkUploadResponse <ResponseT >> recover () {
189+ return ApiFutures .transformAsync (
190+ queryStatusCallable .futureCall (QueryStatusRequest .create (uploadUrl ), callContext ),
191+ this ::resumeFrom ,
192+ chunkExecutor );
193+ }
194+
195+ private ApiFuture <ChunkUploadResponse <ResponseT >> resumeFrom (
196+ QueryStatusResponse <ResponseT > queryResponse ) throws IOException {
197+ if (queryResponse .getUploadStatus () == ResumableUploadStatus .UNKNOWN ) {
198+ throw protocolViolation (
199+ "Query status response missing X-Goog-Upload-Status header for upload URL: " + uploadUrl );
200+ }
201+ if (queryResponse .getUploadStatus () == ResumableUploadStatus .FINAL ) {
202+ return ApiFutures .immediateFuture (
203+ ChunkUploadResponse .create (ResumableUploadStatus .FINAL , queryResponse .getResponse ()));
204+ }
205+ Long committedOffset = queryResponse .getCommittedOffset ();
206+ if (committedOffset == null ) {
207+ throw protocolViolation (
208+ "Incomplete query status response did not include a committed offset for upload URL: "
209+ + uploadUrl );
210+ }
211+ buffer .realignTo (committedOffset );
212+ return executeChunkWithRecovery ();
213+ }
214+
215+ private void onChunkUploaded (ChunkUploadResponse <ResponseT > response ) {
216+ if (result .isDone ()) {
217+ return ;
218+ }
219+ long nextOffset = buffer .getBufferBaseOffset () + buffer .getPayloadLength ();
220+ if (response .getUploadStatus () == ResumableUploadStatus .FINAL ) {
221+ result .set (response .getResponse ());
222+ } else if (buffer .isFinal ()) {
223+ result .setException (
224+ new IllegalStateException (
225+ "Upload stream ended and final chunk was transmitted, but server returned"
226+ + " incomplete status for upload URL: "
227+ + uploadUrl ));
228+ } else {
229+ chunkExecutor .execute (() -> transmitChunk (nextOffset ));
230+ }
231+ }
232+
233+ private ChunkUploadRequest buildCurrentChunkRequest () {
234+ // Determine if this is the final chunk and build the chunk request.
235+ return ChunkUploadRequest .newBuilder ()
236+ .setUploadUrl (uploadUrl )
237+ .setPayload (buffer .getBuffer ())
238+ .setPayloadLength (buffer .getPayloadLength ())
239+ .setOffset (buffer .getBufferBaseOffset ())
240+ .setFinal (buffer .isFinal ())
241+ .build ();
242+ }
243+
244+ private static final StatusCode FAILED_PRECONDITION_STATUS_CODE =
245+ new StatusCode () {
246+ @ Override
247+ public StatusCode .Code getCode () {
248+ return StatusCode .Code .FAILED_PRECONDITION ;
249+ }
250+
251+ @ Override
252+ public @ Nullable Object getTransportCode () {
253+ return null ;
254+ }
255+ };
256+
257+ private static FailedPreconditionException protocolViolation (String message ) {
258+ return new FailedPreconditionException (message , null , FAILED_PRECONDITION_STATUS_CODE , false );
259+ }
173260}
0 commit comments