Commit 06864bcc66f for woocommerce
commit 06864bcc66fa2f5de6e2214b6310c4103482adc0
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date: Thu Apr 30 16:58:16 2026 +0200
[Performance] : Streamline cart validation and stock reservation workflows (#64475)
During BFCM, obtaining product stock is a red-hot path. Here, we are optimizing for efficient, early identification of sold-out stock and efficient, deadlock-resilient stock reservation workflows.
diff --git a/plugins/woocommerce/changelog/performance-checkout-thruput-stock-and-validation b/plugins/woocommerce/changelog/performance-checkout-thruput-stock-and-validation
new file mode 100644
index 00000000000..47a130a3f93
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-checkout-thruput-stock-and-validation
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Enhance the efficiency and reliability of stock reservation processes during checkout.
diff --git a/plugins/woocommerce/includes/class-wc-cart.php b/plugins/woocommerce/includes/class-wc-cart.php
index f92478a8a25..b6e976e832d 100644
--- a/plugins/woocommerce/includes/class-wc-cart.php
+++ b/plugins/woocommerce/includes/class-wc-cart.php
@@ -755,8 +755,8 @@ class WC_Cart extends WC_Legacy_Cart {
$quantities = array();
foreach ( $this->get_cart() as $values ) {
- $product = $values['data'];
- $quantities[ $product->get_stock_managed_by_id() ] = isset( $quantities[ $product->get_stock_managed_by_id() ] ) ? $quantities[ $product->get_stock_managed_by_id() ] + $values['quantity'] : $values['quantity'];
+ $managed_by_id = $values['data']->get_stock_managed_by_id();
+ $quantities[ $managed_by_id ] = $values['quantity'] + ( $quantities[ $managed_by_id ] ?? 0 );
}
return $quantities;
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 9b72c60fc08..1267e49dc13 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -56775,30 +56775,12 @@ parameters:
count: 1
path: src/Caching/WPCacheEngine.php
- -
- message: '#^@param Automattic\\WooCommerce\\Checkout\\Helpers\\WC_Order \$order does not accept actual type of parameter\: WC_Order\.$#'
- identifier: parameter.phpDocType
- count: 1
- path: src/Checkout/Helpers/ReserveStock.php
-
- -
- message: '#^@param Automattic\\WooCommerce\\Checkout\\Helpers\\WC_Order_Item_Product \$item does not accept actual type of parameter\: WC_Order_Item\.$#'
- identifier: parameter.phpDocType
- count: 1
- path: src/Checkout/Helpers/ReserveStock.php
-
-
message: '#^Call to an undefined method WC_Data_Store\:\:get_query_for_stock\(\)\.$#'
identifier: method.notFound
count: 1
path: src/Checkout/Helpers/ReserveStock.php
- -
- message: '#^Call to an undefined method WC_Order_Item\:\:get_product\(\)\.$#'
- identifier: method.notFound
- count: 2
- path: src/Checkout/Helpers/ReserveStock.php
-
-
message: '#^Method Automattic\\WooCommerce\\Checkout\\Helpers\\ReserveStock\:\:get_query_for_reserved_stock\(\) never returns void so it can be removed from the return type\.$#'
identifier: return.unusedType
diff --git a/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php b/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php
index df8457253b0..e66d229a72b 100644
--- a/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php
+++ b/plugins/woocommerce/src/Checkout/Helpers/ReserveStock.php
@@ -71,6 +71,10 @@ final class ReserveStock {
* @param int $minutes How long to reserve stock in minutes. Defaults to woocommerce_hold_stock_minutes.
*/
public function reserve_stock_for_order( $order, $minutes = 0 ) {
+ if ( ! $this->is_enabled() ) {
+ return;
+ }
+
$minutes = $minutes ? $minutes : (int) get_option( 'woocommerce_hold_stock_minutes', 60 );
/**
* Filters the number of minutes an order should reserve stock for.
@@ -83,24 +87,25 @@ final class ReserveStock {
* @param \WC_Order $order Order object.
*/
$minutes = (int) apply_filters( 'woocommerce_order_hold_stock_minutes', $minutes, $order );
-
- if ( ! $minutes || ! $this->is_enabled() ) {
+ if ( ! $minutes ) {
return;
}
$held_stock_notes = array();
-
try {
- $items = array_filter(
- $order->get_items(),
- function ( $item ) {
- return $item->is_type( OrderItemType::LINE_ITEM ) && $item->get_product() instanceof \WC_Product && $item->get_quantity() > 0;
+ $rows = array();
+
+ foreach ( $order->get_items() as $item ) {
+ $is_target_item = $item->is_type( OrderItemType::LINE_ITEM ) && $item->get_quantity() > 0;
+ if ( ! $is_target_item ) {
+ continue;
}
- );
- $rows = array();
- foreach ( $items as $item ) {
+ /** @var \WC_Order_Item_Product $item */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
$product = $item->get_product();
+ if ( ! $product instanceof \WC_Product ) {
+ continue;
+ }
if ( ! $product->is_in_stock() ) {
throw new ReserveStockException(
@@ -124,13 +129,14 @@ final class ReserveStock {
/**
* Filter order item quantity.
*
- * @param int|float $quantity Quantity.
- * @param WC_Order $order Order data.
- * @param WC_Order_Item_Product $item Order item data.
+ * @since 4.5.0
+ * @param int|float $quantity Quantity.
+ * @param \WC_Order $order Order data.
+ * @param \WC_Order_Item_Product $item Order item data.
*/
$item_quantity = apply_filters( 'woocommerce_order_item_quantity', $item->get_quantity(), $order, $item );
- $rows[ $managed_by_id ] = isset( $rows[ $managed_by_id ] ) ? $rows[ $managed_by_id ] + $item_quantity : $item_quantity;
+ $rows[ $managed_by_id ] = $item_quantity + ( $rows[ $managed_by_id ] ?? 0 );
if ( count( $held_stock_notes ) < 5 ) {
// translators: %1$s is a product's formatted name, %2$d: is the quantity of said product to which the stock hold applied.
@@ -139,6 +145,8 @@ final class ReserveStock {
}
if ( ! empty( $rows ) ) {
+ // Reliability: consistent lock order = no cross-product ordering deadlocks from concurrent orders with same products added in different sequences.
+ ksort( $rows );
foreach ( $rows as $product_id => $quantity ) {
$this->reserve_stock_for_product( $product_id, $quantity, $order, $minutes );
}
@@ -200,35 +208,41 @@ final class ReserveStock {
*
* @throws ReserveStockException If a row cannot be inserted.
*
- * @param int $product_id Product ID which is having stock reserved.
+ * @param int $product_id Product ID which is having stock reserved.
* @param int $stock_quantity Stock amount to reserve.
- * @param \WC_Order $order Order object which contains the product.
- * @param int $minutes How long to reserve stock in minutes.
+ * @param \WC_Order $order Order object which contains the product.
+ * @param int $minutes How long to reserve stock in minutes.
*/
private function reserve_stock_for_product( $product_id, $stock_quantity, $order, $minutes ) {
global $wpdb;
- $product_data_store = \WC_Data_Store::load( 'product' );
- $query_for_stock = $product_data_store->get_query_for_stock( $product_id );
+ $query_for_stock = \WC_Data_Store::load( 'product' )->get_query_for_stock( $product_id );
$query_for_reserved_stock = $this->get_query_for_reserved_stock( $product_id, $order->get_id() );
- // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
- $result = $wpdb->query(
- $wpdb->prepare(
- "
- INSERT INTO {$wpdb->wc_reserved_stock} ( `order_id`, `product_id`, `stock_quantity`, `timestamp`, `expires` )
- SELECT %d, %d, %d, NOW(), ( NOW() + INTERVAL %d MINUTE ) FROM DUAL
- WHERE ( $query_for_stock FOR UPDATE ) - ( $query_for_reserved_stock FOR UPDATE ) >= %d
- ON DUPLICATE KEY UPDATE `expires` = VALUES( `expires` ), `stock_quantity` = VALUES( `stock_quantity` )
- ",
- $order->get_id(),
- $product_id,
- $stock_quantity,
- $minutes,
- $stock_quantity
- )
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $sql = $wpdb->prepare(
+ "
+ INSERT INTO {$wpdb->wc_reserved_stock} ( `order_id`, `product_id`, `stock_quantity`, `timestamp`, `expires` )
+ SELECT %d, %d, %d, NOW(), ( NOW() + INTERVAL %d MINUTE ) FROM DUAL
+ WHERE ( $query_for_stock FOR UPDATE ) - ( $query_for_reserved_stock FOR UPDATE ) >= %d
+ ON DUPLICATE KEY UPDATE `expires` = VALUES( `expires` ), `stock_quantity` = VALUES( `stock_quantity` )
+ ",
+ $order->get_id(),
+ $product_id,
+ $stock_quantity,
+ $minutes,
+ $stock_quantity
);
- // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ // Reliability: high concurrency on the same product reservation can trigger deadlocks (error codes 1213 and 1205).
+ for ( $attempt = 0; $attempt < 3; ++$attempt ) {
+ $result = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+ $is_lock = false === $result && false !== strpos( $wpdb->last_error, 'try restarting transaction' );
+ if ( ! $is_lock ) {
+ break;
+ }
+ }
if ( ! $result ) {
$product = wc_get_product( $product_id );
@@ -247,18 +261,19 @@ final class ReserveStock {
/**
* Returns query statement for getting reserved stock of a product.
*
- * @param int $product_id Product ID.
+ * @param int $product_id Product ID.
* @param int $exclude_order_id Optional order to exclude from the results.
- * @return string|void Query statement.
+ * @return string|void Query statement.
*/
private function get_query_for_reserved_stock( $product_id, $exclude_order_id = 0 ) {
global $wpdb;
- $join = "$wpdb->posts posts ON stock_table.`order_id` = posts.ID";
- $where_status = "posts.post_status IN ( 'wc-checkout-draft', '" . OrderInternalStatus::PENDING . "' )";
if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
$join = "{$wpdb->prefix}wc_orders orders ON stock_table.`order_id` = orders.id";
$where_status = "orders.status IN ( 'wc-checkout-draft', '" . OrderInternalStatus::PENDING . "' )";
+ } else {
+ $join = "{$wpdb->posts} posts ON stock_table.`order_id` = posts.ID";
+ $where_status = "posts.post_status IN ( 'wc-checkout-draft', '" . OrderInternalStatus::PENDING . "' )";
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
index a2120758e8a..58f1a7876e3 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
@@ -692,25 +692,24 @@ class CartController {
);
}
- if ( $product->is_sold_individually() && $cart_item['quantity'] > 1 ) {
- throw new TooManyInCartException(
- 'woocommerce_rest_product_too_many_in_cart',
+ if ( ! $product->is_in_stock() ) {
+ throw new OutOfStockException(
+ 'woocommerce_rest_product_out_of_stock',
$product->get_name()
);
}
- if ( ! $product->is_in_stock() ) {
- throw new OutOfStockException(
- 'woocommerce_rest_product_out_of_stock',
+ if ( $cart_item['quantity'] > 1 && $product->is_sold_individually() ) {
+ throw new TooManyInCartException(
+ 'woocommerce_rest_product_too_many_in_cart',
$product->get_name()
);
}
if ( $product->managing_stock() && ! $product->backorders_allowed() ) {
- $qty_remaining = $this->get_remaining_stock_for_product( $product );
- $qty_in_cart = $this->get_product_quantity_in_cart( $product );
-
- if ( $qty_remaining < $qty_in_cart ) {
+ $stock_remaining = $this->get_remaining_stock_for_product( $product );
+ $sold_out = $stock_remaining <= 0;
+ if ( $sold_out || $stock_remaining < $this->get_product_quantity_in_cart( $product ) ) {
throw new PartialOutOfStockException(
'woocommerce_rest_product_partially_out_of_stock',
$product->get_name()
@@ -1092,7 +1091,7 @@ class CartController {
$product_quantities = $cart->get_cart_item_quantities();
$product_id = $product->get_stock_managed_by_id();
- return isset( $product_quantities[ $product_id ] ) ? $product_quantities[ $product_id ] : 0;
+ return $product_quantities[ $product_id ] ?? 0;
}
/**
@@ -1102,8 +1101,7 @@ class CartController {
* @return int
*/
protected function get_remaining_stock_for_product( $product ) {
- $reserve_stock = new ReserveStock();
- $qty_reserved = $reserve_stock->get_reserved_stock( $product, $this->get_draft_order_id() );
+ $qty_reserved = ( new ReserveStock() )->get_reserved_stock( $product, $this->get_draft_order_id() );
return $product->get_stock_quantity() - $qty_reserved;
}