Incorrect total price on "order" document when its order items are being updated concurrently

Hi everyone,

I have 2 collections: order and orderitems where an order consists of order item(s).

The order has field price which should be the sum of the price of the order items only if the status is “new”.

For example, order ABC has 3 order items :

  1. Price: 5, status: “new”
  2. Price: 10, status: “paid”
  3. Price: 15, status: “new”

This order should have the price = 20.

What happens is sometimes the order price does not calculate correctly when the order items’ status is updated concurrently from “new” to “paid” or vice versa.

For example, when order item 1 and 3 are updated to “paid”, the order price is still 20 instead of 0.

My update order item API (Node JS) looks like this:

    const orderitem = await services
      .query("orderitem")
      .findOne({ _id: orderitemId });
    const currStatus = orderitem.status;
    const newStatus = request.status ?? currStatus;
    const currPrice = orderitem.price;
    const newPrice = request.price ?? currPrice;
    const statusNew = "new";
    let inc = 0.0;

    if (newStatus === statusNew && currStatus === statusNew) {
      inc = newPrice - currPrice;
    } else if (newStatus === statusNew && currStatus !== statusNew) {
      inc = newPrice;
    } else if (newStatus !== statusNew && currStatus === statusNew) {
      inc = currPrice * -1;
    }

    const session = services.common.startDbSession();

    try {
      await session.withTransaction(async () => {
        if (inc !== 0.0) {
          await services.order.updateOutstandingBalance(
            orderitem.order._id,
            inc,
            session
          );
        }

        await services
          .query("orderitem")
          .model.updateOne(params, request)
          .session(session);
      });
    } finally {
      await session.endSession();
    }

And here is the updateOutstandingBalance function:

    updateOutstandingBalance: async (_id, inc = 0.0, session = null) => {
      await services
        .query("order")
        .model.updateOne(
          { _id: _id },
          {
            $inc: { price: inc },
            updatedAt: new Date(),
            myLock: { appName: "myApp", pseudoRandom: new ObjectID() },
          }
        )
        .session(session);
    }

The transaction is using write concern " majority" and read concern “local”.

Any help is really appreciated, thank you.

I’m curious - why are order line items in a separate collection? It’s kind of a textbook example of schema that’s likely going to be better with array of line items in order object.

As far as transaction, I’m not sure I follow your explanation of what the business logic should be but I find it highly suspect that the query against orderitems originally happens outside of transaction. It means if there are two attempts to write that conflict the second one will retry the transaction - but not the read.

Hi Asya,

In our case, order items can be dynamically added and updated individually, customers can also pay for only specific order items. It’s a little bit different from the usual e-commerce where order items are created together with the order upon checkout. I hope this answers you.

You can update individual items within an array - and update the total, etc atomically in a single update. It’s part of normal e-commerce. Different items ship at different times. Some item might get returned. It’s still best done as an array in the order document. Saves space and indexes too.

Thanks for the prompt reply.

This discrepancy only intermittently happens when there is batch status update on multiple order items of a particular order.

For example, order ABC has 3 order items:

  1. Status is updated from “new” to “paid”
  2. Status is also updated from “new” to “paid”
  3. Status is also updated from “new” to “paid”

The read outside of the transaction is only reading from the orderitem, not from the order. Then based on its status and price change, it does $inc (if not 0) to the order’s price and update the orderitem itself. So CMIIW, the calculation doesn’t rely on the order price.

Do you think the orderitem read should also happen in the transaction?

Thanks for the suggestion, but it’s currently not possible to change the data structure because it impacts all the APIs and UI that the customers face.

That aside, what could be wrong from my implementation? Seems like the update sometimes just doesn’t increment correctly. Is there anything wrong on the transaction options, write concern “majority” and read concern “local”?

Using write concern “majority” and read concern “snapshot” also did not help.

Sorry about the delay but I’m still curious to understand what happens and not seeing all of the code I find myself guessing here.

Can you explain what APIs you are using? This looks close to our node.js API examples but not exactly - so I’m wondering if the issue is in some of the supporting code.

