#16825·medusa

Orphaned order when a concurrent reservation-step failure races sibling steps to OK inside completeCartWorkflow's parallelize() batch

Author: Arman-noCreated Sep 15, 2026Updated Sep 15, 2026
Labelstype: bugrequires-team

Environment: @medusajs/medusa v2.19.0, @medusajs/orchestration v2.19.0

Reproduction: N concurrent cart-completion requests racing for the last unit of a shared inventory item (different carts, same inventory item — not the same cart twice, which v2.8.4 already covers with a per-cart lock). Result: 2 orders created for 1 unit of stock. The losing order has no reservation row and no payment/capture row — orphaned.

Root cause, confirmed against actual source (dist/transaction/transaction-orchestrator.js, TransactionOrchestrator.setStepFailure): the guard before calling step.changeStatus(TEMPORARY_FAILURE) excludes PERMANENT_FAILURE but not OK. TransactionStep.changeStatus's allowed transitions map has no entry for OK at all, so calling it on an already-OK step throws unconditionally: "Updating Status from 'ok' to 'temp_failure' is not allowed."

Mechanism: completeCartWorkflow runs reserveInventoryStep inside a parallelize() batch alongside faster steps (emitEventStep, updateCartsStep, createRemoteLinkStep). Those reach OK before the slower reservation step (real DB work) fails. When the engine then tries to mark every step in that batch failed, it hits the already-OK sibling and throws, uncaught — aborting the rest of that failure-handling pass before the earlier, sequential createOrdersStep's own correct compensation (deleteOrders) gets a chance to run. The order row is left orphaned.

Not a duplicate of the v2.8.4 "strengthening of cart-completion flows" fix — that solved the same-cart-twice race with a per-cart-ID lock, confirmed present in v2.19.0. This is a different-carts-same-inventory-item race, which that lock doesn't cover.

Fix, verified:

diff
 if (!isTimeout &&
     step.getStates().status !== types_1.TransactionStepStatus.PERMANENT_FAILURE &&
+    step.getStates().status !== types_1.TransactionStepStatus.OK) {
     step.changeStatus(types_1.TransactionStepStatus.TEMPORARY_FAILURE);
 }

Skips the redundant, would-throw status change for an already-succeeded step, matching the existing PERMANENT_FAILURE guard right above it. Re-verified with a 50-concurrent-request race against a 2-unit pool: exactly 2 legitimate orders, both with a real reservation and captured payment, zero orphans. Full existing test suite still green.

Happy to share more detail if useful.