Problem/Motivation
#3438654: Drupal\commerce_order\Exception\OrderVersionMismatchException: Attempted to save order 125081 with version 8. Current version is 9. in /app/web/modules/contrib/commerce/modules/order/src/Entity/Order.php:682 made ShippingInformation reload the order with OrderStorage::loadForUpdate() in buildPaneForm() and validatePaneForm(). That fixes the stale order inside the pane, but two problems remain on the order_information step:
1. The checkout flow and the other panes keep the stale order.
executeWithOrderLoadedForUpdate() only replaces $this->order of the shipping pane. CheckoutFlowBase::$order and the other panes keep the order loaded at the start of the request. At the end of the step submission CheckoutFlowBase::submitForm() saves that older object. If a parallel request (typically the auto-recalculation) saved the order in the meantime, this throws OrderVersionMismatchException. Also, submitPaneForm() sets shipments on the pane's own copy of the order, which the flow never saves.
OrderVersionMismatchException: Attempted to save order N with version 6. Current version is 7.
Order->preSave()
OrderStorage->save()
CheckoutFlowBase->submitForm() (CheckoutFlowBase.php:480)
CheckoutFlowWithPanesBase->submitForm()
... FormBuilder->getForm()
CheckoutController->formPage()
2. The lock is released before the final save.
The pane releases the lock when validatePaneForm() returns. While the flow then saves the order, a parallel request, such as the "Recalculate shipping" AJAX request, can hold the lock. The save then fails with:
OrderLockedSaveException: Attempted to save order N that is locked for updating. Use OrderStorage::loadForUpdate().
OrderStorage->doOrderPreSave()
OrderStorage->save()
CheckoutFlowBase->submitForm() (CheckoutFlowBase.php:480)
CheckoutFlowWithPanesBase->submitForm()
... CheckoutController->formPage()On our production site this appeared 7 times in 4 days on POST /checkout/{order}/order_information, for guests and for a logged-in customer. We have log_version_mismatch: TRUE, so the exceptions are only logged and the save still goes through. With FALSE the customer would get a fatal error, and with TRUE a parallel save can still be overwritten.
Trigger: the shipping pane has auto_recalculate enabled. The recalculation starts on the change event of a required address field. If the customer leaves the field by clicking "Continue", the form is submitted while the recalculation request still runs. The JS disables the button only after a 100 ms timeout, which is too late for that click. A double click on the button has the same effect.
Steps to reproduce
- Configure the default checkout flow with the shipping information pane, auto_recalculate enabled and require_shipping_profile enabled, and have a shipping method available.
- As a guest, enter a complete shipping address on order_information.
- Change the last required field (e.g. the postal code) and click "Continue to Review" immediately, without waiting for the recalculation.
Proposed resolution
- Use the same order across the pane same, so that the flow's final save no longer uses an outdated one.
- In validatePaneForm() keep the lock when the step's primary button was triggered. In every other case, such as build, recalculation or validation errors, the lock is released as before.
- Disable the submit button immediately in shipping_checkout.js when a required field changes, not after the 100 ms wait. Enable it again if no recalculation follows and none is running. (Many customers changed there adress and immediatly clicked on "Continue" without the recalculations taking effect because the 100ms delay did not trigger throug the direct click)
Remaining tasks
User interface changes
API changes
Data model changes
| Comment | File | Size | Author |
|---|---|---|---|
| #7 | 3633498-follow-up-of-3438654-7.patch | 19.69 KB | grevil |
Issue fork commerce_shipping-3633498
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
grevil commentedComment #4
grevil commentedIssue found and fixed with the help of Claude.
I left the code comments in for now, but we should compact them a bit more before merge.
Comment #5
anybodyComment #6
anybodyThanks, I can confirm this issue is happening quite often for a client's frequently visited shop. We'll test the fix now, but LGTM and seems to fix the issue so far! Maintainer feedback welcome, because this is a general race condition issue.
Comment #7
grevil commentedStatic patch for the time being.
Comment #8
anybodyComment #9
didumir commentedConfirming this on a production shop, with a concrete customer-facing
consequence that the summary does not mention yet: **the order was placed
and paid without any shipping cost.**
Drupal 11.4.11, Commerce 3.3.12, Commerce Shipping 3.0.5 (contains the
#3438654 fix), PHP 8.4. Multistep flow, shipping information pane with auto
recalculate on order_information, `log_version_mismatch: true` (default).
Anonymous customer on a mobile browser.
Access log for the order:
```
19:01:38 GET /checkout/5397/order_information
19:02:05 POST /checkout/5397/order_information?ajax_form=1&_wrapper_format=drupal_ajax
19:02:05 POST /checkout/5397/order_information (Continue)
19:02:06 GET /checkout/5397/review
19:02:21 POST /checkout/5397/review
19:02:44 payment completed (8.90 EUR, subtotal only)
```
Logged once, during the non-AJAX POST:
```
OrderVersionMismatchException: Attempted to save order 5397 with version 4.
Current version is 5.
...
CheckoutFlowBase->submitForm() (CheckoutFlowBase.php:480)
```
Resulting data:
- commerce_shipment 2629 exists for order 5397: amount 4.90, correct
shipping method, state still `draft`.
- `commerce_order__shipments` has no row for order 5397.
- No shipping adjustment, order total = subtotal, payment captured for the
subtotal.
So the AJAX recalculation saved the order with the new shipment
(version 5). The submit request then saved the flow's stale order object
(version 4, without shipments). Because `log_version_mismatch` only logs,
that save overwrote the reference. After that, nothing in review or payment
noticed that a shippable order had no shipment, so checkout completed
normally.
This matches point 1 of the summary exactly. Maybe worth adding to the
issue summary under impact: with `log_version_mismatch: TRUE` this is not
only a logged exception, it can silently drop the shipment and the shipping
charge from a placed order.
As a stopgap on our side we add a small checkout pane on the review step
that redirects back to order_information if
`ShippingOrderManager::isShippable($order) && !hasShipments($order)`.
We will also test patch #7 and report back.
Comment #10
anybodyThanks @didumir very much looking forward to your feedback - I guess the MR will fix your issues probably!
Comment #11
didumir commentedFollow-up to my comment above: patch #7 looks good so far, no new errors in production after patching.
Requests are now strictly sequential. On a local dev copy I sent the "Recalculate shipping" AJAX POST and the "Continue to review" POST at the same time, several times in a row. Each time the Continue request waited for the order lock (it finished about 1.3 s after the recalculation), then went on to review. In every run the order had its shipment and the shipping adjustment, and no OrderVersionMismatchException was logged.
In a normal browser run (address changed, then "Continue to review" clicked right away), only the recalculate POST was sent. The JS change disabled the submit button at once. After that, review showed the correct total including shipping.
The patch also works with the latest security release. After the update for SA-CONTRIB-2026-254 (Commerce 3.3.13, Commerce Shipping 3.0.6), patch #7 still applies cleanly via composer-patches, with all hunks in place. The 3.0.6 changes (field access on shipment fields and on shipments, the separate resend_confirmation operation) don't touch any file the patch changes. All the tests above were run on 3.0.6 with the patch. It is also running in production on 3.0.6 now.
Comment #12
anybody@didumir thanks - would you mind setting this RTBC then, if you think it's fine?
Comment #13
didumir commentedCompared MR !136 (single commit 6eee4462) with patch #7: applied to 3.0.6, both produce identical code.
So my tests in #11 cover the current MR state.
Code-wise I only reviewed it as far as needed for testing; the testing itself was done on 3.0.5 and 3.0.6, locally and in production.
Setting to RTBC.
Comment #14
jsacksick commentedI'm going to apply this patch on a project where I noticed this issue in our logs, then report back in several days.
Comment #15
rhovlandI also encountered this as a result of my custom shipping insurance module saving to the order on the shipping information pane which results in the adjustment never being recorded once the order is placed.
EDIT: MR fixes the issue.
Comment #16
dpacassiWhile testing patch #7, we found that changing a required shipping field and immediately clicking “Continue to review” can still lose the click because recalculation disables or replaces the button.
Opened #3634785: Preserve the “Continue to review” click during shipping recalculation with a follow-up that retains the activation until recalculation finishes, plus regression tests. MR !138 is a draft and depends on this issue’s MR !136. We also verified the fix in the affected project.
Keeping the follow-up postponed until this issue is merged. AI assisted with the investigation, implementation, tests and this comment.
Comment #17
jsacksick commentedConsidering MR #136 fixes an important issue, I'm willing to merge the MR, tag a new release and we can implement what's reported #16 as a followup release.
The fix suggested in a followup issue is a UX fix, what's being fixed here is more important IMO.
Comment #19
jsacksick commented