Implementing Pessimistic Locking in Node.js and Mongoose for Secure Database Transactions
Mastering Pessimistic Locking in Node.js and Mongoose: Safeguarding Data Consistency in Concurrent Environments
Introduction:
Introducing a rock-solid strategy to conquer the chaotic realm of concurrent environments! ๐ช๏ธ๐ Data consistency and integrity are paramount to fend off treacherous race conditions and data corruption. Enter the knight in shining armor - Pessimistic Locking! ๐ก๏ธ A formidable technique that shields shared resources, like database records, from the clutches of concurrency chaos. ๐ค๐
Join us on an exhilarating journey through the realms of Node.js and Mongoose, as we unveil the secrets of implementing Pessimistic Locking! ๐ Hold tight as we navigate the treacherous waters of data concurrency, unraveling the power of Mongoose's MongoDB object modeling prowess. ๐
Are you ready to tame the dragon of inconsistent data and safeguard your applications like never before? ๐๐ฐ Then, saddle up as we embark on this thrilling adventure into the heart of Pessimistic Locking! ๐ช๐ Let's forge an unbreakable bond between your code and the magic of concurrency control. ๐โจ.
Concurrency Issues:
Let's take, for instance, a banking app scenario where a user tries to withdraw 20 naira from two devices simultaneously, and their balance is 20 naira. Without pessimistic locking:
Device 1: Balance 20 naira -> Withdraw 20 naira -> New balance: 0 naira
Device 2: Balance 20 naira -> Withdraw 20 naira -> New balance: 0 naira
With pessimistic locking:
Device 1: Balance 20 naira -> Withdraw 20 naira -> New balance: 0 naira
Device 2: Balance 0 naira -> Cannot withdraw (insufficient balance) -> Transaction fails
Pessimistic locking ensures consistency and prevents negative balances.
Prerequisites:
Before diving into the implementation, make sure you have the following set up:
Node.js and npm installed on your machine.
MongoDB server running locally or on a remote server.
Setting up the Environment:
Let's start by setting up the environment. We'll use Mongoose to interact with our MongoDB database. Install Mongoose using npm:
npm install mongoose
After this create a file (index.js) to run your code.
Below is a code snippet that retrieves the user's balance, deducts a specific amount, and then updates the user's balance.
const mongoose = require('mongoose');
// Enable connection pooling and useUnifiedTopology for the latest version of MongoDB driver.
mongoose.connect('mongodb://localhost/test', { useNewUrlParser: true, useUnifiedTopology: true });
const BankAccountSchema = new mongoose.Schema({
name: String,
balance: Number,
});
const BankAccount = mongoose.model('BankAccount', BankAccountSchema);
const withdraw = async function (id ) {
try {
const account = await BankAccount.findById(id).exec();
if (account.balance < 10) {
throw new Error("Insufficient balance");
}
const data = await BankAccount.findOneAndUpdate(
{ "_id": id},
{ $inc: { balance: -10 } },
{new:true}
);
console.log("new balance is ", data.balance);
} catch (err) {
console.error("Error:", err.message);
}
};
let testConcurrency = async ()=>{
const newBankAccount = await BankAccount.create({balance:10,name:"accountName"})
// simulate Concurrency withdraw
const id = newBankAccount.id
console.log(id)
await Promise.all([
withdraw(id),
withdraw(id),
withdraw(id)
]);
}
testConcurrency()
outcome:
As evident from the results, all instances considered the user account balance as 10. However, the operation resulted in a new balance of -20, which is incorrect. It's important to note that there is a balance checker in the code that should prevent balances less than 10. Unfortunately, it did not get triggered because all instances picked 10 as their balance. If this situation were to occur in a banking sector, the bank could face bankruptcy due to such inconsistencies in balance handling.
Pessimistic lock implementation:
const mongoose = require('mongoose');
// Enable connection pooling and useUnifiedTopology for the latest version of MongoDB driver.
mongoose.connect('mongodb://localhost/test', { useNewUrlParser: true, useUnifiedTopology: true });
const BankAccountSchema = new mongoose.Schema({
name: String,
balance: Number,
});
const BankAccount = mongoose.model('BankAccount', BankAccountSchema);
const withdraw = async function (id ) {
try {
const account = await BankAccount.findById(id).exec();
if (account.balance < 10) {
throw new Error("Insufficient balance");
}
console.log("balance before withdrawal", account.balance)
const data = await BankAccount.findOneAndUpdate(
{ "_id": id, "__v": account.__v },
{ $inc: { balance: -10 ,__v: 1} },
{new:true}
);
if(!data){
console.log("Error occur while withdrawing from your account")
return
}
console.log("new balance is ", data.balance);
} catch (err) {
console.error("Error:", err.message);
}
};
let testConcurrency = async ()=>{
const newBankAccount = await BankAccount.create({balance:10,name:"accountName"})
// simulate Concurrency withdraw
const id = newBankAccount.id
console.log(id)
await Promise.all([
withdraw(id),
withdraw(id),
withdraw(id)
]);
}
testConcurrency()
outcome:
As you can observe, the new balance remains 0 despite all instances choosing their balance as 10. You might be curious about why Mongoose always generates a field called "__v," known as the versionKey, which keeps track of document updates after reading. This key serves to prevent concurrency issues in a collection. When you update a document, you simply increment the field on every update. Furthermore, you have the flexibility to rename the "__v" field to any name you desire by adding {"versionKey: accountBalanceVersionKey"}
to your collection.
Using MongoDB and working with numeric values, it is advisable to utilize the $inc
command instead of resetting the number field when performing updates. Whether you want to update a specific field or use any of the available update functions, employing the $inc
operator is considered a best practice. This approach ensures atomic modifications of numeric fields at the database level, preventing potential race conditions and maintaining data integrity, especially in concurrent environments. Conversely, directly resetting the number field might lead to data inconsistencies when multiple updates occur concurrently, making the $inc
operator a safer and more reliable choice for numeric updates in MongoDB.
If you don't want to return an error to the user on concurrent execution, you can implement a retry mechanism to attempt the withdrawal again. When the retry mechanism detects that the user has insufficient balance, you can throw an error to handle the situation gracefully.
Conclusion:
Pessimistic locking in MongoDB ensures data consistency and prevents concurrency issues. Acquiring locks on resources before updates allows only one process to modify them at a time. In Node.js and Mongoose, implementing pessimistic locking involves using findOneAndUpdate
with the new
option set to true
. The $inc
operator is recommended for numeric updates to maintain data integrity. Designing a bank account schema with name and balance fields exemplifies the implementation. Pessimistic locking is essential for creating robust and secure systems, avoiding data corruption and race conditions. Understanding concurrency control empowers developers to build efficient and scalable applications.