Skip to content

Firestore: Fix FAILED_PRECONDITION when writing to a deleted document in a transaction #10431

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Nov 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a251b91
Firestore/CHANGELOG.md: entry added
dconeybe Nov 1, 2022
a60a13b
Firestore/CHANGELOG.md: update PR number
dconeybe Nov 1, 2022
d6bc3ec
fix the bug
dconeybe Nov 1, 2022
b7539e7
FSTTransactionTests.mm: add skeleton to test deleted doc
dconeybe Nov 1, 2022
f3d303f
Add tests: testRunsTransactionsAfterGettingDeletedDoc and testRunsTra…
dconeybe Nov 1, 2022
b4b50a9
testRetryOnAlreadyExistsError added
dconeybe Nov 1, 2022
12ad6eb
FSTTransactionTests.mm: format code
dconeybe Nov 1, 2022
9ea7c92
FSTTransactionTests.mm: minor indentation cleanup in switch statement
dconeybe Nov 1, 2022
43e7453
FSTTransactionTests.mm: factor out getDocumentRef method
dconeybe Nov 1, 2022
981346d
FSTTransactionTests.mm: delete incorrect comment
dconeybe Nov 1, 2022
80ef623
FSTTransactionTests.mm: fix build error: explicitly assigning value o…
dconeybe Nov 1, 2022
17cb970
FSTTransactionTests.mm: fix callbackNum in testRetryOnAlreadyExistsError
dconeybe Nov 1, 2022
b798c77
Merge remote-tracking branch 'origin/master' into TransactionWriteToD…
dconeybe Nov 1, 2022
4b8b462
nanopb.patch: fix `ValueError: invalid mode: 'rU'` bug in nanopb_gene…
dconeybe Nov 1, 2022
e821c38
nanopb.patch: fix `ValueError: invalid mode: 'rU'` bug in nanopb_gene…
dconeybe Nov 1, 2022
c83a2b9
Merge branch 'NanopPbPython311Fix' into TransactionWriteToDeletedDocFix
dconeybe Nov 1, 2022
9ebf538
Merge remote-tracking branch 'origin/master' into TransactionWriteToD…
dconeybe Nov 2, 2022
97332bb
Merge remote-tracking branch 'origin/master' into TransactionWriteToD…
dconeybe Nov 3, 2022
79e87ec
FSTTransactionTests.mm: use the existing readDocumentForRef method in…
dconeybe Nov 3, 2022
f14b5d2
Merge remote-tracking branch 'origin/master' into TransactionWriteToD…
dconeybe Nov 3, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Firestore/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Unreleased
- [fixed] Fix FAILED_PRECONDITION when writing to a deleted document in a
transaction (#10431).

# 10.0.0
- [feature] Added `Query.count()`, which fetches the number of documents in the
result set without actually downloading the documents (#10246).
Expand Down
158 changes: 151 additions & 7 deletions Firestore/Example/Tests/Integration/FSTTransactionTests.mm
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ - (void)assertSnapshot:(FIRDocumentSnapshot *)snapshot
[transaction getDocument:doc error:&error];
};

typedef NS_ENUM(NSUInteger, FIRFromDocumentType) {
// The operation will be performed on a document that exists.
FIRFromDocumentTypeExisting,
// The operation will be performed on a document that has never existed.
FIRFromDocumentTypeNonExistent,
// The operation will be performed on a document that existed, but was deleted.
FIRFromDocumentTypeDeleted,
};

/**
* Used for testing that all possible combinations of executing transactions result in the desired
* document value or error.
Expand All @@ -117,14 +126,15 @@ - (void)assertSnapshot:(FIRDocumentSnapshot *)snapshot
@interface FSTTransactionTester : NSObject
- (FSTTransactionTester *)withExistingDoc;
- (FSTTransactionTester *)withNonexistentDoc;
- (FSTTransactionTester *)withDeletedDoc;
- (FSTTransactionTester *)runWithStages:(NSArray<TransactionStage> *)stages;
- (void)expectDoc:(NSObject *)expected;
- (void)expectNoDoc;
- (void)expectError:(FIRFirestoreErrorCode)expected;

@property(atomic, strong, readwrite) NSArray<TransactionStage> *stages;
@property(atomic, strong, readwrite) FIRDocumentReference *docRef;
@property(atomic, assign, readwrite) BOOL fromExistingDoc;
@property(atomic, assign, readwrite) FIRFromDocumentType fromDocumentType;
@end

@implementation FSTTransactionTester {
Expand All @@ -137,19 +147,25 @@ - (instancetype)initWithDb:(FIRFirestore *)db testCase:(FSTTransactionTests *)te
if (self) {
_db = db;
_stages = [NSArray array];
_fromDocumentType = FIRFromDocumentTypeNonExistent;
_testCase = testCase;
_testExpectations = [NSMutableArray array];
}
return self;
}

- (FSTTransactionTester *)withExistingDoc {
self.fromExistingDoc = YES;
self.fromDocumentType = FIRFromDocumentTypeExisting;
return self;
}

- (FSTTransactionTester *)withNonexistentDoc {
self.fromExistingDoc = NO;
self.fromDocumentType = FIRFromDocumentTypeNonExistent;
return self;
}

- (FSTTransactionTester *)withDeletedDoc {
self.fromDocumentType = FIRFromDocumentTypeDeleted;
return self;
}

Expand Down Expand Up @@ -195,10 +211,30 @@ - (void)expectError:(FIRFirestoreErrorCode)expected {

- (void)prepareDoc {
self.docRef = [[_db collectionWithPath:@"nonexistent"] documentWithAutoID];
if (_fromExistingDoc) {
NSError *setError = [self writeDocumentRef:self.docRef data:@{@"foo" : @"bar"}];
NSString *message = [NSString stringWithFormat:@"Failed set at %@", [self stageNames]];
[_testCase assertNilError:setError message:message];
switch (_fromDocumentType) {
case FIRFromDocumentTypeExisting: {
NSError *setError = [self writeDocumentRef:self.docRef data:@{@"foo" : @"bar"}];
NSString *message = [NSString stringWithFormat:@"Failed set at %@", [self stageNames]];
[_testCase assertNilError:setError message:message];
break;
}
case FIRFromDocumentTypeNonExistent: {
// Nothing to do; document does not exist.
break;
}
case FIRFromDocumentTypeDeleted: {
{
NSError *setError = [self writeDocumentRef:self.docRef data:@{@"foo" : @"bar"}];
NSString *message = [NSString stringWithFormat:@"Failed set at %@", [self stageNames]];
[_testCase assertNilError:setError message:message];
}
{
NSError *deleteError = [self deleteDocumentRef:self.docRef];
NSString *message = [NSString stringWithFormat:@"Failed delete at %@", [self stageNames]];
[_testCase assertNilError:deleteError message:message];
}
break;
}
}
}

Expand All @@ -215,6 +251,17 @@ - (NSError *)writeDocumentRef:(FIRDocumentReference *)ref
return errorResult;
}

- (NSError *)deleteDocumentRef:(FIRDocumentReference *)ref {
__block NSError *errorResult;
XCTestExpectation *expectation = [_testCase expectationWithDescription:@"prepareDoc:delete"];
[ref deleteDocumentWithCompletion:^(NSError *error) {
errorResult = error;
[expectation fulfill];
}];
[_testCase awaitExpectations];
return errorResult;
}

- (void)runSuccessfulTransaction {
XCTestExpectation *expectation =
[_testCase expectationWithDescription:@"runSuccessfulTransaction"];
Expand Down Expand Up @@ -322,6 +369,32 @@ - (void)testRunsTransactionsAfterGettingNonexistentDoc {
[[[tt withNonexistentDoc] runWithStages:@[ get, set1, set2 ]] expectDoc:@{@"foo" : @"bar2"}];
}

// This test is identical to the test above, except that withNonexistentDoc() is replaced by
// withDeletedDoc(), to guard against regression of
// https://github.com/firebase/firebase-js-sdk/issues/5871, where transactions would incorrectly
// fail with FAILED_PRECONDITION when operations were performed on a deleted document (rather than
// a non-existent document).
- (void)testRunsTransactionsAfterGettingDeletedDoc {
FIRFirestore *firestore = [self firestore];
FSTTransactionTester *tt = [[FSTTransactionTester alloc] initWithDb:firestore testCase:self];

[[[tt withDeletedDoc] runWithStages:@[ get, delete1, delete1 ]] expectNoDoc];
[[[tt withDeletedDoc] runWithStages:@[ get, delete1, update2 ]]
expectError:FIRFirestoreErrorCodeInvalidArgument];
[[[tt withDeletedDoc] runWithStages:@[ get, delete1, set2 ]] expectDoc:@{@"foo" : @"bar2"}];

[[[tt withDeletedDoc] runWithStages:@[ get, update1, delete1 ]]
expectError:FIRFirestoreErrorCodeInvalidArgument];
[[[tt withDeletedDoc] runWithStages:@[ get, update1, update2 ]]
expectError:FIRFirestoreErrorCodeInvalidArgument];
[[[tt withDeletedDoc] runWithStages:@[ get, update1, set2 ]]
expectError:FIRFirestoreErrorCodeInvalidArgument];

[[[tt withDeletedDoc] runWithStages:@[ get, set1, delete1 ]] expectNoDoc];
[[[tt withDeletedDoc] runWithStages:@[ get, set1, update2 ]] expectDoc:@{@"foo" : @"bar2"}];
[[[tt withDeletedDoc] runWithStages:@[ get, set1, set2 ]] expectDoc:@{@"foo" : @"bar2"}];
}

- (void)testRunsTransactionOnExistingDoc {
FIRFirestore *firestore = [self firestore];
FSTTransactionTester *tt = [[FSTTransactionTester alloc] initWithDb:firestore testCase:self];
Expand Down Expand Up @@ -361,6 +434,27 @@ - (void)testRunsTransactionsOnNonexistentDoc {
[[[tt withNonexistentDoc] runWithStages:@[ set1, set2 ]] expectDoc:@{@"foo" : @"bar2"}];
}

- (void)testRunsTransactionsOnDeletedDoc {
FIRFirestore *firestore = [self firestore];
FSTTransactionTester *tt = [[FSTTransactionTester alloc] initWithDb:firestore testCase:self];

[[[tt withDeletedDoc] runWithStages:@[ delete1, delete1 ]] expectNoDoc];
[[[tt withDeletedDoc] runWithStages:@[ delete1, update2 ]]
expectError:FIRFirestoreErrorCodeInvalidArgument];
[[[tt withDeletedDoc] runWithStages:@[ delete1, set2 ]] expectDoc:@{@"foo" : @"bar2"}];

[[[tt withDeletedDoc] runWithStages:@[ update1, delete1 ]]
expectError:FIRFirestoreErrorCodeNotFound];
[[[tt withDeletedDoc] runWithStages:@[ update1, update2 ]]
expectError:FIRFirestoreErrorCodeNotFound];
[[[tt withDeletedDoc] runWithStages:@[ update1, set2 ]]
expectError:FIRFirestoreErrorCodeNotFound];

[[[tt withDeletedDoc] runWithStages:@[ set1, delete1 ]] expectNoDoc];
[[[tt withDeletedDoc] runWithStages:@[ set1, update2 ]] expectDoc:@{@"foo" : @"bar2"}];
[[[tt withDeletedDoc] runWithStages:@[ set1, set2 ]] expectDoc:@{@"foo" : @"bar2"}];
}

- (void)testSetDocumentWithMerge {
FIRFirestore *firestore = [self firestore];
FIRDocumentReference *doc = [[firestore collectionWithPath:@"towns"] documentWithAutoID];
Expand Down Expand Up @@ -653,6 +747,56 @@ - (void)testDoesNotRetryOnPermanentError {
[self awaitExpectations];
}

- (void)testRetryOnAlreadyExistsError {
FIRFirestore *firestore = [self firestore];
FIRDocumentReference *doc1 = [[firestore collectionWithPath:@"counters"] documentWithAutoID];
auto transactionCallbackCallCount = std::make_shared<std::atomic_int>(0);

// Skip backoff delays.
[firestore workerQueue]->SkipDelaysForTimerId(TimerId::RetryTransaction);

XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
[firestore
runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
int callbackNum = ++(*transactionCallbackCallCount);

FIRDocumentSnapshot *snapshot = [transaction getDocument:doc1 error:error];
XCTAssertNil(*error);

if (callbackNum == 1) {
XCTAssertFalse(snapshot.exists);
// Create the document outside of the transaction to cause the commit to fail with
// ALREADY_EXISTS.
dispatch_semaphore_t writeSemaphore = dispatch_semaphore_create(0);
[doc1 setData:@{@"foo1" : @"bar1"}
completion:^(NSError *) {
dispatch_semaphore_signal(writeSemaphore);
}];
// We can block on it, because transactions run on a background queue.
dispatch_semaphore_wait(writeSemaphore, DISPATCH_TIME_FOREVER);
} else if (callbackNum == 2) {
XCTAssertTrue(snapshot.exists);
} else {
XCTFail(@"unexpected callbackNum: %@", @(callbackNum));
}

[transaction setData:@{@"foo2" : @"bar2"} forDocument:doc1];

return nil;
}
completion:^(id, NSError *_Nullable error) {
[expectation fulfill];
XCTAssertNil(error);
}];
[self awaitExpectations];

XCTAssertEqual(transactionCallbackCallCount->load(), 2);
FIRDocumentSnapshot *snapshot = [self readDocumentForRef:doc1];
XCTAssertNotNil(snapshot);
XCTAssertTrue(snapshot.exists);
XCTAssertEqualObjects(snapshot.data, (@{@"foo2" : @"bar2"}));
}

- (void)testMakesDefaultMaxAttempts {
FIRFirestore *firestore = [self firestore];
FIRDocumentReference *doc1 = [[firestore collectionWithPath:@"counters"] documentWithAutoID];
Expand Down
6 changes: 5 additions & 1 deletion Firestore/core/src/core/transaction.cc
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,11 @@ void Transaction::WriteMutations(std::vector<Mutation>&& mutations) {
Precondition Transaction::CreatePrecondition(const DocumentKey& key) {
absl::optional<SnapshotVersion> version = GetVersion(key);
if (written_docs_.count(key) == 0 && version.has_value()) {
return Precondition::UpdateTime(version.value());
if (version.value() == SnapshotVersion::None()) {
return Precondition::Exists(false);
} else {
return Precondition::UpdateTime(version.value());
}
} else {
return Precondition::None();
}
Expand Down
5 changes: 3 additions & 2 deletions Firestore/core/src/core/transaction_runner.cc
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ using util::TimerId;

bool IsRetryableTransactionError(const util::Status& error) {
// In transactions, the backend will fail outdated reads with
// FAILED_PRECONDITION and non-matching document versions with ABORTED. These
// FAILED_PRECONDITION, non-matching document versions with ABORTED, and
// attempts to create already-existing document with ALREADY_EXISTS. These
// errors should be retried.
Error code = error.code();
return code == Error::kErrorAborted ||
return code == Error::kErrorAborted || code == Error::kErrorAlreadyExists ||
code == Error::kErrorFailedPrecondition ||
!remote::Datastore::IsPermanentError(error);
}
Expand Down