Johnny Oshika
Johnny Oshika

Reputation: 57482

Firebase Cloud Function: Firestore transaction doesn't rerun on concurrent edit

According to Firebase's docs on Firestore transactions:

In the case of a concurrent edit, Cloud Firestore runs the entire transaction again.

Is this also true when running a transaction inside Firebase Cloud Functions using the admin SDK? Based on my tests, it doesn't seem to do so.

I tested this with this fictitious example. If all of the cars have been deleted, I'm deleting the carsSummary/index document. To ensure that there are no race conditions, I'm wrapping this in a transaction.

  try {
    await db.runTransaction(async transaction => {
      const results = await transaction.get(db.collection(`cars`));
      
      console.log('Running transaction');
      await sleep(10000); // during these 10 seconds, a new car gets added

      if (results.size === 0)
        transaction.delete(db.doc(`carsSummary/index`));
    });
  } catch (error) {
    console.error(error);
  }

With the test above, the delete operation correctly doesn't execute if a car is added during the sleep(10000), thereby invalidating the results query. However, the transaction doesn't re-run (i.e. Running transaction console.log only gets called once). Is this the correct behavior? Is the Firebase documentation wrong?

Upvotes: 0

Views: 844

Answers (2)

Johnny Oshika
Johnny Oshika

Reputation: 57482

To elaborate on my other answer, I did further testing and observed an interesting behavior. It turns out that the Admin SDK transactions do re-run on a concurrent edit when trying to set documents. Take this example:

const writeOnce = async path => {
  await admin.firestore().runTransaction(async transaction => {
    const ref = admin.firestore().doc(path);

    const snap = await transaction.get(ref);
    if (snap.exists) return;

    await sleep(10000);

    await transaction.set(ref, {
      createdAt: admin.firestore.FieldValue.serverTimestamp(),
    });
  });
};

If 2 processes execute that function concurrently, the first one will successfully set the document. The second one's transaction will be invalidated so the set command will fail silently. It then re-runs the transaction and when it does, it will find the document written by the first process and will exit early.

Another interesting experiment is executing the function above in the first process, then concurrently executing this one (without a transaction) in the second process:

const writeOnceNoTransaction = async path => {
  const ref = admin.firestore().doc(path);

  const snap = await ref.get();
  if (snap.exists) return;

  await ref.set({
    createdAt: admin.firestore.FieldValue.serverTimestamp(),
  });
};

The transaction in the first process takes a pessimistic write lock on the document, so the second process will pause before committing the set. Once the transaction in the first process completes, the second process will continue and overwrite the changes in the first process.

These tests were done using Firestore in the cloud and NOT the emulator.

Upvotes: 1

Johnny Oshika
Johnny Oshika

Reputation: 57482

The answer lies in the Firestore documentation here: https://googleapis.dev/nodejs/firestore/latest/Transaction.html#get

get(refOrQuery) → {Promise} Retrieve a document or a query result from the database. Holds a pessimistic lock on all returned documents.

The database holds a pessimistic lock on the returned documents, so it won't try to rerun the transaction on a concurrent edit.

Note that the behavior is different if you're using the emulator (at least in Firebase CLI v9.16.0), and the transaction will rerun on a concurrent edit even when using the Admin SDK: https://github.com/firebase/firebase-tools/issues/3928

Upvotes: 3

Related Questions