Photo by Braden Collum / Unsplash

Introduction

You have probably heard of mysql, postgresql, or sqlite. But have you heard of MongoDB? It is an open-source, non-relational database management system (DBMS) that uses flexible documents instead of tables and rows to process and store various forms of data. In this blog post, we will be looking at a situation called a Race Condition in MongoDB.

Now, what is a Race Condition? "A race condition arises in software when a computer program, to operate properly, depends on the sequence or timing of the program's processes or threads (Wikipedia)." Basically, a race condition is an event that locks software from working as intended due to a corruption of a saved state or memory.

Creating a Race Condition in MongoDB

Race conditions can occur in MongoDB because of bad queries. We will dig into that with an elaborate example. Let's say we are creating a game-server project that does the following.

1. Connects to MongoDB
2. Provides an API for players to join a room with a maximum capacity of 4 players

Step 1: Setting up the Project

I am using nodejs for this test. You can install nodejs and npm on your OS using any method you like. Set up a basic nodejs project with npm init and execute the code below under the projects directory.

npm install typescript mongoose express @types/node @types/express
Script to Install Basic npm Packages

Create a typescript config file tsconfig.json as,

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es6",
    "noImplicitAny": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "sourceMap": true,
    "outDir": "dist",
    "baseUrl": ".",
    "paths": {
      "*": [
        "node_modules/*"
      ]
    }
  },
  "include": [
    "src/**/*"
  ]
}
Json File Containing Typescript Configuration

Then, create a directory called src. Under it, create a mongoose schema called room and create a typescript file room.schema.ts. It should contain a schema with players in it. To update the room's value to full if it reaches max players, we create another field named isFull.

We can also create another field named count which we will use later.

import { Document, model, Schema } from "mongoose";

export interface IRoom extends Document {
	players: string[];
	isFull: boolean;
	count: number;
}

const RoomSchema = new Schema<IRoom>({
	players: { type: [String], default: [] },
	isFull: { type: Boolean, default: false },
	count: { type: Number, default: 1 },
});

export const Room = model<IRoom>("Room", RoomSchema);
Room Schema

Step 2: Coding the API

Next, create an index.ts file using express. Then, establish a connection to mongoose with an API that searches for a room that is not full. If found, it should add a player to it; otherwise, it should create a new room to host that new player. Think of it as a room of Ludo, where a maximum of 4 players can join, and new rooms need to be created if more players want to join.

import express from "express";
import { connect as MongooseConnect } from "mongoose";
import { IRoom, Room } from "./room.schema";

const app: express.Application = express();
const port: number = 3000;

MongooseConnect("mongodb://127.0.0.1", {
	dbName: "swat",
});

function generateRandomNumber() {
	return Math.random().toString().slice(2, 18);
}

app.get("/", async (req, res) => {
	res.send("api works fine");
});

app.get("/race", async (req, res) => {
	const userId = generateRandomNumber();
	const filter = { isFull: false };
	const update = { $addToSet: { players: userId } };
	const options = {
		new: true,
		upsert: true,
		setDefaultsOnInsert: true,
	};
	await Room.findOneAndUpdate(filter, update, options).then((room: IRoom) => {
		if (room) {
			if (room.players.length === 4) {
				room.isFull = true;
				room.save();
			}
		}
	});
	res.send();
});

app.listen(port, () => {
	console.log("app running on http://localhost:" + port);
});
Index File that Contains Mongoose Connection and API Route Processes

Notice that I created an API on the route /race. The code looks like it should work. Let's run it.

tsc -p tsconfig.json
node dist/index.js
Script that Compiles and Runs the Code

The first command compiles typescript and puts javascript code under the dist folder as described in tsconfig.json . The second command runs our compiled javascript code from the dist folder.

Step 3: Testing

If we go to the URL, http://localhost:3000/race it will hit the /race section API code. First, it will create a random string for "userId", then search for a room that is not full. If all created rooms are full, it will create a new room. If we go to that URL again, a room should already exist with a player. So, we will find that room and be added as another player.

Repeat this a total of 8 times. If you query your MongoDB database, you will find 2 rooms with 4 players each and isFull set to true for both rooms.

So, everything works, right? Where is the race condition I was blabbering about?
The thing is, this is not a real case scenario. Let's suppose 20 players concurrently hit your API; how will it work then? Let's try clearing the MongoDB database and then executing this code.

ab -n 20 -c 20 http://127.0.0.1:3000/race
Apache-benchmark Code for 20 Calls on Route/Race

This script uses the apache-benchmark tool to send 20 requests through 20 concurrent processes to simulate 20 players hitting the API concurrently.

Result Data in MongoDB After Script Test

It looks like my code put the first 19 players in one room and then 1 player in another room. Why did this happen? How to solve it? We will be discussing this next time, so tune in to find out.

This is Part 1 of understanding and fixing Race Conditions in MongoDB. Read Part 2 here.