Achieving Atomicity in Mongo DB Database operations

oluwatobi2001 - Aug 13 - - Dev Community

Databases play an integral role in the overall web architecture and it's important to store relevant server data to meet the needs of the users. Bearing this in mind, the developer needs to ensure that the database is optimized to work under the best practices available.
In this article, More details regarding Database Atomicity will be discussed and a demo project will be executed to illustrate this, Node JS and MongoDb will serve as our Backend server and database of choice. Here are some of the prerequisites for this tutorial.

  • Knowledge of MongoDB database
  • Knowledge of Node JS.

What is Atomicity?

Atomicity simply implies that any transaction performed on a given database exists as a single indivisible unit. It also means that the entire transaction will get aborted peradventure while executing the transaction, and an error occurs. The transaction however, gets executed if no error shows up while executing it. Contextually, transactions refer to database operations. The changes made in the database preceding the error get removed to its default status before the transaction is executed. These characteristics are one of the many important characteristics of an ideal database. More information regarding these best practices can be found in this article.

Real-life Use cases of Database Atomicity

It helps to prevent the occurrence of database transaction inefficiencies. A single database transaction may contain several operations waiting to be executed, most especially in cases of storing financial transactions involving debiting, crediting and storing transaction details as required. Having the atomic principle in place ensures that the overall changes to the database won't be made pending the successful completion of all functions within the transaction.
Right now we will be demonstrating how to implement atomicity in a MongoDB project with a controller function defined in our Demo banking application.

Implementing Atomicity in Mongo DB: Demo project

To set up the project, create an empty folder and initialize a node project within that folder by runningnpm init on the command line. Then, install relevant packages. Mongoose will serve as our node library to interact with our Mongo DB server. Thereafter, proceed to set up the project and ensure the MongoDB is connected to the Node JS application.

Here is the initial code to send money across to a receiver.

exports.sendMoney = async (req, res) => {
  const { recepientID, amount, pin } = req.body;
  const { emailAddress, id } = req.user;

  try {
    // Verify if the recipient account exists
    const verifyReceiver = await Account.findOne({ acctNo: recepientID });
    if (!verifyReceiver) {
      return res.status(400).json("Wrong account credentials, please recheck details");
    }

    // Verify the sender's account and balance
    const verifyBalance = await Account.findOne({ acctOwner: emailAddress });
    if (!verifyBalance) {
      return res.status(400).json("Sender account not found");
    }

    if (verifyBalance.acctBalance < amount) {
      return res.status(401).json("Insufficient funds, kindly top up your account in order to proceed");
    }

    if (verifyBalance.acctBalance === 0) {
      return res.status(400).json("Sorry, your account balance is too low.");
    }

    if (verifyBalance.acctPin !== pin) {
      return res.status(400).json("Incorrect Pin. Check and try again");
    }

    // Deduct amount from sender's balance and save
    const newBalance = verifyBalance.acctBalance - amount;
    verifyBalance.acctBalance = newBalance;
    await verifyBalance.save();

    // Add amount to receiver's balance and save
    const creditedBalance = verifyReceiver.acctBalance + amount;
    verifyReceiver.acctBalance = creditedBalance;
    await verifyReceiver.save();

    // Create transaction details
    const transactionDets = {
      amount,
      receiver: recepientID,
      sender: emailAddress,
      status: "successful"
    };
    const newTransaction = await Transaction.create(transactionDets);

    // Update sender's account with the transaction
    const updatedAccount = await Account.findByIdAndUpdate(
      verifyBalance._id,
      { $push: { transactions: newTransaction._id } },
      { new: true, useFindAndModify: false }
    );

    console.log(updatedAccount);

    return res.status(200).json("Transaction successful");

  } catch (err) {
    console.error(err);
    return res.status(500).json("Sorry, this transaction cannot be completed currently. Try again later");
  }
};

Enter fullscreen mode Exit fullscreen mode

This code above is a mini representation of how a financial transaction is made when executed in Node JS. Via the use of the promise callbacks(async and await methods), it ensures orderly execution of the functions provided. However, in cases of errors in user input information or in the model schemas itself, the function when executed might not be completely successful and may return an `error 500 or 400` as the case may be.

