1616
1717package io .seqera .tower .cli .commands .data .links .upload ;
1818
19+ import io .seqera .tower .ApiException ;
20+ import io .seqera .tower .api .DataLinksApi ;
21+ import io .seqera .tower .cli .exceptions .TowerRuntimeException ;
22+ import io .seqera .tower .cli .utils .progress .ProgressTracker ;
23+ import io .seqera .tower .cli .utils .progress .ProgressTrackingBodyPublisher ;
24+ import io .seqera .tower .model .DataLinkFinishMultiPartUploadRequest ;
25+ import io .seqera .tower .model .DataLinkMultiPartUploadRequest ;
26+ import io .seqera .tower .model .DataLinkMultiPartUploadResponse ;
27+ import io .seqera .tower .model .UploadEtag ;
28+
1929import java .io .File ;
2030import java .io .IOException ;
2131import java .io .RandomAccessFile ;
2232import java .io .UncheckedIOException ;
33+ import java .net .URI ;
34+ import java .net .http .HttpClient ;
35+ import java .net .http .HttpRequest ;
36+ import java .net .http .HttpResponse ;
37+ import java .util .ArrayList ;
38+ import java .util .Collections ;
39+ import java .util .HashMap ;
40+ import java .util .List ;
41+ import java .util .Map ;
42+ import java .util .concurrent .ThreadLocalRandom ;
43+ import java .util .function .Supplier ;
44+ import java .util .regex .Matcher ;
45+ import java .util .regex .Pattern ;
2346
2447public abstract class AbstractProviderUploader implements CloudProviderUploader {
2548
2649 static final Integer MULTI_UPLOAD_PART_SIZE_IN_BYTES = 250 * 1024 * 1024 ; // 250 MB
2750
51+ /** Max attempts per part, covering both refresh-on-expiry and transient-error retries. */
52+ static final int MAX_PART_ATTEMPTS = 5 ;
53+
54+ /** Number of upcoming parts whose URLs are refreshed in a single call when a credential expires. */
55+ static final int REFRESH_WINDOW = 100 ;
56+
57+ private static final long BACKOFF_BASE_MILLIS = 500L ;
58+ private static final long BACKOFF_MAX_MILLIS = 10_000L ;
59+
60+ // Provider error codes (from the S3/Azure XML <Error><Code>...</Code></Error> body) that mean the
61+ // signing credentials have expired and the URL must be refreshed before retrying.
62+ private static final Pattern ERROR_CODE = Pattern .compile ("<Code>(.*?)</Code>" , Pattern .DOTALL );
63+
64+ protected final String id ;
65+ protected final String credId ;
66+ protected final Long wspId ;
67+ protected final String outputDir ;
68+ protected final String relativeKey ;
69+ protected final DataLinksApi dataLinksApi ;
70+
71+ protected AbstractProviderUploader (String id , String credId , Long wspId , String outputDir , String relativeKey , DataLinksApi dataLinksApi ) {
72+ this .id = id ;
73+ this .credId = credId ;
74+ this .wspId = wspId ;
75+ this .outputDir = outputDir ;
76+ this .relativeKey = relativeKey ;
77+ this .dataLinksApi = dataLinksApi ;
78+ }
79+
80+ protected enum UploadErrorType { EXPIRY , TRANSIENT , HARD_FAIL }
81+
2882 protected byte [] getChunk (File file , int index ) {
2983 try (RandomAccessFile raf = new RandomAccessFile (file , "r" )) {
3084 long start = (long ) index * MULTI_UPLOAD_PART_SIZE_IN_BYTES ;
@@ -40,4 +94,239 @@ protected byte[] getChunk(File file, int index) {
4094 throw new UncheckedIOException (e );
4195 }
4296 }
43- }
97+
98+ protected int totalParts (long contentLength ) {
99+ if (contentLength <= 0 ) {
100+ return 1 ;
101+ }
102+ return (int ) Math .ceil ((double ) contentLength / MULTI_UPLOAD_PART_SIZE_IN_BYTES );
103+ }
104+
105+ /**
106+ * Uploads a single part.
107+ * When {@code refreshable}, an expiry-class error causes the presigned URL (and a forward window of upcoming parts)
108+ * to be refreshed via the Platform and the part retried;
109+ * otherwise an expiry is terminal (providers such as Azure/GCS cannot re-mint URLs for an in-progress upload).
110+ *
111+ * In both modes a transient error retries the same URL with exponential backoff.
112+ * On a hard failure, or once the attempt budget is exhausted, the error is propagated so the
113+ * caller can finalize/abort the upload.
114+ *
115+ * @param partUrls mutable part-number -> URL map, seeded from the initial upload response and
116+ * updated in place as URLs are refreshed
117+ * @param uploadId the in-progress multi-part upload id (may be {@code null}, e.g. for Azure)
118+ * @param successStatus the HTTP status that indicates a successful part upload (200 for S3, 201 for Azure)
119+ * @param refreshable whether the provider supports refreshing URLs on expiry (S3 only)
120+ * @return the successful HTTP response (headers/body available to the caller, e.g. for the S3 ETag)
121+ */
122+ protected HttpResponse <String > uploadPartWithRetry (HttpClient client , Map <Integer , String > partUrls , int partNumber ,
123+ byte [] chunk , ProgressTracker tracker , String uploadId , long contentLength , int successStatus , boolean refreshable )
124+ throws ApiException , IOException , InterruptedException {
125+
126+ long baseline = tracker .snapshot ();
127+ for (int attempt = 1 ; attempt <= MAX_PART_ATTEMPTS ; attempt ++) {
128+ String url = partUrls .get (partNumber );
129+ if (url == null ) {
130+ if (!refreshable ) {
131+ throw new TowerRuntimeException ("Failed to obtain an upload URL for part " + partNumber );
132+ }
133+ partUrls .putAll (refreshUrls (uploadId , contentLength , refreshWindow (partNumber , contentLength )));
134+ url = partUrls .get (partNumber );
135+ if (url == null ) {
136+ throw new TowerRuntimeException ("Failed to obtain an upload URL for part " + partNumber );
137+ }
138+ }
139+
140+ final String partUrl = url ;
141+ HttpResponse <String > response = sendWithRetryOnTransientError (client , tracker , baseline ,
142+ () -> HttpRequest .newBuilder ()
143+ .uri (URI .create (partUrl ))
144+ .PUT (new ProgressTrackingBodyPublisher (chunk , tracker ))
145+ .build ());
146+
147+ if (response .statusCode () == successStatus ) {
148+ return response ;
149+ }
150+
151+ // Non-success: discard the bytes this attempt reported before deciding what to do.
152+ tracker .restore (baseline );
153+ UploadErrorType type = classify (response .statusCode (), response .body ());
154+ if (type == UploadErrorType .EXPIRY && refreshable && attempt < MAX_PART_ATTEMPTS ) {
155+ // Re-sign this part and a forward window of upcoming parts in a single call, then retry.
156+ partUrls .putAll (refreshUrls (uploadId , contentLength , refreshWindow (partNumber , contentLength )));
157+ continue ;
158+ }
159+ throw new IOException ("Failed to upload part " + partNumber + ": HTTP " + response .statusCode ()
160+ + (isNotEmpty (response .body ()) ? ", Message: " + response .body () : "" ));
161+ }
162+ throw new IOException ("Failed to upload part " + partNumber + " after " + MAX_PART_ATTEMPTS + " attempts" );
163+ }
164+
165+ /**
166+ * Sends a request with transient-failure recovery shared by all providers: network errors and transient
167+ * HTTP responses (5xx / throttling, per {@link #classify}) are retried by re-sending the same request
168+ * with exponential backoff, up to {@link #MAX_PART_ATTEMPTS}. Returns the first response that is not a
169+ * transient failure — the caller decides whether that means success, expiry, resume, or hard failure.
170+ * Throws the last network error if the budget is exhausted by network failures.
171+ *
172+ * @param baseline the tracker snapshot taken before the first attempt (see {@link ProgressTracker#snapshot()})
173+ * @param request factory invoked once per attempt to build a fresh request (and body publisher)
174+ */
175+ protected HttpResponse <String > sendWithRetryOnTransientError (HttpClient client , ProgressTracker tracker , long baseline ,
176+ Supplier <HttpRequest > request ) throws IOException , InterruptedException {
177+
178+ IOException lastError = null ;
179+ for (int attempt = 1 ; attempt <= MAX_PART_ATTEMPTS ; attempt ++) {
180+ tracker .restore (baseline );
181+ try {
182+ HttpResponse <String > response = client .send (request .get (), HttpResponse .BodyHandlers .ofString ());
183+ if (classify (response .statusCode (), response .body ()) == UploadErrorType .TRANSIENT && attempt < MAX_PART_ATTEMPTS ) {
184+ backoff (attempt );
185+ continue ;
186+ }
187+ return response ;
188+ } catch (IOException e ) {
189+ // Network-level failure (connection reset, socket timeout, ...) — treat as transient.
190+ lastError = e ;
191+ if (attempt == MAX_PART_ATTEMPTS ) {
192+ break ;
193+ }
194+ backoff (attempt );
195+ }
196+ }
197+ throw lastError != null ? lastError : new IOException ("Request failed after " + MAX_PART_ATTEMPTS + " attempts" );
198+ }
199+
200+ private List <Integer > refreshWindow (int partNumber , long contentLength ) {
201+ int total = totalParts (contentLength );
202+ List <Integer > parts = new ArrayList <>();
203+ for (int p = partNumber ; p < partNumber + REFRESH_WINDOW && p <= total ; p ++) {
204+ parts .add (p );
205+ }
206+ return parts ;
207+ }
208+
209+ /**
210+ * Requests freshly-signed upload URLs for the given part numbers.
211+ */
212+ protected Map <Integer , String > refreshUrls (String uploadId , long contentLength , List <Integer > partNumbers ) throws ApiException {
213+ DataLinkMultiPartUploadRequest request = new DataLinkMultiPartUploadRequest ();
214+ request .setUploadId (uploadId );
215+ request .setFileName (relativeKey );
216+ request .setContentLength (contentLength );
217+ request .setPartNumbers (partNumbers );
218+
219+ DataLinkMultiPartUploadResponse response ;
220+ try {
221+ response = outputDir != null
222+ ? dataLinksApi .generateDataLinkUploadUrlWithPath (id , outputDir , request , credId , wspId , null )
223+ : dataLinksApi .generateDataLinkUploadUrl (id , request , credId , wspId , null );
224+ } catch (ApiException e ) {
225+ if (e .getCode () == 404 ) {
226+ throw new TowerRuntimeException ("Token refresh is not supported for this Platform version." );
227+ }
228+ throw e ;
229+ }
230+
231+ // A Platform that predates re-signing ignores the uploadId/partNumbers fields and instead initiates a
232+ // brand-new multi-part upload, returning a different uploadId. Detect that by the echoed uploadId and
233+ // fail clearly rather than mixing URLs from a different upload into the in-progress one.
234+ if (!uploadId .equals (response .getUploadId ())) {
235+ abandonUpload (response .getUploadId ());
236+ throw new TowerRuntimeException ("Token refresh is not supported for this Platform version." );
237+ }
238+
239+ List <String > urls = response .getUploadUrls ();
240+ Map <Integer , String > map = new HashMap <>();
241+ if (urls != null ) {
242+ if (urls .size () != partNumbers .size ()) {
243+ throw new TowerRuntimeException ("Platform returned " + urls .size ()
244+ + " refreshed upload URLs but " + partNumbers .size () + " were requested" );
245+ }
246+ for (int i = 0 ; i < partNumbers .size (); i ++) {
247+ map .put (partNumbers .get (i ), urls .get (i ));
248+ }
249+ }
250+ return map ;
251+ }
252+
253+ /**
254+ * Finalizes a multi-part upload on the Platform. With {@code withError} the upload is aborted instead of
255+ * committed, which is also how an unwanted upload is cleaned up.
256+ */
257+ protected void finishUpload (String uploadId , boolean withError , List <UploadEtag > tags ) throws ApiException {
258+ DataLinkFinishMultiPartUploadRequest request = new DataLinkFinishMultiPartUploadRequest ();
259+ request .setFileName (relativeKey );
260+ request .setUploadId (uploadId );
261+ request .setWithError (withError );
262+ request .setTags (tags );
263+
264+ if (outputDir != null ) {
265+ dataLinksApi .finishDataLinkUploadWithPath (id , outputDir , request , credId , wspId );
266+ } else {
267+ dataLinksApi .finishDataLinkUpload (id , request , credId , wspId );
268+ }
269+ }
270+
271+ private void abandonUpload (String uploadId ) {
272+ if (uploadId == null ) {
273+ return ;
274+ }
275+ try {
276+ finishUpload (uploadId , true , Collections .emptyList ());
277+ } catch (Exception e ) {
278+ // ignore — cleanup is best-effort against a Platform that may not support it
279+ }
280+ }
281+
282+ /**
283+ * Classifies a failed part upload from its HTTP status and provider error body:
284+ * <ul>
285+ * <li>EXPIRY — the signing credentials expired; the URL must be refreshed before retrying</li>
286+ * <li>TRANSIENT — a temporary error (5xx / 429 / throttling / network); retry the same URL with backoff</li>
287+ * <li>HARD_FAIL — anything else; do not retry</li>
288+ * </ul>
289+ */
290+ protected UploadErrorType classify (int statusCode , String body ) {
291+ String code = extractErrorCode (body );
292+ if (code != null ) {
293+ switch (code ) {
294+ case "ExpiredToken" : // S3
295+ case "SignatureDoesNotMatch" : // S3
296+ case "RequestTimeTooSkewed" : // S3
297+ return UploadErrorType .EXPIRY ;
298+ case "InternalError" : // S3
299+ case "SlowDown" : // S3 throttling
300+ case "RequestTimeout" : // S3
301+ case "ServerBusy" : // Azure throttling
302+ case "OperationTimedOut" : // Azure
303+ return UploadErrorType .TRANSIENT ;
304+ default :
305+ // fall through to status-based classification
306+ }
307+ }
308+ // 429 is how GCS (and some fronting proxies) signal throttling, without an XML error body.
309+ if (statusCode == 429 || statusCode == 500 || statusCode == 502 || statusCode == 503 || statusCode == 504 ) {
310+ return UploadErrorType .TRANSIENT ;
311+ }
312+ return UploadErrorType .HARD_FAIL ;
313+ }
314+
315+ protected static String extractErrorCode (String body ) {
316+ if (!isNotEmpty (body )) {
317+ return null ;
318+ }
319+ Matcher m = ERROR_CODE .matcher (body );
320+ return m .find () ? m .group (1 ).trim () : null ;
321+ }
322+
323+ protected void backoff (int attempt ) throws InterruptedException {
324+ long base = BACKOFF_BASE_MILLIS * (1L << (attempt - 1 ));
325+ long jitter = ThreadLocalRandom .current ().nextLong (BACKOFF_BASE_MILLIS / 2 );
326+ Thread .sleep (Math .min (base + jitter , BACKOFF_MAX_MILLIS ));
327+ }
328+
329+ private static boolean isNotEmpty (String s ) {
330+ return s != null && !s .isEmpty ();
331+ }
332+ }
0 commit comments