Firestore – Deleting lot of documents without concurrency issues

I have developed a game using Firestore, but I have noticed some problems in my scheduled cloud function which deletes rooms that were created 5 minutes ago and are not full OR are finished.

So for that, I am running the following code.

async function deleteExpiredRooms() {
  // Delete all rooms that are expired and not full
  deleteExpiredSingleRooms();

  // Also, delete all rooms that are finished
  deleteFinishedRooms();
}

Deleting finished rooms seems to work correctly with this:

async function deleteFinishedRooms() {
  const query = firestore
    .collection("gameRooms")
    .where("finished", "==", true);

  const querySnapshot = await query.get();

  console.log(`Deleting ${querySnapshot.size} expired rooms`);

  // Delete the matched documents
  querySnapshot.forEach((doc) => {
    doc.ref.delete();
  });
}

But I am experiencing concurrency problems when deleting rooms created 5 minutes ago that are not full (one room is full when 2 users are in the room, so that the game can start).

async function deleteExpiredSingleRooms() {
  const currentDate = new Date();

  // Calculate the target date
  const targetDate = // ... 5 minutes ago

  const query = firestore
    .collection("gameRooms")
    .where("full", "==", false)
    .where("createdAt", "<=", targetDate);

  const querySnapshot = await query.get();

  console.log(`Deleting ${querySnapshot.size} expired rooms`);

  // Delete the matched documents
  querySnapshot.forEach((doc) => {
    doc.ref.delete();
  });
}

Because during the deletion of a room, a user can enter it before it is completely deleted.

Any ideas?

Note: For searching rooms I am using a transaction

firestore.runTransaction(async (transaction) => {
  ...

  const query = firestore
    .collection("gameRooms")
    .where("full", "==", false);

  return transaction.get(query.limit(1));
});

Answer

You can use BatchWrites:

const query = firestore
    .collection("gameRooms")
    .where("full", "==", false)
    .where("createdAt", "<=", targetDate);

const querySnapshot = await query.get();

console.log(`Deleting ${querySnapshot.size} expired rooms`);

const batch = db.batch();

querySnapshot.forEach((doc) => {
  batch.delete(doc.ref);
});

// Commit the batch
batch.commit().then(() => {
    // ...
});

A batched write can contain up to 500 operations. Each operation in the batch counts separately towards your Cloud Firestore usage.

This should delete all the rooms matching that criteria at once. Using a loop to delete them might take a while as it’ll happen one by one.

If you are concerned about the 500 docs limit in a batch write, consider using Promise.all as shown:

const deleteOps = []
querySnapshot.forEach((doc) => {
  deleteOps.push(doc.ref.delete());
});

await Promise.all(deleteOps)

Now to prevent users from joining the rooms that are being delete, it’s kind of harder in a Cloud Function to do so as all the instances run independently and there may be a race condition.

To avoid that, you many have to manually check if the room that user is trying to join is older than 5 minutes and has less number of players. This is just a check to make sure the room is being deleted or will be deleted in no time.

function joinRoom() {
 // isOlderThanMin()
 // hasLessNumOfPlayers()
 // return 'Room suspended'
}

Because the logic to filter which rooms should be deleted is same, this should not be an issue.