Atomicity and Transactions

Riddhesh Ganatra
4 min readFeb 9, 2023

Data corruption in case of system crashes, power failures, and other errors is a nightmare in real production systems with heavy traffic.

We will see how Atomicity and Transactions will solve this problem with the example below.

We will make a simple ticket booking system with a high chance of race conditions.

Requirements: Every day we have 2 tickets available and more than 2 users try to book tickets at the same time.

Version 1:

app.post('/v1/book-ticket', async (req, res) => {
try {
// count number of tickets already booked for that date
let ticketCount = await client.db("irctc-demo")
.collection("ticketsV1")
.countDocuments({ date: req.body.date })


if (ticketCount > 2) {
return res.json({ message: `already full` })
}


// adding this line makes easy to test parallel requests
await wait(10000)

// book ticket for user
const result = await client.db("irctc-demo")
.collection("ticketsV1")
.insertOne({ email: req.body.email, date: req.body.date });

res.json({ id: result.insertedId })
} catch (error) {
console.log(error)
res.json({ message: error.message })
}
})

If we don't have this 10-second wait time, the difference between the count query in DB and the insert query in DB will be a few milliseconds and for testing, we will have to hit the API within milliseconds for which we have to use load testing tools like JMeter, artillery, etc

So a 10-second wait time is added in the above code to make parallel request testing easy.

Result:

When different users hit version 1 API within 10 seconds for the same date, more than 2 tickets get created in this case.

Version 2:

app.post('/v2/book-ticket', async (req, res) => {
try {

// decrement number of available tickets for a particular date with condition
let ticketCount = await client.db("irctc-demo")
.collection("ticketCountV2")
.updateOne(
{ date: req.body.date, count: { $lt: 3 } },
{
$inc: { count: 1 },
},
)

if (ticketCount.modifiedCount == 0) {
console.log(`request error`);
return res.json({ message: 'false' })
}

// fake code to simulate random power failure
let randomNumber = Math.random() * 2;
if (randomNumber < 1) {
throw new Error(`power failure`)
}

const result = await client.db("irctc-demo")
.collection("ticketsV2")
.insertOne({ email: req.body.email, date: req.body.date });
return res.json({ id: result.insertedId, message: 'success' })


} catch (error) {
res.json({ message: error.message })
}
})

In this method, we save the count of tickets for all dates in different collection ticketCountV2 and use the atomic operator $inc with the condition of $lt. This code will only book max 2 tickets for a particular date no matter how many users are trying to book simultaneously.

query 1: decrement available tickets for a day in ticketCountV2 collection

query 2: creating ticket in ticketsV2 collection

The problem with this is when our server restarts for some random reason like a power failure between 2 different queries, the system will allow the booking of fewer than 2 tickets which is also a problem.

Version 3:

app.post('/v3/book-ticket', async (req, res) => {
const session = client.startSession();
try {
const transactionOptions = {
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' },
readPreference: 'primary'
};

session.startTransaction(transactionOptions);

// decrement number of available tickets for a particular date with condition
let ticketCount = await client.db("irctc-demo").collection("ticketCountV3")
.updateOne(
{ date: req.body.date, count: { $lt: 3 } },
{
$inc: { count: 1 },
}, {
session
}
)

if (ticketCount.modifiedCount == 0) {
return res.json({ message: 'false' })
}

// fake code to simulate random power failure
let randomNumber = Math.random() * 2;
if (randomNumber < 1) {
throw new Error(`some error`)
}

const result = await client.db("irctc-demo")
.collection("ticketsV3")
.insertOne({ email: req.body.email, date: req.body.date }, { session });

await session.commitTransaction();

return res.json({ id: result.insertedId, message: 'success' })

} catch (error) {
console.log(error)
await session.abortTransaction();
res.json({ message: error.message })
} finally {
await session.endSession();
}
})

Database transactions to rescue: If there is a power failure after the first database query, the database will roll back changes automatically since the transaction will not be committed.

Happy path:

  • step 1: decrement available tickets for a day in the ticketCountV2 collection
  • step 2: Create a ticket in the ticketsV2 collection
  • step 3: commit the transaction

In case of power failure between 2 queries:

  • step 1: decrement available tickets for a day in the ticketCountV2 collection
  • step 2: Create a ticket in the ticketsV2 collection
  • power failure
  • DB will automatically rollback since there is no commit for the transaction

Source Code: https://github.com/riddheshganatra/Atomicity-and-Transactions

Share this with anybody you think would benefit from this. Have any suggestions or questions? Feel free to message me on LinkedIn.

We at Code B would be happy to help if you need help.

--

--

Riddhesh Ganatra

Software Architect, Full Stack Web developer, MEAN/MERN stack, Microservices, etc