Skip to content

Commit 988ff71

Browse files
authored
Implemented exponential backoff and max retry with resumable uploads (#4087)
1 parent d0045e3 commit 988ff71

File tree

11 files changed

+850
-20
lines changed

11 files changed

+850
-20
lines changed

firebase-storage/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 20.0.3
2+
- [fixed] Fixed an issue that caused infinite number of retries with no exponential
3+
backoff for `uploadChunk`
4+
15
# 19.2.2
26
- [fixed] Fixed an issue that caused the SDK to report incorrect values for
37
"getTotalByteCount()" after a download was paused and resumed.

firebase-storage/src/main/java/com/google/firebase/storage/FirebaseStorage.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public class FirebaseStorage {
5353
@Nullable private final Provider<InternalAppCheckTokenProvider> mAppCheckProvider;
5454
@Nullable private final String mBucketName;
5555
private long sMaxUploadRetry = 10 * DateUtils.MINUTE_IN_MILLIS; // 10 * 60 * 1000
56+
private long sMaxChunkUploadRetry = DateUtils.MINUTE_IN_MILLIS; // 60 * 1000
5657
private long sMaxDownloadRetry = 10 * DateUtils.MINUTE_IN_MILLIS; // 10 * 60 * 1000
5758
private long sMaxQueryRetry = 2 * DateUtils.MINUTE_IN_MILLIS; // 2 * 60 * 1000
5859

@@ -226,6 +227,25 @@ public void setMaxUploadRetryTimeMillis(long maxTransferRetryMillis) {
226227
sMaxUploadRetry = maxTransferRetryMillis;
227228
}
228229

230+
/**
231+
* Returns the maximum time to retry sending a chunk if a failure occurs
232+
*
233+
* @return maximum time in milliseconds. Defaults to 1 minute.
234+
*/
235+
public long getMaxChunkUploadRetry() {
236+
return sMaxChunkUploadRetry;
237+
}
238+
239+
/**
240+
* Sets the maximum time to retry sending a chunk if a failure occurs
241+
*
242+
* @param maxChunkRetryMillis the maximum time in milliseconds. Defaults to 1 minute (60,000
243+
* milliseconds).
244+
*/
245+
public void setMaxChunkUploadRetry(long maxChunkRetryMillis) {
246+
sMaxChunkUploadRetry = maxChunkRetryMillis;
247+
}
248+
229249
/**
230250
* Returns the maximum time to retry operations other than upload and download if a failure
231251
* occurs.

firebase-storage/src/main/java/com/google/firebase/storage/StorageTaskScheduler.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ public class StorageTaskScheduler {
6262
CALLBACK_QUEUE_EXECUTOR.allowCoreThreadTimeOut(true);
6363
}
6464

65+
public static void setCallbackQueueKeepAlive(long keepAliveTime, TimeUnit timeUnit) {
66+
CALLBACK_QUEUE_EXECUTOR.setKeepAliveTime(keepAliveTime, timeUnit);
67+
}
68+
6569
public static StorageTaskScheduler getInstance() {
6670
return sInstance;
6771
}

firebase-storage/src/main/java/com/google/firebase/storage/UploadTask.java

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
package com.google.firebase.storage;
1616

17+
import static com.google.firebase.storage.internal.ExponentialBackoffSender.RND_MAX;
18+
1719
import android.content.ContentResolver;
1820
import android.content.Context;
1921
import android.net.Uri;
@@ -25,10 +27,14 @@
2527
import androidx.annotation.VisibleForTesting;
2628
import com.google.android.gms.common.api.Status;
2729
import com.google.android.gms.common.internal.Preconditions;
30+
import com.google.android.gms.common.util.Clock;
31+
import com.google.android.gms.common.util.DefaultClock;
2832
import com.google.firebase.appcheck.interop.InternalAppCheckTokenProvider;
2933
import com.google.firebase.auth.internal.InternalAuthProvider;
3034
import com.google.firebase.storage.internal.AdaptiveStreamBuffer;
3135
import com.google.firebase.storage.internal.ExponentialBackoffSender;
36+
import com.google.firebase.storage.internal.Sleeper;
37+
import com.google.firebase.storage.internal.SleeperImpl;
3238
import com.google.firebase.storage.internal.Util;
3339
import com.google.firebase.storage.network.NetworkRequest;
3440
import com.google.firebase.storage.network.ResumableUploadByteRequest;
@@ -40,6 +46,7 @@
4046
import java.io.FileNotFoundException;
4147
import java.io.IOException;
4248
import java.io.InputStream;
49+
import java.util.Random;
4350
import java.util.concurrent.atomic.AtomicLong;
4451
import org.json.JSONException;
4552

@@ -72,6 +79,12 @@ public class UploadTask extends StorageTask<UploadTask.TaskSnapshot> {
7279
private volatile Exception mServerException = null;
7380
private volatile int mResultCode = 0;
7481
private volatile String mServerStatus;
82+
private volatile long maxSleepTime;
83+
private static final Random random = new Random();
84+
/*package*/ static Sleeper sleeper = new SleeperImpl();
85+
/*package*/ static Clock clock = DefaultClock.getInstance();
86+
private int sleepTime = 0;
87+
private final int minimumSleepInterval = 1000;
7588

7689
UploadTask(StorageReference targetRef, StorageMetadata metadata, byte[] bytes) {
7790
Preconditions.checkNotNull(targetRef);
@@ -89,6 +102,7 @@ public class UploadTask extends StorageTask<UploadTask.TaskSnapshot> {
89102
new AdaptiveStreamBuffer(new ByteArrayInputStream(bytes), PREFERRED_CHUNK_SIZE);
90103
this.mIsStreamOwned = true;
91104

105+
this.maxSleepTime = storage.getMaxChunkUploadRetry();
92106
mSender =
93107
new ExponentialBackoffSender(
94108
storage.getApp().getApplicationContext(),
@@ -110,6 +124,7 @@ public class UploadTask extends StorageTask<UploadTask.TaskSnapshot> {
110124
this.mAppCheckProvider = storage.getAppCheckProvider();
111125
this.mUri = file;
112126
InputStream inputStream = null;
127+
this.maxSleepTime = storage.getMaxChunkUploadRetry();
113128
mSender =
114129
new ExponentialBackoffSender(
115130
mStorageRef.getApp().getApplicationContext(),
@@ -173,12 +188,13 @@ public class UploadTask extends StorageTask<UploadTask.TaskSnapshot> {
173188
this.mStreamBuffer = new AdaptiveStreamBuffer(stream, PREFERRED_CHUNK_SIZE);
174189
this.mIsStreamOwned = false;
175190
this.mUri = null;
191+
this.maxSleepTime = storage.getMaxChunkUploadRetry();
176192
mSender =
177193
new ExponentialBackoffSender(
178194
mStorageRef.getApp().getApplicationContext(),
179195
mAuthProvider,
180196
mAppCheckProvider,
181-
mStorageRef.getStorage().getMaxUploadRetryTimeMillis());
197+
storage.getMaxUploadRetryTimeMillis());
182198
}
183199

184200
/** @return the target of the upload. */
@@ -321,15 +337,18 @@ private boolean shouldContinue() {
321337
}
322338

323339
boolean inErrorState = mServerException != null || mResultCode < 200 || mResultCode >= 300;
340+
long deadLine = clock.elapsedRealtime() + this.maxSleepTime;
341+
long currentTime = clock.elapsedRealtime() + this.sleepTime;
324342
// we attempt to recover by calling recoverStatus(true)
325-
if (inErrorState && !recoverStatus(true)) {
326-
// we failed to recover.
327-
if (serverStateValid()) {
328-
tryChangeState(INTERNAL_STATE_FAILURE, false);
343+
if (inErrorState) {
344+
if (currentTime > deadLine || !recoverStatus(true)) {
345+
if (serverStateValid()) {
346+
tryChangeState(INTERNAL_STATE_FAILURE, false);
347+
}
348+
return false;
329349
}
330-
return false;
350+
sleepTime = Math.max(sleepTime * 2, minimumSleepInterval);
331351
}
332-
333352
return true;
334353
}
335354

@@ -410,6 +429,36 @@ private boolean recoverStatus(boolean withRetry) {
410429
return true;
411430
}
412431

432+
/**
433+
* Send with a delay that uses sleepTime to delay sending a request to the server. Will reset
434+
* sleepTime upon send success. TODO: Create an exponential backoff helper to consolidate code
435+
* here and in ExponentialBackoffSender.java
436+
*
437+
* @param request to send
438+
* @return whether the delay and send were successful
439+
*/
440+
private boolean delaySend(NetworkRequest request) {
441+
try {
442+
Log.d(TAG, "Waiting " + sleepTime + " milliseconds");
443+
sleeper.sleep(sleepTime + random.nextInt(RND_MAX));
444+
} catch (InterruptedException e) {
445+
Log.w(TAG, "thread interrupted during exponential backoff.");
446+
447+
Thread.currentThread().interrupt();
448+
mServerException = e;
449+
return false;
450+
}
451+
boolean sendRes = send(request);
452+
// We reset the sleepTime if the send was successful. For example,
453+
// uploadChunk(request) // false, then sleepTime becomes 1000
454+
// uploadChunk(request) // false, then sleepTime becomes 2000
455+
// uploadChunk(request) // true, then sleepTime becomes 0 again
456+
if (sendRes) {
457+
sleepTime = 0;
458+
}
459+
return sendRes;
460+
}
461+
413462
private void uploadChunk() {
414463
try {
415464
mStreamBuffer.fill(mCurrentChunkSize);
@@ -425,7 +474,7 @@ private void uploadChunk() {
425474
bytesToUpload,
426475
mStreamBuffer.isFinished());
427476

428-
if (!send(uploadRequest)) {
477+
if (!delaySend(uploadRequest)) {
429478
mCurrentChunkSize = PREFERRED_CHUNK_SIZE;
430479
Log.d(TAG, "Resetting chunk size to " + mCurrentChunkSize);
431480
return;

firebase-storage/src/main/java/com/google/firebase/storage/network/NetworkRequest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ public static Uri getBaseUrl(@Nullable EmulatedServiceSettings emulatorSettings)
106106
return Uri.parse(
107107
"http://" + emulatorSettings.getHost() + ":" + emulatorSettings.getPort() + "/v0");
108108
} else {
109-
return Uri.parse("https://firebasestorage.googleapis.com/v0");
109+
return PROD_BASE_URL;
110110
}
111111
}
112112

firebase-storage/src/test/java/com/google/firebase/storage/TestUtil.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@
3232
public class TestUtil {
3333

3434
static FirebaseApp createApp() {
35+
/**
36+
* Many tests require you to call the callback on the same thread that was initially
37+
* instantiated. With the 5 second keepalive, after 5 seconds, the thread will get killed and
38+
* eventually a new one will be created. Therefore causing many of the tests to fail.
39+
*/
40+
StorageTaskScheduler.setCallbackQueueKeepAlive(90, TimeUnit.SECONDS);
3541
return FirebaseApp.initializeApp(
3642
ApplicationProvider.getApplicationContext(),
3743
new FirebaseOptions.Builder()
@@ -144,10 +150,10 @@ static void await(Task<?> task, int timeout, TimeUnit timeUnit) throws Interrupt
144150
}
145151

146152
/**
147-
* Awaits for a Task for 3 seconds, but flushes the Robolectric scheduler to allow newly added
153+
* Awaits for a Task for 10 seconds, but flushes the Robolectric scheduler to allow newly added
148154
* Tasks to be executed.
149155
*/
150156
static void await(Task<?> task) throws InterruptedException {
151-
await(task, 3, TimeUnit.SECONDS);
157+
await(task, 10, TimeUnit.SECONDS);
152158
}
153159
}

firebase-storage/src/test/java/com/google/firebase/storage/UploadTest.java

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,36 @@ public void smallTextUpload() throws Exception {
100100
TestUtil.verifyTaskStateChanges("smallTextUpload", task.getResult().toString());
101101
}
102102

103+
/**
104+
* This test will replicate uploadChunk() returning 500's and test to make sure the retries are
105+
* limited and using exponential backoff. If the maxretry limit is not checked, then the await
106+
* task will time out.
107+
*
108+
* @throws Exception
109+
*/
110+
@Test
111+
public void fileUploadWith500() throws Exception {
112+
113+
System.out.println("Starting test fileUploadWith500.");
114+
115+
MockConnectionFactory factory = NetworkLayerMock.ensureNetworkMock("fileUploadWith500", true);
116+
117+
String filename = TEST_ASSET_ROOT + "image.jpg";
118+
ClassLoader classLoader = UploadTest.class.getClassLoader();
119+
InputStream imageStream = classLoader.getResourceAsStream(filename);
120+
Uri sourceFile = Uri.parse("file://" + filename);
121+
122+
ContentResolver resolver = ApplicationProvider.getApplicationContext().getContentResolver();
123+
Shadows.shadowOf(resolver).registerInputStream(sourceFile, imageStream);
124+
125+
Task<StringBuilder> task = TestUploadHelper.fileUpload(sourceFile, "image.jpg");
126+
127+
TestUtil.await(task, 3, TimeUnit.MINUTES);
128+
129+
factory.verifyOldMock();
130+
TestUtil.verifyTaskStateChanges("fileUploadWith500", task.getResult().toString());
131+
}
132+
103133
@Test
104134
public void cantUploadToRoot() throws Exception {
105135
System.out.println("Starting test cantUploadToRoot.");
@@ -130,12 +160,13 @@ public void cantUploadToRoot() throws Exception {
130160
});
131161

132162
// TODO(mrschmidt): Lower the timeout
133-
TestUtil.await(task, 300, TimeUnit.SECONDS);
163+
TestUtil.await(task, 1, TimeUnit.MINUTES);
134164

135165
try {
136166
task.getResult();
137167
Assert.fail();
138168
} catch (RuntimeExecutionException e) {
169+
// Note: This test can be flaky due to the fact that the second .getCause() may be null.
139170
Assert.assertEquals(taskException.get().getCause(), e.getCause().getCause());
140171
}
141172

@@ -263,7 +294,7 @@ public void cancelledUpload() throws Exception {
263294
Task<StringBuilder> task = TestUploadHelper.byteUploadCancel();
264295

265296
// TODO(mrschmidt): Lower the timeout
266-
TestUtil.await(task, 500, TimeUnit.SECONDS);
297+
TestUtil.await(task, 1000, TimeUnit.SECONDS);
267298

268299
factory.verifyOldMock();
269300
TestUtil.verifyTaskStateChanges("cancelledUpload", task.getResult().toString());
@@ -466,7 +497,7 @@ public void fileUploadRecovery() throws Exception {
466497

467498
Task<StringBuilder> task = TestUploadHelper.fileUpload(sourceFile, "flubbertest.jpg");
468499

469-
TestUtil.await(task, 5, TimeUnit.SECONDS);
500+
TestUtil.await(task, 4, TimeUnit.MINUTES);
470501

471502
factory.verifyOldMock();
472503
TestUtil.verifyTaskStateChanges("fileUploadRecovery", task.getResult().toString());
@@ -489,7 +520,7 @@ public void fileUploadNoRecovery() throws Exception {
489520

490521
Task<StringBuilder> task = TestUploadHelper.fileUpload(sourceFile, "flubbertest.jpg");
491522

492-
TestUtil.await(task, 5, TimeUnit.SECONDS);
523+
TestUtil.await(task);
493524

494525
factory.verifyOldMock();
495526
TestUtil.verifyTaskStateChanges("fileUploadNoRecovery", task.getResult().toString());

0 commit comments

Comments
 (0)