3434import com .google .api .core .ApiFuture ;
3535import com .google .api .core .ApiFutureCallback ;
3636import com .google .api .core .ApiFutures ;
37+ import com .google .api .core .SettableApiFuture ;
3738import com .google .api .gax .resumable .ChunkUploadRequest ;
3839import com .google .api .gax .resumable .ChunkUploadResponse ;
39- import com .google .api .gax .retrying .NonCancellableFuture ;
40+ import com .google .api .gax .resumable .QueryStatusRequest ;
41+ import com .google .api .gax .resumable .QueryStatusResponse ;
4042import com .google .api .gax .retrying .RetryingFuture ;
4143import com .google .common .util .concurrent .MoreExecutors ;
4244import java .time .Duration ;
4850 * A {@link Callable} representing an attempt to transmit a single chunk in a resumable upload
4951 * session. Used with {@link com.google.api.gax.retrying.ScheduledRetryingExecutor}.
5052 *
51- * <p>Execution follows the standard six-step attempt template with an initial attempt preparation
52- * seam. The callable never blocks on {@code .get()}; results and cancellations propagate
53- * asynchronously.
53+ * <p>Execution follows the standard attempt template with pre-attempt recovery handling. When the
54+ * previous attempt failed with a Category 2 (recoverable) error or missing status header, {@code
55+ * prepareAttempt} queries session status, realigns the buffer window, tops up from the stream, and
56+ * dispatches the chunk upload request. The callable never blocks on {@code .get()}; results and
57+ * cancellations propagate asynchronously.
5458 *
5559 * @param <ResponseT> the type of the final response message once the upload completes
5660 */
@@ -59,43 +63,199 @@ class ChunkAttemptCallable<ResponseT> implements Callable<ChunkUploadResponse<Re
5963
6064 private final UnaryCallable <ChunkUploadRequest , ChunkUploadResponse <ResponseT >>
6165 uploadChunkCallable ;
62- private final ChunkUploadRequest request ;
66+ private final UnaryCallable <QueryStatusRequest , QueryStatusResponse <ResponseT >>
67+ queryStatusCallable ;
68+ private final RewindableStreamBuffer buffer ;
69+ private final String uploadUrl ;
6370 private final ApiCallContext originalCallContext ;
64- private final UploadCommand command ;
71+
72+ private volatile ChunkUploadRequest currentRequest ;
73+ private volatile UploadCommand currentCommand ;
6574
6675 private volatile @ Nullable RetryingFuture <ChunkUploadResponse <ResponseT >> retryingFuture ;
76+ private volatile @ Nullable ApiFuture <?> inFlightFuture ;
6777 private volatile @ Nullable Throwable lastFailure ;
78+ private volatile @ Nullable ChunkUploadResponse <ResponseT > lastResponse ;
6879
6980 ChunkAttemptCallable (
7081 UnaryCallable <ChunkUploadRequest , ChunkUploadResponse <ResponseT >> uploadChunkCallable ,
82+ UnaryCallable <QueryStatusRequest , QueryStatusResponse <ResponseT >> queryStatusCallable ,
83+ RewindableStreamBuffer buffer ,
84+ String uploadUrl ,
7185 ChunkUploadRequest request ,
7286 ApiCallContext callContext ,
7387 UploadCommand command ) {
7488 this .uploadChunkCallable =
7589 checkNotNull (uploadChunkCallable , "uploadChunkCallable must not be null" );
76- this .request = checkNotNull (request , "request must not be null" );
90+ this .queryStatusCallable =
91+ checkNotNull (queryStatusCallable , "queryStatusCallable must not be null" );
92+ this .buffer = checkNotNull (buffer , "buffer must not be null" );
93+ this .uploadUrl = checkNotNull (uploadUrl , "uploadUrl must not be null" );
94+ this .currentRequest = checkNotNull (request , "request must not be null" );
7795 this .originalCallContext = checkNotNull (callContext , "callContext must not be null" );
78- this .command = checkNotNull (command , "command must not be null" );
96+ this .currentCommand = checkNotNull (command , "command must not be null" );
7997 }
8098
8199 void setRetryingFuture (RetryingFuture <ChunkUploadResponse <ResponseT >> retryingFuture ) {
82100 this .retryingFuture = checkNotNull (retryingFuture , "retryingFuture must not be null" );
83101 }
84102
103+ private boolean needsRecovery () {
104+ if (lastFailure != null ) {
105+ UploadErrorCategory category = UploadErrorClassifier .classify (lastFailure , currentCommand );
106+ return category == UploadErrorCategory .RECOVERABLE ;
107+ }
108+ if (lastResponse != null && lastResponse .getUploadStatus () == null ) {
109+ UploadErrorCategory category =
110+ UploadErrorClassifier .classifyMissingStatusHeader (currentCommand );
111+ return category == UploadErrorCategory .RECOVERABLE ;
112+ }
113+ return false ;
114+ }
115+
116+ private void failAttempt (
117+ SettableApiFuture <ChunkUploadResponse <ResponseT >> attemptFuture , Throwable t ) {
118+ lastFailure = t ;
119+ lastResponse = null ;
120+ attemptFuture .setException (t );
121+ }
122+
85123 /**
86- * Pre-attempt hook called before each transmission attempt.
87- *
88- * <p>In this phase, Category 2 (recoverable) errors throw to fail fast. Subsequent phases expand
89- * this seam into the query status -> realign buffer -> top up recovery sequence.
124+ * Pre-attempt recovery step invoked before transmitting an attempt when the previous attempt
125+ * encountered a Category 2 (recoverable) error or missing status header.
90126 */
91- void prepareAttempt () {
92- if (lastFailure != null ) {
93- UploadErrorCategory category = UploadErrorClassifier .classify (lastFailure , command );
94- if (category == UploadErrorCategory .RECOVERABLE ) {
95- throw new UnsupportedOperationException (
96- "Category 2 (recoverable) error recovery is not yet implemented" , lastFailure );
97- }
127+ private void prepareAttempt (
128+ SettableApiFuture <ChunkUploadResponse <ResponseT >> attemptFuture ,
129+ ApiCallContext attemptContext ,
130+ RetryingFuture <ChunkUploadResponse <ResponseT >> currentRetryingFuture ) {
131+ QueryStatusRequest queryRequest = QueryStatusRequest .create (uploadUrl );
132+ ApiFuture <QueryStatusResponse <ResponseT >> queryFuture =
133+ queryStatusCallable .futureCall (queryRequest , attemptContext );
134+ if (queryFuture == null ) {
135+ failAttempt (
136+ attemptFuture , new IllegalStateException ("queryStatusCallable returned a null future" ));
137+ return ;
98138 }
139+ this .inFlightFuture = queryFuture ;
140+
141+ ApiFutures .addCallback (
142+ queryFuture ,
143+ new ApiFutureCallback <QueryStatusResponse <ResponseT >>() {
144+ @ Override
145+ public void onSuccess (QueryStatusResponse <ResponseT > queryResponse ) {
146+ handleQuerySuccess (
147+ queryResponse , attemptFuture , attemptContext , currentRetryingFuture );
148+ }
149+
150+ @ Override
151+ public void onFailure (Throwable t ) {
152+ failAttempt (attemptFuture , t );
153+ }
154+ },
155+ MoreExecutors .directExecutor ());
156+ }
157+
158+ private void handleQuerySuccess (
159+ QueryStatusResponse <ResponseT > queryResponse ,
160+ SettableApiFuture <ChunkUploadResponse <ResponseT >> attemptFuture ,
161+ ApiCallContext attemptContext ,
162+ RetryingFuture <ChunkUploadResponse <ResponseT >> currentRetryingFuture ) {
163+ if (currentRetryingFuture .isDone ()) {
164+ return ;
165+ }
166+
167+ if (queryResponse .getUploadStatus () == null ) {
168+ failAttempt (
169+ attemptFuture ,
170+ RewindableStreamBuffer .protocolViolation (
171+ "Query status response missing X-Goog-Upload-Status header for upload URL: "
172+ + uploadUrl ));
173+ return ;
174+ }
175+
176+ // Server already finalized the upload.
177+ if (queryResponse .isComplete ()) {
178+ ChunkUploadResponse <ResponseT > response =
179+ ChunkUploadResponse .create (
180+ true , queryResponse .getResponse (), queryResponse .getUploadStatus ());
181+ lastFailure = null ;
182+ lastResponse = response ;
183+ attemptFuture .set (response );
184+ return ;
185+ }
186+
187+ // Incomplete query response with null committed offset violates the protocol invariant.
188+ Long committedOffset = queryResponse .getCommittedOffset ();
189+ if (committedOffset == null ) {
190+ failAttempt (
191+ attemptFuture ,
192+ RewindableStreamBuffer .protocolViolation (
193+ "Incomplete query status response did not include a committed offset for upload URL: "
194+ + uploadUrl ));
195+ return ;
196+ }
197+
198+ // Normal path: realign buffer to committedOffset, compact and top up.
199+ try {
200+ buffer .realignTo (committedOffset );
201+ } catch (Throwable e ) {
202+ failAttempt (attemptFuture , e );
203+ return ;
204+ }
205+
206+ // Determine the upload command for the realigned buffer.
207+ // Preserve upload,finalize for a trailing partial after realignment.
208+ UploadCommand realignedCommand ;
209+ if (buffer .isFinal ()) {
210+ realignedCommand = buffer .isEmpty () ? UploadCommand .FINALIZE : UploadCommand .UPLOAD_FINALIZE ;
211+ } else {
212+ realignedCommand = UploadCommand .UPLOAD ;
213+ }
214+
215+ ChunkUploadRequest realignedRequest =
216+ ChunkUploadRequest .newBuilder ()
217+ .setUploadUrl (uploadUrl )
218+ .setPayload (buffer .getBuffer ())
219+ .setPayloadLength (buffer .getPayloadLength ())
220+ .setOffset (buffer .getBufferBaseOffset ())
221+ .setFinal (buffer .isFinal ())
222+ .build ();
223+
224+ this .currentRequest = realignedRequest ;
225+ this .currentCommand = realignedCommand ;
226+
227+ dispatchChunkUpload (attemptFuture , attemptContext , currentRetryingFuture );
228+ }
229+
230+ private void dispatchChunkUpload (
231+ SettableApiFuture <ChunkUploadResponse <ResponseT >> attemptFuture ,
232+ ApiCallContext attemptContext ,
233+ RetryingFuture <ChunkUploadResponse <ResponseT >> currentRetryingFuture ) {
234+ attemptContext
235+ .getTracer ()
236+ .attemptStarted (
237+ currentRequest , currentRetryingFuture .getAttemptSettings ().getOverallAttemptCount ());
238+
239+ ApiFuture <ChunkUploadResponse <ResponseT >> chunkFuture =
240+ uploadChunkCallable .futureCall (currentRequest , attemptContext );
241+ this .inFlightFuture = chunkFuture ;
242+
243+ ApiFutures .addCallback (
244+ chunkFuture ,
245+ new ApiFutureCallback <ChunkUploadResponse <ResponseT >>() {
246+ @ Override
247+ public void onSuccess (ChunkUploadResponse <ResponseT > response ) {
248+ lastFailure = null ;
249+ lastResponse = response ;
250+ attemptFuture .set (response );
251+ }
252+
253+ @ Override
254+ public void onFailure (Throwable t ) {
255+ failAttempt (attemptFuture , t );
256+ }
257+ },
258+ MoreExecutors .directExecutor ());
99259 }
100260
101261 @ Override
@@ -104,64 +264,38 @@ void prepareAttempt() {
104264 checkNotNull (retryingFuture , "retryingFuture must be set before call()" );
105265 ApiCallContext attemptContext = originalCallContext ;
106266
107- try {
108- // Seam for recoverable error handling.
109- prepareAttempt ();
110-
111- // Set the RPC timeout if caller did not provide their own.
112- Duration rpcTimeout = currentRetryingFuture .getAttemptSettings ().getRpcTimeoutDuration ();
113- if (!rpcTimeout .isZero () && attemptContext .getTimeoutDuration () == null ) {
114- attemptContext = attemptContext .withTimeoutDuration (rpcTimeout );
115- }
116-
117- // Placeholder non-cancellable future.
118- currentRetryingFuture .setAttemptFuture (
119- new NonCancellableFuture <ChunkUploadResponse <ResponseT >>());
267+ Duration rpcTimeout = currentRetryingFuture .getAttemptSettings ().getRpcTimeoutDuration ();
268+ if (!rpcTimeout .isZero () && attemptContext .getTimeoutDuration () == null ) {
269+ attemptContext = attemptContext .withTimeoutDuration (rpcTimeout );
270+ }
120271
121- // Early exit if retryingFuture was already cancelled or completed.
122- if (currentRetryingFuture .isDone ()) {
123- return null ;
124- }
272+ SettableApiFuture <ChunkUploadResponse <ResponseT >> attemptFuture = SettableApiFuture .create ();
273+ currentRetryingFuture .setAttemptFuture (attemptFuture );
125274
126- // Dispatch chunk upload and wire cancellation propagation and error
127- // tracking.
128- attemptContext
129- .getTracer ()
130- .attemptStarted (
131- request , currentRetryingFuture .getAttemptSettings ().getOverallAttemptCount ());
132-
133- ApiFuture <ChunkUploadResponse <ResponseT >> internalFuture =
134- uploadChunkCallable .futureCall (request , attemptContext );
135-
136- // Propagate cancellation to the feeder future immediately.
137- currentRetryingFuture .addListener (
138- () -> {
139- if (currentRetryingFuture .isCancelled ()) {
140- internalFuture .cancel (true );
141- }
142- },
143- MoreExecutors .directExecutor ());
144-
145- ApiFutures .addCallback (
146- internalFuture ,
147- new ApiFutureCallback <ChunkUploadResponse <ResponseT >>() {
148- @ Override
149- public void onSuccess (ChunkUploadResponse <ResponseT > response ) {
150- lastFailure = null ;
151- }
275+ if (currentRetryingFuture .isDone ()) {
276+ return null ;
277+ }
152278
153- @ Override
154- public void onFailure (Throwable t ) {
155- lastFailure = t ;
279+ currentRetryingFuture .addListener (
280+ () -> {
281+ if (currentRetryingFuture .isCancelled ()) {
282+ ApiFuture <?> inFlight = inFlightFuture ;
283+ if (inFlight != null ) {
284+ inFlight .cancel (true );
156285 }
157- },
158- MoreExecutors .directExecutor ());
286+ attemptFuture .cancel (true );
287+ }
288+ },
289+ MoreExecutors .directExecutor ());
159290
160- currentRetryingFuture .setAttemptFuture (internalFuture );
161- } catch (Throwable e ) {
162- lastFailure = e ;
163- currentRetryingFuture .setAttemptFuture (
164- ApiFutures .<ChunkUploadResponse <ResponseT >>immediateFailedFuture (e ));
291+ try {
292+ if (needsRecovery ()) {
293+ prepareAttempt (attemptFuture , attemptContext , currentRetryingFuture );
294+ } else {
295+ dispatchChunkUpload (attemptFuture , attemptContext , currentRetryingFuture );
296+ }
297+ } catch (Throwable t ) {
298+ failAttempt (attemptFuture , t );
165299 }
166300
167301 return null ;
0 commit comments