15
15
16
16
package software .amazon .awssdk .services .s3 .internal .multipart ;
17
17
18
+ import java .util .Optional ;
18
19
import java .util .concurrent .CompletableFuture ;
19
20
import java .util .concurrent .atomic .AtomicInteger ;
20
- import java .util .regex .Matcher ;
21
- import java .util .regex .Pattern ;
22
21
import org .reactivestreams .Subscriber ;
23
22
import org .reactivestreams .Subscription ;
24
23
import software .amazon .awssdk .annotations .Immutable ;
25
24
import software .amazon .awssdk .annotations .SdkInternalApi ;
26
25
import software .amazon .awssdk .annotations .ThreadSafe ;
27
26
import software .amazon .awssdk .core .async .AsyncResponseTransformer ;
27
+ import software .amazon .awssdk .core .exception .SdkClientException ;
28
28
import software .amazon .awssdk .services .s3 .S3AsyncClient ;
29
29
import software .amazon .awssdk .services .s3 .model .GetObjectResponse ;
30
30
import software .amazon .awssdk .services .s3 .presignedurl .model .PresignedUrlDownloadRequest ;
@@ -49,18 +49,18 @@ public class PresignedUrlMultipartDownloaderSubscriber
49
49
50
50
private static final Logger log = Logger .loggerFor (PresignedUrlMultipartDownloaderSubscriber .class );
51
51
private static final String BYTES_RANGE_PREFIX = "bytes=" ;
52
- private static final Pattern CONTENT_RANGE_PATTERN = Pattern .compile ("bytes\\ s+(\\ d+)-(\\ d+)/(\\ d+)" );
53
52
54
53
private final S3AsyncClient s3AsyncClient ;
55
54
private final PresignedUrlDownloadRequest presignedUrlDownloadRequest ;
56
55
private final Long configuredPartSizeInBytes ;
57
56
private final CompletableFuture <Void > future ;
58
57
private final Object lock = new Object ();
59
58
private final AtomicInteger completedParts ;
59
+ private final AtomicInteger requestsSent ;
60
60
61
- private Long totalContentLength ;
62
- private Integer totalParts ;
63
- private String eTag ;
61
+ private volatile Long totalContentLength ;
62
+ private volatile Integer totalParts ;
63
+ private volatile String eTag ;
64
64
private Subscription subscription ;
65
65
66
66
public PresignedUrlMultipartDownloaderSubscriber (
@@ -71,27 +71,26 @@ public PresignedUrlMultipartDownloaderSubscriber(
71
71
this .presignedUrlDownloadRequest = presignedUrlDownloadRequest ;
72
72
this .configuredPartSizeInBytes = configuredPartSizeInBytes ;
73
73
this .completedParts = new AtomicInteger (0 );
74
+ this .requestsSent = new AtomicInteger (0 );
74
75
this .future = new CompletableFuture <>();
75
76
}
76
77
77
78
@ Override
78
79
public void onSubscribe (Subscription s ) {
79
- synchronized (lock ) {
80
- if (subscription != null ) {
81
- s .cancel ();
82
- return ;
83
- }
84
- this .subscription = s ;
85
- s .request (1 );
80
+ if (subscription != null ) {
81
+ s .cancel ();
82
+ return ;
86
83
}
84
+ this .subscription = s ;
85
+ s .request (1 );
87
86
}
88
87
89
88
@ Override
90
89
public void onNext (AsyncResponseTransformer <GetObjectResponse , GetObjectResponse > asyncResponseTransformer ) {
91
90
if (asyncResponseTransformer == null ) {
92
91
throw new NullPointerException ("onNext must not be called with null asyncResponseTransformer" );
93
92
}
94
-
93
+
95
94
int nextPartIndex ;
96
95
synchronized (lock ) {
97
96
nextPartIndex = completedParts .get ();
@@ -102,16 +101,16 @@ public void onNext(AsyncResponseTransformer<GetObjectResponse, GetObjectResponse
102
101
}
103
102
completedParts .incrementAndGet ();
104
103
}
105
-
106
104
makeRangeRequest (nextPartIndex , asyncResponseTransformer );
107
105
}
108
106
109
107
private void makeRangeRequest (int partIndex ,
110
108
AsyncResponseTransformer <GetObjectResponse ,
111
109
GetObjectResponse > asyncResponseTransformer ) {
112
- PresignedUrlDownloadRequest partRequest = createPartRequest (partIndex );
110
+ PresignedUrlDownloadRequest partRequest = createRangedGetRequest (partIndex );
113
111
log .debug (() -> "Sending range request for part " + partIndex + " with range=" + partRequest .range ());
114
112
113
+ requestsSent .incrementAndGet ();
115
114
s3AsyncClient .presignedUrlExtension ()
116
115
.getObject (partRequest , asyncResponseTransformer )
117
116
.whenComplete ((response , error ) -> {
@@ -120,81 +119,134 @@ private void makeRangeRequest(int partIndex,
120
119
handleError (error );
121
120
return ;
122
121
}
123
- requestMoreIfNeeded (response );
122
+ requestMoreIfNeeded (response , partIndex );
124
123
});
125
124
}
126
125
127
- private void requestMoreIfNeeded (GetObjectResponse response ) {
126
+ private void requestMoreIfNeeded (GetObjectResponse response , int partIndex ) {
128
127
int totalComplete = completedParts .get ();
129
128
log .debug (() -> String .format ("Completed part %d" , totalComplete ));
129
+
130
+ String responseETag = response .eTag ();
131
+ String responseContentRange = response .contentRange ();
132
+ if (eTag == null ) {
133
+ this .eTag = responseETag ;
134
+ log .debug (() -> String .format ("Multipart object ETag: %s" , this .eTag ));
135
+ }
136
+
137
+ Optional <SdkClientException > validationError = validateResponse (response , partIndex );
138
+ if (validationError .isPresent ()) {
139
+ log .debug (() -> "Response validation failed" , validationError .get ());
140
+ handleError (validationError .get ());
141
+ return ;
142
+ }
130
143
131
- synchronized ( lock ) {
132
- if ( eTag == null ) {
133
- this . eTag = response . eTag ();
134
- log . debug (() -> String . format ( "Multipart object ETag: %s" , this . eTag ) );
135
- } else if ( response . eTag () != null && ! eTag . equals ( response . eTag ())) {
136
- handleError (new IllegalStateException ( "ETag mismatch - object may have changed during download" ) );
144
+ if ( totalContentLength == null && responseContentRange != null ) {
145
+ Optional < Long > parsedContentLength = MultipartDownloadUtils . parseContentRangeForTotalSize ( responseContentRange );
146
+ if (! parsedContentLength . isPresent ()) {
147
+ SdkClientException error = PresignedUrlDownloadHelper . invalidContentRangeHeader ( responseContentRange );
148
+ log . debug (() -> "Failed to parse content range" , error );
149
+ handleError (error );
137
150
return ;
138
151
}
139
- if (totalContentLength == null && response .contentRange () != null ) {
140
- try {
141
- validateResponse (response );
142
- this .totalContentLength = parseContentRangeForTotalSize (response .contentRange ());
143
- this .totalParts = calculateTotalParts (totalContentLength , configuredPartSizeInBytes );
144
- log .debug (() -> String .format ("Total content length: %d, Total parts: %d" , totalContentLength , totalParts ));
145
- } catch (Exception e ) {
146
- log .debug (() -> "Failed to parse content range" , e );
147
- handleError (e );
148
- return ;
149
- }
150
- }
151
- if (totalParts != null && totalParts > 1 && totalComplete < totalParts ) {
152
+
153
+ this .totalContentLength = parsedContentLength .get ();
154
+ this .totalParts = calculateTotalParts (totalContentLength , configuredPartSizeInBytes );
155
+ log .debug (() -> String .format ("Total content length: %d, Total parts: %d" , totalContentLength , totalParts ));
156
+ }
157
+
158
+ synchronized (lock ) {
159
+ if (hasMoreParts (totalComplete )) {
152
160
subscription .request (1 );
153
161
} else {
162
+ if (totalParts != null && requestsSent .get () != totalParts ) {
163
+ handleError (new IllegalStateException (
164
+ "Request count mismatch. Expected: " + totalParts + ", sent: " + requestsSent .get ()));
165
+ return ;
166
+ }
154
167
log .debug (() -> String .format ("Completing multipart download after a total of %d parts downloaded." , totalParts ));
155
168
subscription .cancel ();
156
169
}
157
170
}
158
171
}
159
172
160
- private void validateResponse (GetObjectResponse response ) {
173
+ private Optional < SdkClientException > validateResponse (GetObjectResponse response , int partIndex ) {
161
174
if (response == null ) {
162
- throw new IllegalStateException ( "Response cannot be null" );
175
+ return Optional . of ( SdkClientException . create ( "Response cannot be null" ) );
163
176
}
164
- if (response .contentRange () == null ) {
165
- throw new IllegalStateException ("No Content-Range header in response" );
177
+
178
+ String contentRange = response .contentRange ();
179
+ if (contentRange == null ) {
180
+ return Optional .of (PresignedUrlDownloadHelper .missingContentRangeHeader ());
166
181
}
182
+
167
183
Long contentLength = response .contentLength ();
168
184
if (contentLength == null || contentLength < 0 ) {
169
- throw new IllegalStateException ( "Invalid or missing Content-Length in response" );
185
+ return Optional . of ( PresignedUrlDownloadHelper . invalidContentLength () );
170
186
}
171
- }
172
187
173
- private long parseContentRangeForTotalSize (String contentRange ) {
174
- Matcher matcher = CONTENT_RANGE_PATTERN .matcher (contentRange );
175
- if (!matcher .matches ()) {
176
- throw new IllegalArgumentException ("Invalid Content-Range header: " + contentRange );
188
+ long expectedStartByte = partIndex * configuredPartSizeInBytes ;
189
+ long expectedEndByte ;
190
+ if (totalContentLength != null ) {
191
+ expectedEndByte = Math .min (expectedStartByte + configuredPartSizeInBytes - 1 , totalContentLength - 1 );
192
+ } else {
193
+ expectedEndByte = expectedStartByte + configuredPartSizeInBytes - 1 ;
194
+ }
195
+
196
+ String expectedRange = "bytes " + expectedStartByte + "-" + expectedEndByte + "/" ;
197
+ if (!contentRange .startsWith (expectedRange )) {
198
+ return Optional .of (SdkClientException .create (
199
+ "Content-Range mismatch. Expected range starting with: " + expectedRange +
200
+ ", but got: " + contentRange ));
201
+ }
202
+
203
+ long expectedPartSize ;
204
+ if (totalContentLength != null && partIndex == totalParts - 1 ) {
205
+ expectedPartSize = totalContentLength - (partIndex * configuredPartSizeInBytes );
206
+ } else {
207
+ expectedPartSize = configuredPartSizeInBytes ;
208
+ }
209
+
210
+ if (!contentLength .equals (expectedPartSize )) {
211
+ return Optional .of (SdkClientException .create (
212
+ "Part content length validation failed for part " + partIndex +
213
+ ". Expected: " + expectedPartSize + ", but got: " + contentLength ));
177
214
}
178
- return Long .parseLong (matcher .group (3 ));
179
- }
180
215
216
+ long actualStartByte = MultipartDownloadUtils .parseStartByteFromContentRange (contentRange );
217
+ if (actualStartByte != expectedStartByte ) {
218
+ return Optional .of (SdkClientException .create (
219
+ "Content range offset mismatch for part " + partIndex +
220
+ ". Expected start: " + expectedStartByte + ", but got: " + actualStartByte ));
221
+ }
222
+
223
+ return Optional .empty ();
224
+ }
225
+
181
226
private int calculateTotalParts (long contentLength , long partSize ) {
182
227
return (int ) Math .ceil ((double ) contentLength / partSize );
183
228
}
184
229
185
- private PresignedUrlDownloadRequest createPartRequest (int partIndex ) {
230
+ private boolean hasMoreParts (int completedPartsCount ) {
231
+ return totalParts != null && totalParts > 1 && completedPartsCount < totalParts ;
232
+ }
233
+
234
+ private PresignedUrlDownloadRequest createRangedGetRequest (int partIndex ) {
186
235
long startByte = partIndex * configuredPartSizeInBytes ;
187
236
long endByte ;
188
-
189
237
if (totalContentLength != null ) {
190
238
endByte = Math .min (startByte + configuredPartSizeInBytes - 1 , totalContentLength - 1 );
191
239
} else {
192
240
endByte = startByte + configuredPartSizeInBytes - 1 ;
193
241
}
194
242
String rangeHeader = BYTES_RANGE_PREFIX + startByte + "-" + endByte ;
195
- return presignedUrlDownloadRequest .toBuilder ()
196
- .range (rangeHeader )
197
- .build ();
243
+ PresignedUrlDownloadRequest .Builder builder = presignedUrlDownloadRequest .toBuilder ()
244
+ .range (rangeHeader );
245
+ if (partIndex > 0 && eTag != null ) {
246
+ builder .ifMatch (eTag );
247
+ log .debug (() -> "Setting IfMatch header to: " + eTag + " for part " + partIndex );
248
+ }
249
+ return builder .build ();
198
250
}
199
251
200
252
private void handleError (Throwable t ) {
@@ -218,6 +270,6 @@ public void onComplete() {
218
270
}
219
271
220
272
public CompletableFuture <Void > future () {
221
- return this . future ;
273
+ return future ;
222
274
}
223
275
}
0 commit comments