Skip to content

Commit a0fcca1

Browse files
authored
Fix FAILED_PRECONDITION when writing to a deleted document in a transaction (#4257)
1 parent ffca832 commit a0fcca1

File tree

4 files changed

+110
-10
lines changed

4 files changed

+110
-10
lines changed

firebase-firestore/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
# Unreleased
2+
- [fixed] Fix FAILED_PRECONDITION when writing to a deleted document in a transaction.
3+
(#5871).
4+
25
- [fixed] Fixed Firestore failing to raise initial snapshot from empty local cache result.
36

47
# 24.4.0

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

Lines changed: 98 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,20 +69,29 @@ void runStage(Transaction transaction, DocumentReference docRef)
6969

7070
private static TransactionStage get = Transaction::get;
7171

72+
private enum FromDocumentType {
73+
// The operation will be performed on a document that exists.
74+
EXISTING,
75+
// The operation will be performed on a document that has never existed.
76+
NON_EXISTENT,
77+
// The operation will be performed on a document that existed, but was deleted.
78+
DELETED,
79+
}
80+
7281
/**
7382
* Used for testing that all possible combinations of executing transactions result in the desired
7483
* document value or error.
7584
*
76-
* <p>`run()`, `withExistingDoc()`, and `withNonexistentDoc()` don't actually do anything except
77-
* assign variables into the TransactionTester.
85+
* <p>`run()`, `withExistingDoc()`, `withNonexistentDoc()` and `withDeletedDoc()` don't actually
86+
* do anything except assign variables into the TransactionTester.
7887
*
7988
* <p>`expectDoc()`, `expectNoDoc()`, and `expectError()` will trigger the transaction to run and
8089
* assert that the end result matches the input.
8190
*/
8291
private static class TransactionTester {
8392
private FirebaseFirestore db;
8493
private DocumentReference docRef;
85-
private boolean fromExistingDoc = false;
94+
private FromDocumentType fromDocumentType = FromDocumentType.NON_EXISTENT;
8695
private List<TransactionStage> stages = new ArrayList<>();
8796

8897
TransactionTester(FirebaseFirestore inputDb) {
@@ -91,13 +100,19 @@ private static class TransactionTester {
91100

92101
@CanIgnoreReturnValue
93102
public TransactionTester withExistingDoc() {
94-
fromExistingDoc = true;
103+
fromDocumentType = FromDocumentType.EXISTING;
95104
return this;
96105
}
97106

98107
@CanIgnoreReturnValue
99108
public TransactionTester withNonexistentDoc() {
100-
fromExistingDoc = false;
109+
fromDocumentType = FromDocumentType.NON_EXISTENT;
110+
return this;
111+
}
112+
113+
@CanIgnoreReturnValue
114+
public TransactionTester withDeletedDoc() {
115+
fromDocumentType = FromDocumentType.DELETED;
101116
return this;
102117
}
103118

@@ -160,8 +175,20 @@ private void expectError(Code expected) {
160175

161176
private void prepareDoc() {
162177
docRef = db.collection("tester-docref").document();
163-
if (fromExistingDoc) {
164-
waitFor(docRef.set(map("foo", "bar0")));
178+
179+
switch (fromDocumentType) {
180+
case EXISTING:
181+
waitFor(docRef.set(map("foo", "bar0")));
182+
break;
183+
case NON_EXISTENT:
184+
// Nothing to do; document does not exist.
185+
break;
186+
case DELETED:
187+
waitFor(docRef.set(map("foo", "bar0")));
188+
waitFor(docRef.delete());
189+
break;
190+
default:
191+
throw new RuntimeException("invalid fromDocumentType: " + fromDocumentType);
165192
}
166193
}
167194

@@ -241,6 +268,29 @@ public void testRunsTransactionsAfterGettingNonexistentDoc() {
241268
tt.withNonexistentDoc().run(get, set1, set2).expectDoc(map("foo", "bar2"));
242269
}
243270

271+
// This test is identical to the test above, except that withNonexistentDoc()
272+
// is replaced by withDeletedDoc(), to guard against regression of
273+
// https://github.com/firebase/firebase-js-sdk/issues/5871, where transactions
274+
// would incorrectly fail with FAILED_PRECONDITION when operations were
275+
// performed on a deleted document (rather than a non-existent document).
276+
@Test
277+
public void testRunsTransactionsAfterGettingDeletedDoc() {
278+
FirebaseFirestore firestore = testFirestore();
279+
TransactionTester tt = new TransactionTester(firestore);
280+
281+
tt.withDeletedDoc().run(get, delete1, delete1).expectNoDoc();
282+
tt.withDeletedDoc().run(get, delete1, update2).expectError(Code.INVALID_ARGUMENT);
283+
tt.withDeletedDoc().run(get, delete1, set2).expectDoc(map("foo", "bar2"));
284+
285+
tt.withDeletedDoc().run(get, update1, delete1).expectError(Code.INVALID_ARGUMENT);
286+
tt.withDeletedDoc().run(get, update1, update2).expectError(Code.INVALID_ARGUMENT);
287+
tt.withDeletedDoc().run(get, update1, set2).expectError(Code.INVALID_ARGUMENT);
288+
289+
tt.withDeletedDoc().run(get, set1, delete1).expectNoDoc();
290+
tt.withDeletedDoc().run(get, set1, update2).expectDoc(map("foo", "bar2"));
291+
tt.withDeletedDoc().run(get, set1, set2).expectDoc(map("foo", "bar2"));
292+
}
293+
244294
@Test
245295
public void testRunsTransactionsOnExistingDoc() {
246296
FirebaseFirestore firestore = testFirestore();
@@ -277,6 +327,24 @@ public void testRunsTransactionsOnNonexistentDoc() {
277327
tt.withNonexistentDoc().run(set1, set2).expectDoc(map("foo", "bar2"));
278328
}
279329

330+
@Test
331+
public void testRunsTransactionsOnDeletedDoc() {
332+
FirebaseFirestore firestore = testFirestore();
333+
TransactionTester tt = new TransactionTester(firestore);
334+
335+
tt.withDeletedDoc().run(delete1, delete1).expectNoDoc();
336+
tt.withDeletedDoc().run(delete1, update2).expectError(Code.INVALID_ARGUMENT);
337+
tt.withDeletedDoc().run(delete1, set2).expectDoc(map("foo", "bar2"));
338+
339+
tt.withDeletedDoc().run(update1, delete1).expectError(Code.NOT_FOUND);
340+
tt.withDeletedDoc().run(update1, update2).expectError(Code.NOT_FOUND);
341+
tt.withDeletedDoc().run(update1, set2).expectError(Code.NOT_FOUND);
342+
343+
tt.withDeletedDoc().run(set1, delete1).expectNoDoc();
344+
tt.withDeletedDoc().run(set1, update2).expectDoc(map("foo", "bar2"));
345+
tt.withDeletedDoc().run(set1, set2).expectDoc(map("foo", "bar2"));
346+
}
347+
280348
@Test
281349
public void testSetDocumentWithMerge() {
282350
FirebaseFirestore firestore = testFirestore();
@@ -637,6 +705,29 @@ public void testDoesNotRetryOnPermanentError() {
637705
assertEquals(1, count.get());
638706
}
639707

708+
@Test
709+
public void testRetryOnAlreadyExistsError() {
710+
final FirebaseFirestore firestore = testFirestore();
711+
DocumentReference doc = firestore.collection("foo").document();
712+
AtomicInteger transactionCallbackCount = new AtomicInteger(0);
713+
waitFor(
714+
firestore.runTransaction(
715+
transaction -> {
716+
int currentCount = transactionCallbackCount.incrementAndGet();
717+
transaction.get(doc);
718+
// Do a write outside of the transaction.
719+
if (currentCount == 1) waitFor(doc.set(map("foo1", "bar1")));
720+
// Now try to set the doc within the transaction. This should fail once
721+
// with ALREADY_EXISTS error.
722+
transaction.set(doc, map("foo2", "bar2"));
723+
return null;
724+
}));
725+
DocumentSnapshot snapshot = waitFor(doc.get());
726+
assertEquals(2, transactionCallbackCount.get());
727+
assertTrue(snapshot.exists());
728+
assertEquals(map("foo2", "bar2"), snapshot.getData());
729+
}
730+
640731
@Test
641732
public void testMakesDefaultMaxAttempts() {
642733
FirebaseFirestore firestore = testFirestore();

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,11 @@ private void recordVersion(MutableDocument doc) throws FirebaseFirestoreExceptio
199199
private Precondition precondition(DocumentKey key) {
200200
@Nullable SnapshotVersion version = readVersions.get(key);
201201
if (!writtenDocs.contains(key) && version != null) {
202-
return Precondition.updateTime(version);
202+
if (version.equals(SnapshotVersion.NONE)) {
203+
return Precondition.exists(false);
204+
} else {
205+
return Precondition.updateTime(version);
206+
}
203207
} else {
204208
return Precondition.NONE;
205209
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,12 @@ private void handleTransactionError(Task task) {
9595

9696
private static boolean isRetryableTransactionError(Exception e) {
9797
if (e instanceof FirebaseFirestoreException) {
98-
// In transactions, the backend will fail outdated reads with FAILED_PRECONDITION and
99-
// non-matching document versions with ABORTED. These errors should be retried.
98+
// In transactions, the backend will fail outdated reads with FAILED_PRECONDITION,
99+
// non-matching document versions with ABORTED, and attempts to create already exists with
100+
// ALREADY_EXISTS. These errors should be retried.
100101
FirebaseFirestoreException.Code code = ((FirebaseFirestoreException) e).getCode();
101102
return code == FirebaseFirestoreException.Code.ABORTED
103+
|| code == FirebaseFirestoreException.Code.ALREADY_EXISTS
102104
|| code == FirebaseFirestoreException.Code.FAILED_PRECONDITION
103105
|| !Datastore.isPermanentError(((FirebaseFirestoreException) e).getCode());
104106
}

0 commit comments

Comments
 (0)