For instance I’m not sure what the services.common.startDbSession() call does as I couldn’t find any references to it anywhere - if that your own wrapper of startSession()? Your subsequent calls are on `services.query().model.updateOne().session(session) - is this Mongoose wrapper over MongoDB Node.js driver? Or some other wrapper?

If you can’t share the code, you can run this with logging increased on the DB side and confirm that in fact transaction is starting when you think it’s starting and all the writes are showing up before commitTransaction - otherwise it’s really hard to say what’s happening when you run this code.

Hi Asya,

I am using Strapi and the driver is Mongoose 5.8.0, services.common.startDbSession() is just the wrapper of client.startSession().

Here is the client detail:

    MongoClient {
      _events: [Object: null prototype] {
        newListener: [Function (anonymous)],
        left: [Function (anonymous)]
      },
      _eventsCount: 2,
      _maxListeners: undefined,
      s: {
        url: 'mongodb://some_url/strapi?ssl=true&replicaSet=Cluster0-shard-0&authSource=some_source&retryWrites=true&w=majority&readPreference=primaryPreferred',
        options: {
          servers: [Array],
          ssl: true,
          replicaSet: 'Cluster0-shard-0',
          authSource: 'some_source',
          retryWrites: true,
          w: 'majority',
          readPreference: [ReadPreference],
          caseTranslate: true,
          useNewUrlParser: true,
          useUnifiedTopology: 'false',
          promiseLibrary: [Function: Promise],
          driverInfo: [Object],
          auth: [Object],
          dbName: 'strapi',
          name: 'Mongoose',
          version: '5.8.0',
          socketTimeoutMS: 360000,
          connectTimeoutMS: 30000,
          useRecoveryToken: true,
          credentials: [MongoCredentials]
        },
        promiseLibrary: [Function: Promise],
        dbCache: Map(1) { 'strapi' => [Db] },
        sessions: Set(0) {},
        writeConcern: undefined,
        namespace: MongoDBNamespace { db: 'some_db', collection: undefined }
      },
      topology: NativeTopology {
        _events: [Object: null prototype] {
          authenticated: [Function (anonymous)],
          error: [Array],
          timeout: [Array],
          close: [Array],
          parseError: [Array],
          fullsetup: [Array],
          all: [Array],
          reconnect: [Array],
          serverOpening: [Function (anonymous)],
          serverDescriptionChanged: [Function (anonymous)],
          serverHeartbeatStarted: [Function (anonymous)],
          serverHeartbeatSucceeded: [Function (anonymous)],
          serverHeartbeatFailed: [Function (anonymous)],
          serverClosed: [Function (anonymous)],
          topologyOpening: [Function (anonymous)],
          topologyClosed: [Function (anonymous)],
          topologyDescriptionChanged: [Function (anonymous)],
          commandStarted: [Function (anonymous)],
          commandSucceeded: [Function (anonymous)],
          commandFailed: [Function (anonymous)],
          joined: [Array],
          left: [Array],
          ping: [Function (anonymous)],
          ha: [Function (anonymous)],
          open: [Function],
          reconnectFailed: [Function (anonymous)]
        },
        _eventsCount: 26,
        _maxListeners: Infinity,
        s: {
          id: 0,
          options: [Object],
          seedlist: [Array],
          state: 'connected',
          description: [TopologyDescription],
          serverSelectionTimeoutMS: 30000,
          heartbeatFrequencyMS: 10000,
          minHeartbeatFrequencyMS: 500,
          Cursor: [Function: Cursor],
          bson: BSON {},
          servers: [Map],
          sessionPool: [ServerSessionPool],
          sessions: Set(0) {},
          promiseLibrary: [Function: Promise],
          credentials: [MongoCredentials],
          clusterTime: [Object],
          iterationTimers: Set(0) {},
          connectionTimers: Set(0) {},
          clientInfo: [Object],
          sCapabilities: [ServerCapabilities]
        },
        [Symbol(kCapture)]: false
      },
      [Symbol(kCapture)]: false
    }

And yes, I can see the TRANSACTION_IN_PROGRESS from the session:

    ClientSession {
      _events: [Object: null prototype] {
        ended: [Function: bound onceWrapper] { listener: [Function (anonymous)] }
      },
      _eventsCount: 1,
      _maxListeners: undefined,
      topology: NativeTopology {
        _events: [Object: null prototype] {
          authenticated: [Function (anonymous)],
          error: [Array],
          timeout: [Array],
          close: [Array],
          parseError: [Array],
          fullsetup: [Array],
          all: [Array],
          reconnect: [Array],
          serverOpening: [Function (anonymous)],
          serverDescriptionChanged: [Function (anonymous)],
          serverHeartbeatStarted: [Function (anonymous)],
          serverHeartbeatSucceeded: [Function (anonymous)],
          serverHeartbeatFailed: [Function (anonymous)],
          serverClosed: [Function (anonymous)],
          topologyOpening: [Function (anonymous)],
          topologyClosed: [Function (anonymous)],
          topologyDescriptionChanged: [Function (anonymous)],
          commandStarted: [Function (anonymous)],
          commandSucceeded: [Function (anonymous)],
          commandFailed: [Function (anonymous)],
          joined: [Array],
          left: [Array],
          ping: [Function (anonymous)],
          ha: [Function (anonymous)],
          open: [Function],
          reconnectFailed: [Function (anonymous)]
        },
        _eventsCount: 26,
        _maxListeners: Infinity,
        s: {
          id: 0,
          options: [Object],
          seedlist: [Array],
          state: 'connected',
          description: [TopologyDescription],
          serverSelectionTimeoutMS: 30000,
          heartbeatFrequencyMS: 10000,
          minHeartbeatFrequencyMS: 500,
          Cursor: [Function: Cursor],
          bson: BSON {},
          servers: [Map],
          sessionPool: [ServerSessionPool],
          sessions: [Set],
          promiseLibrary: [Function: Promise],
          credentials: [MongoCredentials],
          clusterTime: [Object],
          iterationTimers: Set(0) {},
          connectionTimers: Set(0) {},
          clientInfo: [Object],
          sCapabilities: [ServerCapabilities]
        },
        [Symbol(kCapture)]: false
      },
      sessionPool: ServerSessionPool {
        topology: NativeTopology {
          _events: [Object: null prototype],
          _eventsCount: 26,
          _maxListeners: Infinity,
          s: [Object],
          [Symbol(kCapture)]: false
        },
        sessions: [
          [ServerSession], [ServerSession],
          [ServerSession], [ServerSession],
          [ServerSession], [ServerSession],
          [ServerSession], [ServerSession],
          [ServerSession], [ServerSession],
          [ServerSession], [ServerSession],
          [ServerSession], [ServerSession],
          [ServerSession]
        ]
      },
      hasEnded: false,
      serverSession: ServerSession {
        id: { id: [Binary] },
        lastUse: 1591716044756,
        txnNumber: 2,
        isDirty: false
      },
      clientOptions: {
        servers: [ [Object], [Object], [Object] ],
        ssl: true,
        replicaSet: 'Cluster0-shard-0',
        authSource: 'some_source',
        retryWrites: true,
        w: 'majority',
        readPreference: ReadPreference { mode: 'primaryPreferred', tags: undefined },
        caseTranslate: true,
        useNewUrlParser: true,
        useUnifiedTopology: 'false',
        promiseLibrary: [Function: Promise],
        driverInfo: { name: 'Mongoose', version: '5.8.0' },
        auth: {
          username: 'some_username',
          password: 'some_password',
          db: 'some_db',
          user: 'some_user'
        },
        dbName: 'strapi',
        name: 'Mongoose',
        version: '5.8.0',
        socketTimeoutMS: 360000,
        connectTimeoutMS: 30000,
        useRecoveryToken: true,
        credentials: MongoCredentials {
          username: 'some_username',
          password: 'some_password',
          source: 'some_source',
          mechanism: 'scram-sha-1',
          mechanismProperties: undefined
        }
      },
      supports: { causalConsistency: true },
      clusterTime: {
        clusterTime: Timestamp { _bsontype: 'Timestamp', low_: 2, high_: 1591716043 },
        signature: { hash: [Binary], keyId: [Long] }
      },
      operationTime: Timestamp { _bsontype: 'Timestamp', low_: 2, high_: 1591716043 },
      explicit: true,
      owner: undefined,
      defaultTransactionOptions: {},
      transaction: Transaction {
        state: 'TRANSACTION_IN_PROGRESS',
        options: {
          writeConcern: [Object],
          readConcern: [Object],
          readPreference: 'primary'
        },
        _pinnedServer: undefined,
        _recoveryToken: undefined
      },
      [Symbol(kCapture)]: false
    }

Hopefully this helps.