Search code examples
firebasegoogle-cloud-firestorefirebase-security

How does firestore's optimistic locking in transactions handle concurrent updates that cause a violation of a security rule?


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:

  1. Transaction starts, sends doc to client
  2. A concurrent write happens which changes lastUpdatedAt
  3. The client does not see the new write so it can't resolve the conflict and it issues the update as-is
  4. The security rule now fails because of the concurrent write when it would have otherwise succeeded
  5. Firestore fails the whole transaction with permission-denied instead of retrying due to dirty data

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?


Solution

  • 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:

    1. Begin transaction
    2. Read a doc such as {id: 1, lastUpdatedAt: 1, data: "foo"}
    3. Before writing to that doc in the transaction a cloud firestore trigger updates the doc to {id: 1, lastUpdatedAt: 2, data: "foo"}
    4. Update doc in transaction .update({lastUpdatedAt: 1, data: "bar"})
    5. Transaction throws exception 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!