skip to Main Content

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"

2

Answers


  1. First of all, I recommend setting a rate limit on your endpoint, such as 1 request per second.

    Second, if you haven’t already defined a check on balance column to prevent negative balances, you should do so.

    (While I’m not an expert on MongoDB), you can achieve this by using a serializable transaction. This would involve selecting and updating the user’s balance, inserting a new record into the withdrawal table, and then committing the transaction. If the check fails, the transaction would be rolled back.

    For financial applications, stored procedures can be a good approach.

    Here’s an example:

    BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
    
    select "balance", "recordID" into "_balance", "_recordID" 
    from "Balance"
    where "userID" = "in_userID"
    for update;
    
    if ("_balance"< "in_amount") raise exception('INSUFFICENT_BALANCE'); end if;
    
    update "Balance" set "balance" = "balance" - "in_amount" where "recordID" = "_recordID";
    
    insert into "Withdrawal" () values ();
    
    COMMIT;
    
    Login or Signup to reply.
  2. Short answer:

    The race condition as it is mentioned in this case 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, 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 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. As we know, the findOneAndUpdate method would perform three operations – find, update and read, in an atomic way. For further details, please see the comments in the code. Two 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();
    
      // create the 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' }, { b: 0 });
    
      // performing a conditional update to safeguard
      // the possible write-write conflict
      // statement #2
      const newdoc = await SomeModel.findOneAndUpdate(
        { a: 'A', b: { $gte: orgdoc.b } },
        { b: latestvalue },
        { new: true }
      ); 
    
      // informing user for the needful action
      if (newdoc) {
        console.log(`Updation passed.`);
        console.log(`Latest document : ${newdoc}`);
        console.log(`Original document: ${orgdoc}`);
      } else {
        console.log(
          `Updation failed - write-write conflict detected, please check the latest document against yours`
        );
        console.log(`Latest document : ${await SomeModel.findOne({ a: 'A' })}`);
        console.log(`Original document: ${orgdoc}`);
      }
    }
    
    // OUTPUT:
    Updation failed - write-write conflict detected, please check the latest document against yours
    Latest document : { _id: ..., a: 'A', b: 0, __v: 0 }
    Original document: { a: 'A', b: 1, _id: ..., __v: 0 }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search