Notwithstanding the error, some documents would have already been created giving room for the data inconsistency illustrated in the previous paragraph. Now how do we prevent this?

Thankfully, the Mongo DB database was built with the need to achieve the beast database optimization in mind. Here is how it solves the problem.

It easily fulfils database atomicity by the introducing Mongo DB sessions. A session in Mongo DB serves to group multiple executable entities together allowing the multiple functions to be executed as a single transaction. We will now discuss some functions that can be called to maintain the atomicity of a data transaction.

  • startSession
  • startTransaction
  • commitTransaction
  • abortTransaction
  • endSession

startSession: This function is usually called at the beginning of the function which encapsulates all operations within it as a single executable unit.
const session = await mongoose.startSession();

endSession: This is called at the end of the database operations to terminate a session opened.

session.endSession();

startTransaction: This function is invoked to begin execution of the transaction within the mongo DB session.

session.startTransaction();

commitTransaction: This function ensures that the entire database operation gets executed altogether. This function is usually called after all data operations have been called successfully without any error occurring.

session.commitTransaction();

abortTransaction: This function immediately cancels the entire transaction whenever it is executed. To ensure consistency and atomicity, it is best invoked while handling errors that may come up while executing the database operation. Appropriate knowledge and use of error handlers will come in handy here.

session.abortTransaction();

Here is the link to the complete code.

const mongoose = require('mongoose');

exports.sendMoney = async (req, res) => {
const { recepientID, amount, pin } = req.body;
const { emailAddress, id } = req.user;

// Start a session and a transaction
const session = await mongoose.startSession();
session.startTransaction();

try {
// Verify if the recipient account exists
const verifyReceiver = await Account.findOne({ acctNo: recepientID }).session(session);
if (!verifyReceiver) {
await session.abortTransaction();
session.endSession();
return res.status(400).json("Wrong account credentials, please recheck details");
}

// Verify the sender's account and balance
const verifyBalance = await Account.findOne({ acctOwner: emailAddress }).session(session);
if (!verifyBalance) {
  await session.abortTransaction();
  session.endSession();
  return res.status(400).json("Sender account not found");
}

if (verifyBalance.acctBalance &lt; amount) {
  await session.abortTransaction();
  session.endSession();
  return res.status(401).json("Insufficient funds, kindly top up your account in order to proceed");
}

if (verifyBalance.acctBalance === 0) {
  await session.abortTransaction();
  session.endSession();
  return res.status(400).json("Sorry, your account balance is too low.");
}

if (verifyBalance.acctPin !== pin) {
  await session.abortTransaction();
  session.endSession();
  return res.status(400).json("Incorrect Pin. Check and try again");
}

// Deduct amount from the sender's balance and save
const newBalance = verifyBalance.acctBalance - amount;
verifyBalance.acctBalance = newBalance;
await verifyBalance.save({ session });

// Add the amount to the receiver's balance and save
const creditedBalance = verifyReceiver.acctBalance + amount;
verifyReceiver.acctBalance = creditedBalance;
await verifyReceiver.save({ session });

// Create transaction details
const transactionDets = {
  amount,
  receiver: recepientID,
  sender: emailAddress,
  status: "successful"
};
const newTransaction = await Transaction.create([transactionDets], { session });

// Update sender's account with the transaction
const updatedAccount = await Account.findByIdAndUpdate(
  verifyBalance._id,
  { $push: { transactions: newTransaction._id } },
  { new: true, useFindAndModify: false, session }
);

console.log(updatedAccount);

// Commit the transaction
await session.commitTransaction();
session.endSession();

return res.status(200).json("Transaction successful");
Enter fullscreen mode Exit fullscreen mode

} catch (err) {
// If an error occurs, abort the transaction and end the session
await session.abortTransaction();
session.endSession();

console.error(err);
return res.status(500).json("Sorry, this transaction cannot be completed currently. Try again later");
Enter fullscreen mode Exit fullscreen mode

}
};

Enter fullscreen mode Exit fullscreen mode




Additional information

So far, we have come to the end of the tutorial. Achieving database efficiency isn't just limited to Atomicity. Other key fundamentals such as Database indexing, sharding, and isolation will also be important to achieving this.

Feel free to check out my other articles here. Till next time, keep on coding!

. . . . . . . . . . .