Skip to content

Commit 3518430

Browse files
feat: data-links - recover from expired signing credentials during large data-link uploads (#652)
* feat: data-links - recover from expired signing credentials during large data-link uploads * refactor: recover expired upload credentials via the merged /upload endpoint Re-signing presigned upload URLs is now a mode of the regular data-link upload endpoint (POST /data-links/{id}/upload with uploadId + partNumbers) rather than a dedicated /upload/refresh endpoint. Adopt that on the client: - refreshUrls() calls generateDataLinkUploadUrl[WithPath] and zips the positional response back to the requested part numbers. - Detect an old Platform (that ignores the fields and initiates a new upload) by the echoed uploadId no longer matching, and fail with a clear message. - Drop the removed DataLinkRefreshMultiPartUpload*/PartUploadUrl models. - Bump tower-java-sdk 1.192.0 -> 1.194.0 (generated from the updated spec). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: version update and cleanup * chore: sync reflect-config.json with latest sdk version * fix: abort orphaned multipart upload; * chore: bump tower-java-sdk version to 1.200.0
1 parent 76fcfba commit 3518430

7 files changed

Lines changed: 694 additions & 95 deletions

File tree

src/main/java/io/seqera/tower/cli/commands/data/links/UploadCmd.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,9 @@ private CloudProviderUploader createUploadStrategy(DataLinkProvider provider, St
151151
case AWS:
152152
return new AwsUploader(id, credId, wspId, outputDir, relativeKey, dataLinksApi());
153153
case GOOGLE:
154-
return new GoogleUploader();
154+
return new GoogleUploader(id, credId, wspId, outputDir, relativeKey, dataLinksApi());
155155
case AZURE:
156-
return new AzureUploader();
156+
return new AzureUploader(id, credId, wspId, outputDir, relativeKey, dataLinksApi());
157157
case SEQERACOMPUTE:
158158
// Seqera Compute uses S3-compatible uploads, same as AWS
159159
return new AwsUploader(id, credId, wspId, outputDir, relativeKey, dataLinksApi());

src/main/java/io/seqera/tower/cli/commands/data/links/upload/AbstractProviderUploader.java

Lines changed: 290 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,69 @@
1616

1717
package 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+
1929
import java.io.File;
2030
import java.io.IOException;
2131
import java.io.RandomAccessFile;
2232
import 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

2447
public 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

Comments
 (0)