node.jsmongodbmongooseconcurrencyrace-condition

Users can concurrently withdraw above their wallet balance by initiating multiple concurrent withdrawals


I am updating a finance application built on node.js, mongodb and ```mongoose`` and I am currently facing a challenge with the logic to process fund withdrawals from the system. The problem is that a user can request an amount greater than their balance if they hit the fund withdrawal endpoint concurrently. For instance, with an available balance of $10, the user can process multiple $10 withdrawals as long as they do it concurrently.

I know where the problem is coming from, just that I have been unable to resolve it. I know this is happening because the actions are taking place concurrently so the user.balance will always be 10 because the debit happens after the read. I am relatively new to backend development so pardon me if I don't use the correct terminologies. This is what my code looks like.

 try{
   // Perform tasks such as validating the account number, etc.

   const user = await User.findById(req.user._id);

   if(user.balance < req.body.amount)
     return res.status(400).send({message:"Insufficient Balance"});

    // update the user balance in the DB to user.balance - req.body.amount

    return res.status(200).send({message:"Fund has been sent to the account provided"})
 }

I tried an approach of updating the user record in the collection and using that response to check the user balance but I am not so sure if this will fix my issue completely or if there will still be loopholes or if there is a better way to handle this.

This is the second approach I tried out

 try{

   // Perform tasks such as validating the account number, etc.
   const idx = uuid()

   const user = await User.findByIdAndUpdate(req.user._id, {withdrawal_intent:idx} {
        new: true,
   });


   if(user.balance < req.body.amount)
     return res.status(400).send({message:"Insufficient Balance"});

    // update the user balance in the DB to user.balance - req.body.amount

    return res.status(200).send({message:"Fund has been sent to the account provided"})
 }

I am open to remodelling the code to fit the best possible way to handle this

P.S: It's an old codebase so I might be using an old syntax of mongoose. "mongoose": "^5.9.9"


Solution

  • Short answer:

    Race condition the concern in this question can be handled using the method findOneAndUpdate. The most important point is the conditional passed into this method. This should always check that the update being performed should not lead to a negative value. A sample code is given below, while reading it, please take advantage of the comments incorporated in it.

    Detailed answer:

    Please read through this post sequentially.

    Conditional update: Conditional update is a common optimistic approach to keep data consistent in the face of concurrent accessing, that is, consistency in the face of concurrency.

    It works on the principle that any client that does an update tests the value just before updating to see if it has changed since the last read. If the test fails, then a race condition - write-write conflict, has occurred, and the current update should be aborted informing users about the situation. For this original question, the test for race condition has been implemented as the updating would not lead to a negative balance.

    The following code would demonstrate it. It is mainly based on the method findOneAndUpdate as it would perform three operations - find, update and read, in an atomic way. For further details, please see the comments given in the code. Two of the statements in the code require some additional information as below.

    The statement - statement #1 : This is the statement causing a race condition in the code. At present it is uncommented. In order to run the code without creating a race condition, please comment this statement.

    The statement - statement #2 : This is the statement wherein the conditional update has been implemented, the condition - { a: 'A', b: { $gte: orgdoc.b } }, is doing the job.

    // MongoDB: 7.0.2
    // Mongoose : 8.3.2
    // Node.js v21.6.0.
    //conditionalUpdate.mjs
    
    import mongoose, { Schema } from 'mongoose';
    
    main().catch((err) => {
      console.log(err);
    });
    
    async function main() {
      await mongoose.connect('mongodb://127.0.0.1:27017/myapp');
    
      const someSchema = new Schema({ a: String, b: Number });
      const SomeModel = mongoose.model('SomeModel', someSchema);
      await SomeModel.deleteMany();
    
      // creating original document.
      const orgdoc = await SomeModel({
        a: 'A',
        b: 1,
      }).save();
    
      // deducting value
      const latestvalue = orgdoc.b - 1;
    
      // let us assume this operation is being executed by a separate client program in another session
      // performing a concurrent update against the same document
      // statement #1
      await SomeModel.updateOne({ a: 'A' }, { $inc: { b: -1 } });
    
      // performing a conditional update to safeguard
      // the possible write-write conflict
      // statement #2
      const updateQuery = SomeModel.findOneAndUpdate(
        { a: 'A', b: { $gte: orgdoc.b } },
        { b: latestvalue },
        { new: true }
      );
      const newdoc = await updateQuery.exec();
    
      // informing user for the needful action
      if (newdoc) {
        console.log(`Updation passed`);
      } else {
        console.log(
          `Updation failed - write-write conflict detected, please check the latest document against yours`
        );
      }
      console.log(`Original document: ${orgdoc}`);
      console.log(`Update Query: ${updateQuery}`);
      console.log(`Latest document : ${await SomeModel.findOne({ a: 'A' })}`);
    }
    
    // Output 1: When statement #1 is uncommented
    // Updation failed - write-write conflict detected, please check the latest document against yours
    // Original document: { a: 'A', b: 1, _id: ..., __v: 0 }
    // Update Query: SomeModel.findOneAndUpdate({ a: 'A', b: { '$gte': 1 } }, { '$set': { b: 0 } })
    // Latest document : { _id: ..., a: 'A', b: 0, __v: 0 }
    
    // Output 2: When statement #1 is commented
    // Updation passed
    // Original document: { a: 'A', b: 1, _id: ..., __v: 0 }
    // Update Query: SomeModel.findOneAndUpdate({ a: 'A', b: { '$gte': 1 } }, { '$set': { b: 0 } })
    // Latest document : { _id: ..., a: 'A', b: 0, __v: 0 }