Skip to content

Commit d05a8e3

Browse files
authored
[TM DownloadFile Pause and Resume] Part 3: Implement resumeDownloadFile (#3157)
* Implement resumeDownloadFile * Move test code around * Address feedback * Fix flaky test * fix flaky integ test
1 parent c1d55ac commit d05a8e3

19 files changed

+827
-133
lines changed

core/sdk-core/src/main/java/software/amazon/awssdk/core/FileTransformerConfiguration.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,11 @@ public static FileTransformerConfiguration defaultCreateNew() {
7979
* Returns the default {@link FileTransformerConfiguration} for {@link FileWriteOption#CREATE_OR_REPLACE_EXISTING}
8080
* <p>
8181
* Create a new file if it doesn't exist, otherwise replace the existing file.
82-
* In the event of an error, the SDK will attempt to delete the file (whatever has been written to it so far).
82+
* In the event of an error, the SDK will NOT attempt to delete the file, leaving it as-is
8383
*/
8484
public static FileTransformerConfiguration defaultCreateOrReplaceExisting() {
8585
return builder().fileWriteOption(FileWriteOption.CREATE_OR_REPLACE_EXISTING)
86-
.failureBehavior(FailureBehavior.DELETE)
86+
.failureBehavior(FailureBehavior.LEAVE)
8787
.build();
8888
}
8989

services-custom/s3-transfer-manager/src/it/java/software/amazon/awssdk/transfer/s3/S3TransferManagerDownloadPauseResumeIntegrationTest.java

Lines changed: 99 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,37 +17,49 @@
1717

1818
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
1919
import static software.amazon.awssdk.testutils.service.S3BucketUtils.temporaryBucketName;
20+
import static software.amazon.awssdk.transfer.s3.SizeConstant.MB;
2021

2122
import java.io.File;
23+
import java.io.IOException;
24+
import java.nio.charset.StandardCharsets;
25+
import java.nio.file.Files;
2226
import java.nio.file.Path;
27+
import java.time.Duration;
2328
import java.util.Optional;
24-
import java.util.concurrent.CountDownLatch;
25-
import java.util.concurrent.TimeUnit;
29+
import org.apache.commons.lang3.RandomStringUtils;
2630
import org.junit.jupiter.api.AfterAll;
2731
import org.junit.jupiter.api.BeforeAll;
2832
import org.junit.jupiter.api.Test;
2933
import software.amazon.awssdk.core.SdkResponse;
34+
import software.amazon.awssdk.core.retry.backoff.FixedDelayBackoffStrategy;
35+
import software.amazon.awssdk.core.sync.RequestBody;
36+
import software.amazon.awssdk.core.waiters.Waiter;
37+
import software.amazon.awssdk.core.waiters.WaiterAcceptor;
3038
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
3139
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
3240
import software.amazon.awssdk.testutils.RandomTempFile;
3341
import software.amazon.awssdk.transfer.s3.progress.TransferListener;
42+
import software.amazon.awssdk.transfer.s3.progress.TransferProgressSnapshot;
43+
import software.amazon.awssdk.utils.Logger;
3444

3545
public class S3TransferManagerDownloadPauseResumeIntegrationTest extends S3IntegrationTestBase {
46+
private static final Logger log = Logger.loggerFor(S3TransferManagerDownloadPauseResumeIntegrationTest.class);
3647
private static final String BUCKET = temporaryBucketName(S3TransferManagerDownloadPauseResumeIntegrationTest.class);
3748
private static final String KEY = "key";
38-
private static final int OBJ_SIZE = 16 * 1024 * 1024;
49+
// 24 * MB is chosen to make sure we have data written in the file already upon pausing.
50+
private static final long OBJ_SIZE = 24 * MB;
3951
private static S3TransferManager tm;
40-
private static File file;
52+
private static File sourceFile;
4153

4254
@BeforeAll
4355
public static void setup() throws Exception {
4456
S3IntegrationTestBase.setUp();
4557
createBucket(BUCKET);
46-
file = new RandomTempFile(OBJ_SIZE);
58+
sourceFile = new RandomTempFile(OBJ_SIZE);
4759
s3.putObject(PutObjectRequest.builder()
4860
.bucket(BUCKET)
4961
.key(KEY)
50-
.build(), file.toPath());
62+
.build(), sourceFile.toPath());
5163
tm = S3TransferManager.builder()
5264
.s3ClientConfiguration(b -> b.region(DEFAULT_REGION)
5365
.credentialsProvider(CREDENTIALS_PROVIDER_CHAIN))
@@ -58,50 +70,111 @@ public static void setup() throws Exception {
5870
public static void cleanup() {
5971
deleteBucketAndAllContents(BUCKET);
6072
tm.close();
73+
sourceFile.delete();
6174
S3IntegrationTestBase.cleanUp();
6275
}
6376

6477
@Test
65-
void downloadToFile_pause_shouldReturnResumableDownload() throws InterruptedException {
66-
CountDownLatch countDownLatch = new CountDownLatch(1);
78+
void pauseAndResume_ObjectNotChanged_shouldResumeDownload() {
6779
Path path = RandomTempFile.randomUncreatedFile().toPath();
68-
TestDownloadListener testDownloadListener = new TestDownloadListener(countDownLatch);
80+
TestDownloadListener testDownloadListener = new TestDownloadListener();
6981
DownloadFileRequest request = DownloadFileRequest.builder()
7082
.getObjectRequest(b -> b.bucket(BUCKET).key(KEY))
7183
.destination(path)
7284
.overrideConfiguration(b -> b
7385
.addListener(testDownloadListener))
7486
.build();
75-
FileDownload download =
76-
tm.downloadFile(request);
77-
boolean count = countDownLatch.await(10, TimeUnit.SECONDS);
78-
if (!count) {
79-
throw new AssertionError("No data has been transferred within 5 seconds");
80-
}
81-
ResumableFileDownload pause = download.pause();
82-
assertThat(pause.downloadFileRequest()).isEqualTo(request);
87+
FileDownload download = tm.downloadFile(request);
88+
waitUntilFirstByteBufferDelivered(download);
89+
90+
ResumableFileDownload resumableFileDownload = download.pause();
91+
long bytesTransferred = resumableFileDownload.bytesTransferred();
92+
log.debug(() -> "Paused: " + resumableFileDownload);
93+
assertThat(resumableFileDownload.downloadFileRequest()).isEqualTo(request);
8394
assertThat(testDownloadListener.getObjectResponse).isNotNull();
84-
assertThat(pause.lastModified()).isEqualTo(testDownloadListener.getObjectResponse.lastModified());
85-
assertThat(pause.bytesTransferred()).isEqualTo(path.toFile().length());
86-
assertThat(pause.transferSizeInBytes()).hasValue(file.length());
95+
assertThat(resumableFileDownload.s3ObjectLastModified()).hasValue(testDownloadListener.getObjectResponse.lastModified());
96+
assertThat(bytesTransferred).isEqualTo(path.toFile().length());
97+
assertThat(resumableFileDownload.totalSizeInBytes()).hasValue(sourceFile.length());
98+
99+
assertThat(bytesTransferred).isLessThan(sourceFile.length());
87100
assertThat(download.completionFuture()).isCancelled();
101+
102+
log.debug(() -> "Resuming download ");
103+
verifyFileDownload(path, resumableFileDownload, OBJ_SIZE - bytesTransferred);
104+
}
105+
106+
@Test
107+
void pauseAndResume_objectChanged_shouldStartFromBeginning() {
108+
Path path = RandomTempFile.randomUncreatedFile().toPath();
109+
DownloadFileRequest request = DownloadFileRequest.builder()
110+
.getObjectRequest(b -> b.bucket(BUCKET).key(KEY))
111+
.destination(path)
112+
.build();
113+
FileDownload download = tm.downloadFile(request);
114+
waitUntilFirstByteBufferDelivered(download);
115+
116+
ResumableFileDownload resumableFileDownload = download.pause();
117+
log.debug(() -> "Paused: " + resumableFileDownload);
118+
String newObject = RandomStringUtils.randomAlphanumeric(1000);
119+
120+
// Re-upload the S3 object
121+
s3.putObject(PutObjectRequest.builder()
122+
.bucket(BUCKET)
123+
.key(KEY)
124+
.build(), RequestBody.fromString(newObject));
125+
126+
log.debug(() -> "Resuming download ");
127+
FileDownload resumedFileDownload = tm.resumeDownloadFile(resumableFileDownload);
128+
resumedFileDownload.progress().snapshot();
129+
resumedFileDownload.completionFuture().join();
130+
assertThat(path.toFile()).hasContent(newObject);
131+
assertThat(resumedFileDownload.progress().snapshot().transferSizeInBytes()).hasValue((long) newObject.getBytes(StandardCharsets.UTF_8).length);
132+
}
133+
134+
@Test
135+
void pauseAndResume_fileChanged_shouldStartFromBeginning() throws IOException {
136+
Path path = RandomTempFile.randomUncreatedFile().toPath();
137+
DownloadFileRequest request = DownloadFileRequest.builder()
138+
.getObjectRequest(b -> b.bucket(BUCKET).key(KEY))
139+
.destination(path)
140+
.build();
141+
FileDownload download = tm.downloadFile(request);
142+
waitUntilFirstByteBufferDelivered(download);
143+
144+
ResumableFileDownload resumableFileDownload = download.pause();
145+
Files.write(path, "helloworld".getBytes(StandardCharsets.UTF_8));
146+
147+
verifyFileDownload(path, resumableFileDownload, OBJ_SIZE);
148+
}
149+
150+
private static void verifyFileDownload(Path path, ResumableFileDownload resumableFileDownload, long expectedBytesTransferred) {
151+
FileDownload resumedFileDownload = tm.resumeDownloadFile(resumableFileDownload);
152+
resumedFileDownload.progress().snapshot();
153+
resumedFileDownload.completionFuture().join();
154+
assertThat(path.toFile()).hasSameBinaryContentAs(sourceFile);
155+
assertThat(resumedFileDownload.progress().snapshot().transferSizeInBytes()).hasValue(expectedBytesTransferred);
156+
}
157+
158+
private static void waitUntilFirstByteBufferDelivered(FileDownload download) {
159+
Waiter<TransferProgressSnapshot> waiter = Waiter.builder(TransferProgressSnapshot.class)
160+
.addAcceptor(WaiterAcceptor.successOnResponseAcceptor(r -> r.bytesTransferred() > 0))
161+
.addAcceptor(WaiterAcceptor.retryOnResponseAcceptor(r -> true))
162+
.overrideConfiguration(o -> o.waitTimeout(Duration.ofMinutes(1))
163+
.maxAttempts(Integer.MAX_VALUE)
164+
.backoffStrategy(FixedDelayBackoffStrategy.create(Duration.ofMillis(100))))
165+
.build();
166+
waiter.run(() -> download.progress().snapshot());
88167
}
89168

90169
private static final class TestDownloadListener implements TransferListener {
91-
private final CountDownLatch countDownLatch;
92170
private GetObjectResponse getObjectResponse;
93171

94-
private TestDownloadListener(CountDownLatch countDownLatch) {
95-
this.countDownLatch = countDownLatch;
96-
}
97-
98172
@Override
99173
public void bytesTransferred(Context.BytesTransferred context) {
100174
Optional<SdkResponse> sdkResponse = context.progressSnapshot().sdkResponse();
101175
if (sdkResponse.isPresent() && sdkResponse.get() instanceof GetObjectResponse) {
102176
getObjectResponse = (GetObjectResponse) sdkResponse.get();
103177
}
104-
countDownLatch.countDown();
105178
}
106179
}
107180

services-custom/s3-transfer-manager/src/main/java/software/amazon/awssdk/transfer/s3/ResumableFileDownload.java

Lines changed: 53 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
import java.util.Objects;
2020
import java.util.Optional;
2121
import software.amazon.awssdk.annotations.SdkPublicApi;
22-
import software.amazon.awssdk.utils.ToString;
2322
import software.amazon.awssdk.utils.Validate;
2423
import software.amazon.awssdk.utils.builder.CopyableBuilder;
2524
import software.amazon.awssdk.utils.builder.ToCopyableBuilder;
@@ -28,25 +27,24 @@
2827
* An opaque token that holds the state and can be used to resume a
2928
* paused download operation.
3029
*
31-
* TODO: 1. should we just store GetObjectResponse?
32-
* 2. consider providing a way to serialize and deserialize the token
33-
* 3. Do we need to store file checksum?
34-
*
3530
* @see S3TransferManager#downloadFile(DownloadFileRequest)
3631
*/
3732
@SdkPublicApi
3833
public final class ResumableFileDownload implements ResumableTransfer,
3934
ToCopyableBuilder<ResumableFileDownload.Builder, ResumableFileDownload> {
4035
private final DownloadFileRequest downloadFileRequest;
4136
private final long bytesTransferred;
42-
private final Instant lastModified;
43-
private final Long transferSizeInBytes;
37+
private final Instant s3ObjectLastModified;
38+
private final Long totalSizeInBytes;
39+
private final Instant fileLastModified;
4440

4541
private ResumableFileDownload(DefaultBuilder builder) {
4642
this.downloadFileRequest = Validate.paramNotNull(builder.downloadFileRequest, "downloadFileRequest");
43+
Validate.isPositiveOrNull(builder.bytesTransferred, "bytesTransferred");
4744
this.bytesTransferred = builder.bytesTransferred == null ? 0 : builder.bytesTransferred;
48-
this.lastModified = builder.lastModified;
49-
this.transferSizeInBytes = builder.transferSizeInBytes;
45+
this.s3ObjectLastModified = builder.s3ObjectLastModified;
46+
this.totalSizeInBytes = Validate.isPositiveOrNull(builder.totalSizeInBytes, "totalSizeInBytes");
47+
this.fileLastModified = builder.fileLastModified;
5048
}
5149

5250
@Override
@@ -66,31 +64,25 @@ public boolean equals(Object o) {
6664
if (!downloadFileRequest.equals(that.downloadFileRequest)) {
6765
return false;
6866
}
69-
if (!Objects.equals(lastModified, that.lastModified)) {
67+
if (!Objects.equals(s3ObjectLastModified, that.s3ObjectLastModified)) {
68+
return false;
69+
}
70+
if (!Objects.equals(fileLastModified, that.fileLastModified)) {
7071
return false;
7172
}
72-
return Objects.equals(transferSizeInBytes, that.transferSizeInBytes);
73+
return Objects.equals(totalSizeInBytes, that.totalSizeInBytes);
7374
}
7475

7576
@Override
7677
public int hashCode() {
7778
int result = downloadFileRequest.hashCode();
7879
result = 31 * result + (int) (bytesTransferred ^ (bytesTransferred >>> 32));
79-
result = 31 * result + (lastModified != null ? lastModified.hashCode() : 0);
80-
result = 31 * result + (transferSizeInBytes != null ? transferSizeInBytes.hashCode() : 0);
80+
result = 31 * result + (s3ObjectLastModified != null ? s3ObjectLastModified.hashCode() : 0);
81+
result = 31 * result + (fileLastModified != null ? fileLastModified.hashCode() : 0);
82+
result = 31 * result + (totalSizeInBytes != null ? totalSizeInBytes.hashCode() : 0);
8183
return result;
8284
}
8385

84-
@Override
85-
public String toString() {
86-
return ToString.builder("ResumableFileDownload")
87-
.add("downloadFileRequest", downloadFileRequest)
88-
.add("bytesTransferred", bytesTransferred)
89-
.add("lastModified", lastModified)
90-
.add("transferSizeInBytes", transferSizeInBytes)
91-
.build();
92-
}
93-
9486
public static Builder builder() {
9587
return new DefaultBuilder();
9688
}
@@ -111,19 +103,26 @@ public long bytesTransferred() {
111103
}
112104

113105
/**
114-
* Last modified time on Amazon S3 for this object.
106+
* Last modified time of the S3 object since last pause, or {@link Optional#empty()} if unknown
115107
*/
116-
public Instant lastModified() {
117-
return lastModified;
108+
public Optional<Instant> s3ObjectLastModified() {
109+
return Optional.ofNullable(s3ObjectLastModified);
110+
}
111+
112+
/**
113+
* Last modified time of the file since last pause
114+
*/
115+
public Instant fileLastModified() {
116+
return fileLastModified;
118117
}
119118

120119
/**
121120
* The total size of the transfer in bytes, or {@link Optional#empty()} if unknown
122121
*
123122
* @return the optional total size of the transfer.
124123
*/
125-
public Optional<Long> transferSizeInBytes() {
126-
return Optional.ofNullable(transferSizeInBytes);
124+
public Optional<Long> totalSizeInBytes() {
125+
return Optional.ofNullable(totalSizeInBytes);
127126
}
128127

129128
@Override
@@ -151,34 +150,43 @@ public interface Builder extends CopyableBuilder<Builder, ResumableFileDownload>
151150

152151
/**
153152
* Sets the total transfer size in bytes
154-
* @param transferSizeInBytes the transfer size in bytes
153+
* @param totalSizeInBytes the transfer size in bytes
154+
* @return a reference to this object so that method calls can be chained together.
155+
*/
156+
Builder totalSizeInBytes(Long totalSizeInBytes);
157+
158+
/**
159+
* Sets the last modified time of the object
160+
*
161+
* @param s3ObjectLastModified the last modified time of the object
155162
* @return a reference to this object so that method calls can be chained together.
156163
*/
157-
Builder transferSizeInBytes(Long transferSizeInBytes);
164+
Builder s3ObjectLastModified(Instant s3ObjectLastModified);
158165

159166
/**
160167
* Sets the last modified time of the object
161168
*
162169
* @param lastModified the last modified time of the object
163170
* @return a reference to this object so that method calls can be chained together.
164171
*/
165-
Builder lastModified(Instant lastModified);
172+
Builder fileLastModified(Instant lastModified);
166173
}
167174

168175
private static final class DefaultBuilder implements Builder {
176+
169177
private DownloadFileRequest downloadFileRequest;
170178
private Long bytesTransferred;
171-
private Instant lastModified;
172-
private Long transferSizeInBytes;
179+
private Instant s3ObjectLastModified;
180+
private Long totalSizeInBytes;
181+
private Instant fileLastModified;
173182

174183
private DefaultBuilder() {
175-
176184
}
177185

178186
private DefaultBuilder(ResumableFileDownload persistableFileDownload) {
179187
this.downloadFileRequest = persistableFileDownload.downloadFileRequest;
180188
this.bytesTransferred = persistableFileDownload.bytesTransferred;
181-
this.lastModified = persistableFileDownload.lastModified;
189+
this.s3ObjectLastModified = persistableFileDownload.s3ObjectLastModified;
182190
}
183191

184192
@Override
@@ -194,14 +202,20 @@ public Builder bytesTransferred(Long bytesTransferred) {
194202
}
195203

196204
@Override
197-
public Builder transferSizeInBytes(Long transferSizeInBytes) {
198-
this.transferSizeInBytes = transferSizeInBytes;
205+
public Builder totalSizeInBytes(Long totalSizeInBytes) {
206+
this.totalSizeInBytes = totalSizeInBytes;
207+
return this;
208+
}
209+
210+
@Override
211+
public Builder s3ObjectLastModified(Instant s3ObjectLastModified) {
212+
this.s3ObjectLastModified = s3ObjectLastModified;
199213
return this;
200214
}
201215

202216
@Override
203-
public Builder lastModified(Instant lastModified) {
204-
this.lastModified = lastModified;
217+
public Builder fileLastModified(Instant fileLastModified) {
218+
this.fileLastModified = fileLastModified;
205219
return this;
206220
}
207221

0 commit comments

Comments
 (0)