Firestore documentation does not seem to specify when or how security rules are evaluated in a transaction and how that interacts with retries and optimistic locking.
My use case is straightforward, I have a lastUpdatedAt
field on my documents and I'm using a transaction to ensure that I grab the latest document and check that the lastUpdatedAt
field so I can resolve any conflicts before issuing an update.
In pseudocode the pattern is this
async function saveDocument(data: MyType, docRef: DocumentReference)
await firebase.firestore().runTransaction(async (transaction) => {
const latestDocRef = await transaction.get(docRef)
const latestDoc = latestDocRef.data()
if(latestDoc.lastUpdatedAt > data.lastUpdatedAt){
data = resolveConflicts(data, latestDoc)
data.lastUpdatedAt = latestDoc.lastUpdatedAt
}
await transaction.update(docRef, data)
}
}
Then in security rules I check to ensure that only updates with the latest or later lastUpdatedAt are allowed
function hasNoLastUpdatedAtConflict(){
return (!("lastUpdatedAt" in resource.data) ||
resource.data.lastUpdatedAt == null ||
request.resource.data.lastUpdatedAt >= resource.data.lastUpdatedAt);
}
//in individualRules
allow update: if hasNoLastUpdatedAtConflict() && someOtherConditionsEtc();
The docs say
In the Mobile/Web SDKs, a transaction keeps track of all the documents you read inside the transaction. The transaction completes its write operations only if none of those documents changed during the transaction's execution. If any document did change, the transaction handler retries the transaction. If the transaction can't get a clean result after a few retries, the transaction fails due to data contention.
However they don't specify how that behavior interacts with security rules. My transaction above is failing somtimes with a security rule violation. It only fails on a live Firestore environment, I haven't been able to make it fail in the emulator. I suspect what's happening is:
I suppose I could implement client side retry in the case a transaction is rejected by a security rule violation but it's very surprising behavior if this is indeed what is happening.
Does anybody have insight into the actual behavior of security rules and Firestore transactions with optimistic locking?
After thorough testing I have confirmed that a firestore transaction using optimistic locking from the iOS/android library can be rejected with a permission-denied error due to a concurrent write happening after a document was read in the transaction.
The following scenario happens:
{id: 1, lastUpdatedAt: 1, data: "foo"}
{id: 1, lastUpdatedAt: 2, data: "foo"}
.update({lastUpdatedAt: 1, data: "bar"})
firestore/permission-denied
because this update violates a security rule of request.resource.data.lastUpdatedAt >= resource.data.lastUpdatedAt
The transaction does not get retried as the docs suggest, even though it performed a read of stale data. This means that users of the firestore libraries cannot rely on transactions being retried if it is possible that a concurrent write could cause the transaction to violate a security rule.
This is surprising and undocumented behavior!