Atomicity and Transactions
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.