[Bug]: Concurrent createReturn calls for the same order can corrupt return_requested_quantity via an unlocked order-change guard and stale reads
Package.json file
{
"name": "medusa-store",
"version": "0.0.1",
"dependencies": {
"@medusajs/framework": "2.19.0",
"@medusajs/medusa": "2.19.0",
"@medusajs/order": "2.19.0"
},
"engines": { "node": ">=20" }
}Node.js version
v22.22.3
Database and its version
PostgreSQL 17.6
Operating system name and version
macOS 15.6 (Sequoia)
Browser name
No response
What happended?
Two compounding defects in OrderModuleService let two concurrent order-modifying calls for the same order (e.g. two returns, or a return racing an order edit) corrupt order item state instead of being safely serialized or rejected.
createOrderChange_'s "one active order change per order" guard is an unlocked check-then-act (packages/modules/order/src/services/order-module-service.ts, ~L2480-2539). It lists existing order changes with status PENDING/REQUESTED, throws "Order (...) already has an existing active order change" if one exists, and creates a new one otherwise - with no row lock and no unique constraint backing the invariant (IDX_order_change_order_id on the order_change table is declared unique: false). Two concurrent calls for the same order can each see no active change and both pass.
applyOrderChanges_'s read of items.detail (fulfilled/return/shipped quantities, ~L3592-3675) can return values already stale in the request's identity map from an earlier read in the same call chain. createReturn (packages/modules/order/src/services/actions/create-return.ts) does an initial this.orderService_.retrieve(data.order_id, { relations: ["items"] }, sharedContext) before ever reaching the order-change guard; if that happens before a concurrent transaction commits its own change to the same order, the cached copy is stale, and applyOrderChanges_'s own later listOrders_ call doesn't correct it - MikroORM merges only the freshly-selected fields into the already-tracked entity rather than replacing it. Every quantity validation in applyChangesToOrder (e.g. RETURN_ITEM's validate() checking fulfilled_quantity - return_requested_quantity) runs against this stale data.
Together: two concurrent createReturn calls for the same item can each independently compute their return quantity against the same pre-race baseline, both succeed, and neither reflects the other's contribution.
Expected behavior
Two concurrent returns for the same order should either serialize correctly (the second seeing the first's committed contribution) or be safely rejected when their combined quantity exceeds what was fulfilled. The final state should always reflect the true combined return_requested_quantity across both returns, on a single consistent order-item version.
Actual behavior
Reproduced against a real Postgres DB. Order item: quantity: 2, fully fulfilled (fulfilled_quantity: 2). Two concurrent createReturn calls, each requesting quantity: 1 for the same item:
- Both calls succeed (as expected, since 1+1 = 2 is within what was fulfilled).
- But the resulting order state is corrupted: two separate OrderItem detail rows are created, both stamped version: 3, each independently showing return_requested_quantity: 1 - neither reflects the other's contribution. The correct final state should be a single current-version row showing return_requested_quantity: 2.
Separately, with only 1 unit fulfilled, two concurrent returns each requesting 1 unit both succeed instead of exactly one being rejected with "Cannot request to return more items than what was fulfilled" - each validates independently against the same stale return_requested_quantity: 0.
Link to reproduction repo
No isolated repo needed - reproducible directly against OrderModuleService: const order = await service.createOrders({ email: "[email protected]", items: [{ title: "Item 1", quantity: 2, unit_price: 10 }], sales_channel_id: "test", shipping_address: { /* ... / }, billing_address: { / ... */ }, shipping_methods: [{ name: "Test shipping method", amount: 10 }], currency_code: "usd", customer_id: "joe", }) const item = order.items[0] await service.registerFulfillment({ order_id: order.id, items: [{ id: item.id, quantity: item.quantity }], }) const attemptReturn = () => service.createReturn({ order_id: order.id, reference: "fulfillment", items: [{ id: item.id, quantity: 1 }], }) // Both resolve "ok" - but the resulting return_requested_quantity is // corrupted (see "Actual behavior" above), not the correct combined 2. await Promise.all([attemptReturn(), attemptReturn()]) const refreshed = await service.retrieveOrder(order.id, { select: ["id", "items.detail.return_requested_quantity"], relations: ["items", "items.detail"], }) console.log(refreshed.items[0].detail.return_requested_quantity) // wrong
Source: medusajs/medusa