Skip to content

Commit 576da00

Browse files
author
Brian Chen
authored
Only retry transactions on non-permanent errors (#628)
1 parent 6135c4a commit 576da00

File tree

3 files changed

+66
-15
lines changed

3 files changed

+66
-15
lines changed

firebase-firestore/src/androidTest/java/com/google/firebase/firestore/TransactionTest.java

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ public void testUpdateTransactionally() {
375375
ArrayList<Task<Void>> readTasks = new ArrayList<>();
376376
// A barrier to make sure every transaction reaches the same spot.
377377
TaskCompletionSource<Void> barrier = new TaskCompletionSource<>();
378-
AtomicInteger started = new AtomicInteger(0);
378+
AtomicInteger counter = new AtomicInteger(0);
379379

380380
FirebaseFirestore firestore = testFirestore();
381381
DocumentReference doc = firestore.collection("counters").document();
@@ -388,9 +388,9 @@ public void testUpdateTransactionally() {
388388
transactionTasks.add(
389389
firestore.runTransaction(
390390
transaction -> {
391+
counter.incrementAndGet();
391392
DocumentSnapshot snapshot = transaction.get(doc);
392393
assertNotNull(snapshot);
393-
started.incrementAndGet();
394394
resolveRead.trySetResult(null);
395395
waitFor(barrier.getTask());
396396
transaction.update(doc, map("count", snapshot.getDouble("count") + 1.0));
@@ -400,10 +400,12 @@ public void testUpdateTransactionally() {
400400

401401
// Let all of the transactions fetch the old value and stop once.
402402
waitFor(Tasks.whenAll(readTasks));
403-
assertEquals(3, started.intValue());
404-
// Let all of the transactions continue and wait for them to finish.
403+
// There should be 3 initial transaction runs.
404+
assertEquals(3, counter.get());
405405
barrier.setResult(null);
406406
waitFor(Tasks.whenAll(transactionTasks));
407+
// There should be a maximum of 3 retries: once for the 2nd update, and twice for the 3rd update
408+
assertTrue(counter.get() <= 6);
407409
// Now all transaction should be completed, so check the result.
408410
DocumentSnapshot snapshot = waitFor(doc.get());
409411
assertEquals(8, snapshot.getDouble("count").intValue());
@@ -546,15 +548,17 @@ public void testReadingADocTwiceWithDifferentVersions() {
546548
FirebaseFirestore firestore = testFirestore();
547549
DocumentReference doc = firestore.collection("counters").document();
548550
waitFor(doc.set(map("count", 15.0)));
551+
AtomicInteger counter = new AtomicInteger(0);
549552
Exception e =
550553
waitForException(
551554
firestore.runTransaction(
552555
transaction -> {
556+
counter.incrementAndGet();
553557
// Get the doc once.
554558
DocumentSnapshot snapshot1 = transaction.get(doc);
555-
assertEquals(15, snapshot1.getDouble("count").intValue());
556-
// Do a write outside of the transaction.
557-
waitFor(doc.set(map("count", 1234.0)));
559+
// Do a write outside of the transaction. Because the transaction will retry, set
560+
// the document to a different value each time.
561+
waitFor(doc.set(map("count", 1234.0 + counter.get())));
558562
// Get the doc again in the transaction with the new version.
559563
DocumentSnapshot snapshot2 = transaction.get(doc);
560564
// The get itself will fail, because we already read an earlier version of this
@@ -563,8 +567,6 @@ public void testReadingADocTwiceWithDifferentVersions() {
563567
return null;
564568
}));
565569
assertEquals(Code.ABORTED, ((FirebaseFirestoreException) e).getCode());
566-
DocumentSnapshot snapshot = waitFor(doc.get());
567-
assertEquals(1234, snapshot.getDouble("count").intValue());
568570
}
569571

570572
@Test
@@ -622,6 +624,28 @@ public void testCannotHaveAGetWithoutMutations() {
622624
assertEquals("Every document read in a transaction must also be written.", e.getMessage());
623625
}
624626

627+
@Test
628+
public void testDoesNotRetryOnPermanentError() {
629+
final FirebaseFirestore firestore = testFirestore();
630+
AtomicInteger count = new AtomicInteger(0);
631+
// Make a transaction that should fail with a permanent error
632+
Task<Void> transactionTask =
633+
firestore.runTransaction(
634+
transaction -> {
635+
count.incrementAndGet();
636+
// Get and update a document that doesn't exist so that the transaction fails
637+
DocumentSnapshot doc =
638+
transaction.get(firestore.collection("nonexistent").document());
639+
transaction.update(doc.getReference(), "foo", "bar");
640+
return null;
641+
});
642+
643+
// Let all of the transactions fetch the old value and stop once.
644+
Exception e = waitForException(transactionTask);
645+
assertEquals(Code.INVALID_ARGUMENT, ((FirebaseFirestoreException) e).getCode());
646+
assertEquals(1, count.get());
647+
}
648+
625649
@Test
626650
public void testSuccessWithNoTransactionOperations() {
627651
FirebaseFirestore firestore = testFirestore();

firebase-firestore/src/main/java/com/google/firebase/firestore/core/SyncEngine.java

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import com.google.common.base.Function;
2525
import com.google.firebase.database.collection.ImmutableSortedMap;
2626
import com.google.firebase.database.collection.ImmutableSortedSet;
27+
import com.google.firebase.firestore.FirebaseFirestoreException;
2728
import com.google.firebase.firestore.auth.User;
2829
import com.google.firebase.firestore.local.LocalStore;
2930
import com.google.firebase.firestore.local.LocalViewChanges;
@@ -38,6 +39,7 @@
3839
import com.google.firebase.firestore.model.SnapshotVersion;
3940
import com.google.firebase.firestore.model.mutation.Mutation;
4041
import com.google.firebase.firestore.model.mutation.MutationBatchResult;
42+
import com.google.firebase.firestore.remote.Datastore;
4143
import com.google.firebase.firestore.remote.RemoteEvent;
4244
import com.google.firebase.firestore.remote.RemoteStore;
4345
import com.google.firebase.firestore.remote.TargetChange;
@@ -262,6 +264,9 @@ public <TResult> Task<TResult> transaction(
262264
asyncQueue.getExecutor(),
263265
userTask -> {
264266
if (!userTask.isSuccessful()) {
267+
if (retries > 0 && isRetryableTransactionError(userTask.getException())) {
268+
return transaction(asyncQueue, updateFunction, retries - 1);
269+
}
265270
return userTask;
266271
}
267272
return transaction
@@ -272,11 +277,11 @@ public <TResult> Task<TResult> transaction(
272277
if (commitTask.isSuccessful()) {
273278
return Tasks.forResult(userTask.getResult());
274279
}
275-
// TODO: Only retry on real transaction failures.
276-
if (retries == 0) {
277-
return Tasks.forException(commitTask.getException());
280+
Exception e = commitTask.getException();
281+
if (retries > 0 && isRetryableTransactionError(e)) {
282+
return transaction(asyncQueue, updateFunction, retries - 1);
278283
}
279-
return transaction(asyncQueue, updateFunction, retries - 1);
284+
return Tasks.forException(e);
280285
});
281286
});
282287
}
@@ -589,4 +594,16 @@ private boolean errorIsInteresting(Status error) {
589594

590595
return false;
591596
}
597+
598+
private boolean isRetryableTransactionError(Exception e) {
599+
if (e instanceof FirebaseFirestoreException) {
600+
// In transactions, the backend will fail outdated reads with FAILED_PRECONDITION and
601+
// non-matching document versions with ABORTED. These errors should be retried.
602+
FirebaseFirestoreException.Code code = ((FirebaseFirestoreException) e).getCode();
603+
return code == FirebaseFirestoreException.Code.ABORTED
604+
|| code == FirebaseFirestoreException.Code.FAILED_PRECONDITION
605+
|| !Datastore.isPermanentError(((FirebaseFirestoreException) e).getCode());
606+
}
607+
return false;
608+
}
592609
}

firebase-firestore/src/main/java/com/google/firebase/firestore/remote/Datastore.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,18 @@ public Task<List<MaybeDocument>> lookup(List<DocumentKey> keys) {
189189
* @see #isPermanentWriteError for classifying write errors.
190190
*/
191191
public static boolean isPermanentError(Status status) {
192+
return isPermanentError(FirebaseFirestoreException.Code.fromValue(status.getCode().value()));
193+
}
194+
195+
/**
196+
* Determines whether the given error code represents a permanent error when received in response
197+
* to a non-write operation.
198+
*
199+
* @see #isPermanentWriteError for classifying write errors.
200+
*/
201+
public static boolean isPermanentError(FirebaseFirestoreException.Code code) {
192202
// See go/firestore-client-errors
193-
switch (status.getCode()) {
203+
switch (code) {
194204
case OK:
195205
throw new IllegalArgumentException("Treated status OK as error");
196206
case CANCELLED:
@@ -217,7 +227,7 @@ public static boolean isPermanentError(Status status) {
217227
case DATA_LOSS:
218228
return true;
219229
default:
220-
throw new IllegalArgumentException("Unknown gRPC status code: " + status.getCode());
230+
throw new IllegalArgumentException("Unknown gRPC status code: " + code);
221231
}
222232
}
223233

0 commit comments

Comments
 (0)