Transactions in MongoDB with ACID Properties

·

6 min read

Transactions in MongoDB with ACID Properties

Introduction

Transactions are set of operations that are executed as a single atomic unit, It means a set of operation is called transaction if all the operation are executed successfully and if any one of the operation fails then the entire transaction is disrupted, and none of the operation end up being executed.

Single-document updates have always been atomic in MongoDB, without the need for transactions.

Transactions are kind of like saying "Go Big! or Go Home!" in the database world.

Lets take a better analogy, Suppose you want to send 10$ from your account to your friend's account, so the operations are -

  1. Deduct 10$ From your Account

  2. Add 10$ to your friend's account

Suppose after the first operation is executed, 10$ deducted from you account, the server goes down and rest of the operation is not executed. This can cause inconsistency in the database. What should have happened was, whenever an operation fails all the changes must be rolled back. This is where Transactions Come into the picture.

Transactions provide data consistency in the database by ensuring either all of the operation within transaction are executed or none of them. You cannot partially execute operations in a transaction

ACID Properties

Transactions are designed to provide ACID properties

  1. Atomicity- Atomicity guarantees a transaction is treated as a single indivisible unit of work. Either all of the operation in the transaction is executed successfully and committed or none of them are. If any part of the transaction fails then all the changed made by operation of transaction are rolled back and reverted to the original state. There is no partial execution of transaction, basically commit All or nothing.

  2. Consistency- Consistency ensures that a transaction brings database from one valid state to another. If any one the operation violates a constraint the transaction fails and the changes are rolled back. The constraint could be referential integrity (ensuring correct reference Id field) or uniqueness constraint (All record are unique with certain context) .

    Suppose there can be a constraint that u cant have balance in negative, if you overdraw money transaction fails because the constraint is violated. Therefore consistency prevents invalid data from messing up the database. Therefore a valid transaction can never violate a constraint/trigger/rule.

  3. Isolation- isolation ensures that, all the transaction take place in isolation, independent of other transaction. It prevents interference of concurrent transactions, preserving data integrity and preventing side-effects.

    So basically unless a transaction commits, it changes are not available outside transaction.

  4. Durability- durability guarantees that the changes committed by a transaction are permanently stored and will survive subsequent failures. Committed changes are made durable by storing the changes in disk or persistent memory storage. This data is not lost and can be recovered after system crash/failure.

Creating a transaction

So let's start our transaction by setting up our database called bank with a collection called accounts in mongo shell. Now we can start our transaction.

  1. We create a session, to begin a transaction following the ACID principles. It returns a session object.

     const session= db.getMongo().startSession();
    
  2. Call the startTransaction() method on session object to begin the transaction.

     session.startTransaction();
    
  3. Now we need to run operation with respect to the session, so it is better to represent our collection in variable. This newly created variable will work the as db.accounts

     const accounts= session.getDatabase('bank').accounts;
    
  4. Now lets run our operations, Each operation is executed within the context of the session,

    Since this transaction session has still not been committed, we will not see it return the same result in a MongoDB shell existing outside of the session.

     accounts.updateOne(
     {_id:1},
     { $inc:{ balance: -100}}
     )
    
     accounts.updateOne(
     {_id:2},
     { $inc:{ balance: 100}}
     )
    
  5. In case a condition fails and If we want to discard the changes the transaction is making, then we call the abortTransaction() method

     if(! condition){
         sesssion.abortTransaction();
         console.error("All the changes made by transaction are rolled back")
     }
    
  6. We run the commitTransaction() method in the session to commit the transaction and bring the database to the new consistent state:

      sesssion.commitTransaction();
      session.endSession()
    
💡
If a transactions session runs for more than 60 seconds after the initial startTransaction() method, MongoDB will automatically abort the operation.

Node.js and mongoose

This code implements a transfer money feature between two accounts. It begins by extracting the necessary data from the request body, such as the recipient's account identifier (to) and the transfer amount (amount). Then, it initiates a MongoDB session and transaction to ensure that the transfer operations are atomic. Inside a try-catch block, it performs several checks and actions:

  1. It verifies the existence of the sender's account and checks if it has sufficient balance for the transfer.

  2. If the sender's account exists and has enough balance, it finds the recipient's account.

  3. It deducts the transfer amount from the sender's account and adds it to the recipient's account using findOneAndUpdate.

  4. After successfully updating both accounts, it commits the transaction, ensuring the consistency of the database. If any error occurs during the process, it aborts the transaction to prevent partial updates. Finally, it ends the session and sends a response back to the client, indicating whether the transfer was successful and providing any relevant error messages.

import mongoose from 'mongoose';
import Account from '../models/account';
import { AsyncWrapper } from '../utils/asyncWrapper';
import { ExpressError, ExpressResponse } from '../utils/expressUtils';
import { transferMoneyBody } from '../validators/transferMoney';

export const transferMoney = AsyncWrapper(async (req, res) => {
    const { to, amount } = req.body;
    const userId = req.userId;

  const session = await mongoose.startSession();
  session.startTransaction();

  try {
    // Find the sender's account
    const user = await Account.findOne({ accountHolder: userId }).session(session);
    if (!user) {
      throw new ExpressError(404, "User not found: Please check the account holder");
    }

    // Check if the user has enough balance
    if (user.balance < amount) {
      throw new ExpressError(400, "Insufficient Balance: Please try again");
    }

    // Find the receiver's account
    const toAccount = await Account.findOne({ accountHolder: to }).session(session);
    if (!toAccount) {
      throw new ExpressError(404, "Receiver not found: Please check the account number");
    }

    // Deduct money from the sender
    const newUserAcc = await Account.findOneAndUpdate(
      { accountHolder: userId },
      { $inc: { balance: -amount } },
      { new: true, session }
    );

    // Add money to the receiver
    await Account.findOneAndUpdate(
      { accountHolder: to },
      { $inc: { balance: amount } },
      { session }
    );

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

    res.status(200).json(
      new ExpressResponse(200, { balance: newUserAcc.balance }, "Money Transferred")
    );
  } catch (error) {
    await session.abortTransaction();
    throw error; // Re-throw the error for centralized error handling
  } finally {
    session.endSession();
  }
});

Conclusion

Transactions are like a safety net for database operations, ensuring that either all operations complete successfully or none at all. They maintain data consistency and reliability by rolling back changes if something goes wrong. In Node.js, we use sessions to handle transactions, ensuring that database interactions are secure and error-proof. The code snippet provided demonstrates how transactions are used to transfer money between accounts, guaranteeing that the process is smooth and error-free.