This is Part 2 of a two-part series. You will need to read Part 1 of this article to understand the references used below.
Photo by Ken Suarez / Unsplash

Here are the culprits of the race condition we encountered in Part 1.

{ isFull: false } // filter query
Filter Query
if (room.players.length === 4) {
    room.isFull = true;
    room.save();
}
Update code after Main Query Completion

What happened?

We saw that when the API was hit one-by-one manually, the update after the query was correctly executed. This is because when we hit that API again, the room.save() function had done its job already. Therefore, when the players count reached 4, the query code ran perfectly and set isFull to true before we manually hit the next API call.

But when we used ab to hit 20 API calls concurrently, how the code worked began to matter. After 4 API calls, the player count reached 4. However, before the query was able to check for it and update the isFull to false, other API calls were already occurring. This allowed the remaining queries to run too, and 19 players could come on board before the isFull value was finally set to true. How can we solve this?

The Fix

Step 1: Using "count"

Remember that we created a field called count in the mongoose schema. Now, add the following API to the index.ts file.

app.get("/fix", async (req, res) => {
    const maxPlayers = 5;
    const userId = generateRandomNumber();
    const filter = {
        count: { $lt: maxPlayers },
    };
    const update: UpdateQuery<IRoom> = {
        $addToSet: { players: userId },
        $inc: { count: 1 },
    };
    const options = {
        new: true,
        upsert: true,
        setDefaultsOnInsert: true,
        rawResult: true,
    };
    await Room.findOneAndUpdate(filter, update, options);
}
API Route that Fixes Race Condition

We could not filter player count in mongoose using player: {$size: {$lt: maxPlayers}} in our first attempt. It would have been much easier if it worked that way, but it doesn't. So, as an alternative, we introduced the new parameter count.

When we update players in a room, we increase the count value by 1 as we insert userId into the player array. Doing so, we can filter using count instead of IsFull.

Step 2: Testing

Next, we need to compile, run, and execute the same ab test on the new API.

ab -n 20 -c 20 http://127.0.0.1:3000/fix
Apache-benchmark Code for 20 Requests on Route/Fix

What results did you get? Did it solve the issue for you? For me, the fixed code created 5 rooms with 4 players each, as expected. You can even test it for a large number of concurrent API calls.

So, let's clear the database and execute a test with 10000 calls with 100 concurrencies.

ab -n 10000 -c 100 http://127.0.0.1:3000/fix
Apache-benchmark Code for 10000 Requests on Route/Fix

In this test, the code created 2499 rooms with 4 players each, 1 room with 3 players, and 1 with 1 player. It created 2501 rooms, but we expected 10000/4=2500. That was probably due to a large number of simultaneous API calls and their exaggerated concurrencies. However, it seems like we were able to avoid the race condition.

You can execute a count query in mongo using the code below.

mongo
use swat
db.rooms.countDocuments({players: {$size: 4}})
Using MongoDB and Count Docs with Player Size as Filter

How does it work?

Instead of updating the value of our filtered parameter after the query as we did with isFull value, we updated the filter count while we processed queries one by one. Therefore, no matter the number of API calls and their concurrencies, our filter could update the count for each one.

Thus concludes this two-part series about Race Conditions in MongoDB. We'll meet again in the next one, cheers!