Commit 7c1a127bef8 for woocommerce
commit 7c1a127bef814dd72fd56cec7ccb64817b011bb6
Author: Chris Huber <chubes@extrachill.com>
Date: Thu Jun 11 11:34:06 2026 -0400
Performance: coalesce variation batch transient deletion (#65591)
diff --git a/plugins/woocommerce/changelog/65591-fix-rest-batch-transient-coalescing b/plugins/woocommerce/changelog/65591-fix-rest-batch-transient-coalescing
new file mode 100644
index 00000000000..894aea7dfc8
--- /dev/null
+++ b/plugins/woocommerce/changelog/65591-fix-rest-batch-transient-coalescing
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Coalesce repeated product transient deletion during REST variation batch writes.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-variations-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-variations-v2-controller.php
index 98e8a499f81..6fe5f040b3d 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-variations-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-variations-v2-controller.php
@@ -11,6 +11,7 @@
use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
+use Automattic\WooCommerce\Internal\Caches\ProductTransientsDeferrer;
use Automattic\WooCommerce\Utilities\I18nUtil;
use Automattic\WooCommerce\Utilities\MetaDataUtil;
@@ -738,7 +739,14 @@ class WC_REST_Product_Variations_V2_Controller extends WC_REST_Products_V2_Contr
$request->set_body_params( $body_params );
$request->set_query_params( $query );
- return parent::batch_items( $request );
+ $transients_deferrer = wc_get_container()->get( ProductTransientsDeferrer::class );
+
+ $transients_deferrer->start_deferring();
+ try {
+ return parent::batch_items( $request );
+ } finally {
+ $transients_deferrer->stop_deferring();
+ }
}
/**
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index daf0d2d8635..a02b896cd61 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
+use Automattic\WooCommerce\Internal\Caches\ProductTransientsDeferrer;
use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
@@ -126,27 +127,13 @@ function wc_product_dimensions_enabled() {
* @param int $post_id (default: 0) The product ID.
*/
function wc_delete_product_transients( $post_id = 0 ) {
- // Transient data to clear with a fixed name which may be stale after product updates.
- $transients_to_clear = array(
- 'wc_products_onsale',
- 'wc_featured_products',
- 'wc_outofstock_count',
- 'wc_low_stock_count',
- );
-
- foreach ( $transients_to_clear as $transient ) {
- delete_transient( $transient );
- }
+ $container = wc_get_container();
- if ( $post_id > 0 ) {
- // Transient names that include an ID - since they are dynamic they cannot be cleaned in bulk without the ID.
- wc_get_container()->get( ProductUtil::class )->delete_product_specific_transients( $post_id );
+ if ( $container->get( ProductTransientsDeferrer::class )->maybe_defer_deletion( absint( $post_id ) ) ) {
+ return;
}
- // Kept for compatibility, WooCommerce core doesn't use product transient versions anymore.
- WC_Cache_Helper::get_transient_version( 'product', true );
-
- do_action( 'woocommerce_delete_product_transients', $post_id );
+ $container->get( ProductUtil::class )->delete_product_transients_for_products( array( $post_id ) );
}
/**
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index cdd3f68a954..3ca5c07a0fb 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -69337,18 +69337,6 @@ parameters:
count: 2
path: src/Internal/Utilities/PluginInstaller.php
- -
- message: '#^Binary operation "\." between ''wc_child_has…''\|''wc_child_has_weight_''\|''wc_product_children_''\|''wc_related_''\|''wc_var_prices_'' and Automattic\\WooCommerce\\Internal\\Utilities\\WC_Product\|int results in an error\.$#'
- identifier: binaryOp.invalid
- count: 1
- path: src/Internal/Utilities/ProductUtil.php
-
- -
- message: '#^Call to method get_id\(\) on an unknown class Automattic\\WooCommerce\\Internal\\Utilities\\WC_Product\.$#'
- identifier: class.notFound
- count: 1
- path: src/Internal/Utilities/ProductUtil.php
-
-
message: '#^Parameter \$product_or_id of method Automattic\\WooCommerce\\Internal\\Utilities\\ProductUtil\:\:delete_product_specific_transients\(\) has invalid type Automattic\\WooCommerce\\Internal\\Utilities\\WC_Product\.$#'
identifier: class.notFound
diff --git a/plugins/woocommerce/src/Internal/Caches/ProductTransientsDeferrer.php b/plugins/woocommerce/src/Internal/Caches/ProductTransientsDeferrer.php
new file mode 100644
index 00000000000..474f6333c05
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Caches/ProductTransientsDeferrer.php
@@ -0,0 +1,123 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Caches;
+
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
+
+/**
+ * Coalesces repeated product transient deletions during bulk write operations.
+ *
+ * @internal
+ */
+class ProductTransientsDeferrer {
+
+ /**
+ * The product utility instance.
+ *
+ * @var ProductUtil
+ */
+ private ProductUtil $product_util;
+
+ /**
+ * Nesting level of active deferrals.
+ *
+ * @var int
+ */
+ private int $deferral_level = 0;
+
+ /**
+ * Product IDs collected while deferral is active, as a set of id => true.
+ *
+ * @var array
+ */
+ private array $deferred_product_ids = array();
+
+ /**
+ * Initialize the class instance.
+ *
+ * @internal
+ *
+ * @param ProductUtil $product_util The product utility instance.
+ * @return void
+ */
+ final public function init( ProductUtil $product_util ): void {
+ $this->product_util = $product_util;
+ }
+
+ /**
+ * Start deferring product transient deletions. Calls can be nested.
+ *
+ * @return void
+ */
+ public function start_deferring(): void {
+ ++$this->deferral_level;
+ if ( 1 === $this->deferral_level ) {
+ add_action( 'shutdown', array( $this, 'handle_shutdown' ) );
+ }
+ }
+
+ /**
+ * Stop deferring product transient deletions.
+ *
+ * When the outermost deferral ends, all collected product IDs are flushed.
+ *
+ * @return void
+ */
+ public function stop_deferring(): void {
+ if ( 0 === $this->deferral_level ) {
+ return;
+ }
+
+ --$this->deferral_level;
+ if ( 0 === $this->deferral_level ) {
+ remove_action( 'shutdown', array( $this, 'handle_shutdown' ) );
+ $this->flush();
+ }
+ }
+
+ /**
+ * Record a product ID for deferred transient deletion, if deferral is active.
+ *
+ * @param int $product_id Product ID whose transients were requested to be deleted.
+ * @return bool True if the deletion was deferred, false if deferral is not active.
+ */
+ public function maybe_defer_deletion( int $product_id ): bool {
+ if ( 0 === $this->deferral_level ) {
+ return false;
+ }
+
+ $this->deferred_product_ids[ $product_id ] = true;
+ return true;
+ }
+
+ /**
+ * Handle the shutdown hook.
+ *
+ * Flushes pending deletions if deferral was not explicitly stopped.
+ *
+ * @internal
+ *
+ * @return void
+ */
+ public function handle_shutdown(): void {
+ $this->deferral_level = 0;
+ $this->flush();
+ }
+
+ /**
+ * Delete transients for all collected product IDs and reset the collection.
+ *
+ * @return void
+ */
+ private function flush(): void {
+ if ( empty( $this->deferred_product_ids ) ) {
+ return;
+ }
+
+ $product_ids = array_keys( $this->deferred_product_ids );
+ $this->deferred_product_ids = array();
+ $this->product_util->delete_product_transients_for_products( $product_ids );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
index 0e31d4242c0..c925cf0eeb6 100644
--- a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
@@ -8,6 +8,50 @@ namespace Automattic\WooCommerce\Internal\Utilities;
* Class with general utility methods related to products.
*/
class ProductUtil {
+ /**
+ * Delete all product transients for a set of products.
+ *
+ * Fixed-name transients are deleted once for the whole set, and the
+ * woocommerce_delete_product_transients action fires once per product ID.
+ *
+ * @param array $product_ids Product IDs whose transients are being deleted.
+ * @return void
+ */
+ public function delete_product_transients_for_products( array $product_ids ): void {
+ $product_ids = array_unique( array_map( 'absint', $product_ids ) );
+
+ // Transient data to clear with a fixed name which may be stale after product updates.
+ $transients_to_clear = array(
+ 'wc_products_onsale',
+ 'wc_featured_products',
+ 'wc_outofstock_count',
+ 'wc_low_stock_count',
+ );
+
+ foreach ( $transients_to_clear as $transient ) {
+ delete_transient( $transient );
+ }
+
+ $product_ids_to_clear = array_filter( $product_ids );
+ if ( ! empty( $product_ids_to_clear ) ) {
+ $this->delete_product_specific_transients_for_products( $product_ids_to_clear );
+ }
+
+ // Kept for compatibility, WooCommerce core doesn't use product transient versions anymore.
+ \WC_Cache_Helper::get_transient_version( 'product', true );
+
+ foreach ( $product_ids as $product_id ) {
+ /**
+ * Fires after product transients are deleted.
+ *
+ * @since 2.3.0
+ *
+ * @param int $product_id Product ID whose transients were deleted.
+ */
+ do_action( 'woocommerce_delete_product_transients', $product_id );
+ }
+ }
+
/**
* Delete the transients related to a specific product.
* If the product is a variation, delete the transients for the parent too.
@@ -16,19 +60,41 @@ class ProductUtil {
* @return void
*/
public function delete_product_specific_transients( $product_or_id ) {
- $parent_id = 0;
- if ( $product_or_id instanceof \WC_Product ) {
- $product = $product_or_id;
- $product_id = $product->get_id();
- } else {
- $product_id = $product_or_id;
- $product = wc_get_product( $product_id );
- }
+ $this->delete_product_specific_transients_for_products( array( $product_or_id ) );
+ }
+
+ /**
+ * Delete the transients related to a set of products.
+ * If a product is a variation, delete the transients for the parent too.
+ *
+ * @param array $products_or_ids Products or product ids.
+ * @return void
+ */
+ public function delete_product_specific_transients_for_products( array $products_or_ids ) {
+ $product_ids = array();
- if ( $product instanceof \WC_Product_Variation ) {
- $parent_id = $product->get_parent_id();
+ foreach ( $products_or_ids as $product_or_id ) {
+ $parent_id = 0;
+ if ( $product_or_id instanceof \WC_Product ) {
+ $product = $product_or_id;
+ $product_id = $product->get_id();
+ } else {
+ $product_id = $product_or_id;
+ $product = wc_get_product( $product_id );
+ }
+
+ if ( $product instanceof \WC_Product_Variation ) {
+ $parent_id = $product->get_parent_id();
+ }
+
+ $product_ids[] = $product_id;
+ if ( $parent_id ) {
+ $product_ids[] = $parent_id;
+ }
}
+ $product_ids = array_unique( array_filter( array_map( 'absint', $product_ids ) ) );
+
$product_specific_transient_names = array(
'wc_product_children_',
'wc_var_prices_',
@@ -37,10 +103,9 @@ class ProductUtil {
'wc_child_has_dimensions_',
);
- foreach ( $product_specific_transient_names as $transient ) {
- delete_transient( $transient . $product_id );
- if ( $parent_id ) {
- delete_transient( $transient . $parent_id );
+ foreach ( $product_ids as $product_id ) {
+ foreach ( $product_specific_transient_names as $transient ) {
+ delete_transient( $transient . $product_id );
}
}
}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
index ac7db2c9eb5..5a7bf1dce1a 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
@@ -984,4 +984,60 @@ class WC_REST_Product_Variations_Controller_Tests extends WC_REST_Unit_Test_Case
$this->assert_incomplete_meta_data_handled_correctly( wc_get_product( $variation->get_id() ) );
}
+
+ /**
+ * @testdox Variation batch updates coalesce repeated product transient deletion.
+ */
+ public function test_batch_update_coalesces_product_transient_deletion(): void {
+ $parent = WC_Helper_Product::create_variation_product();
+ $variation_ids = array_slice( $parent->get_children(), 0, 2 );
+ $deleted_ids = array();
+ $parent_product_children_deletes = 0;
+ $track_deletes = static function ( $product_id ) use ( &$deleted_ids ) {
+ $deleted_ids[] = (int) $product_id;
+ };
+ $track_parent_product_children_deletes = static function () use ( &$parent_product_children_deletes ) {
+ ++$parent_product_children_deletes;
+ };
+
+ add_action( 'woocommerce_delete_product_transients', $track_deletes );
+ add_action( 'delete_transient_wc_product_children_' . $parent->get_id(), $track_parent_product_children_deletes );
+ try {
+ $request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $parent->get_id() . '/variations/batch' );
+ $request->set_body_params(
+ array(
+ 'update' => array(
+ array(
+ 'id' => $variation_ids[0],
+ 'regular_price' => '12.99',
+ ),
+ array(
+ 'id' => $variation_ids[1],
+ 'regular_price' => '13.99',
+ ),
+ ),
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ } finally {
+ remove_action( 'woocommerce_delete_product_transients', $track_deletes );
+ remove_action( 'delete_transient_wc_product_children_' . $parent->get_id(), $track_parent_product_children_deletes );
+ }
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 2, $response->get_data()['update'] );
+
+ $expected_deleted_ids = array_merge( $variation_ids, array( $parent->get_id() ) );
+ $this->assertEqualsCanonicalizing( $expected_deleted_ids, array_unique( $deleted_ids ) );
+
+ foreach ( $expected_deleted_ids as $expected_deleted_id ) {
+ $this->assertSame(
+ 1,
+ count( array_keys( $deleted_ids, $expected_deleted_id, true ) ),
+ 'Each affected product should have product transients deleted once per batch.'
+ );
+ }
+ $this->assertSame( 1, $parent_product_children_deletes, 'Parent variation transients should be deleted once per batch.' );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Caches/ProductTransientsDeferrerTest.php b/plugins/woocommerce/tests/php/src/Internal/Caches/ProductTransientsDeferrerTest.php
new file mode 100644
index 00000000000..61fcc305253
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Caches/ProductTransientsDeferrerTest.php
@@ -0,0 +1,46 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Caches;
+
+use Automattic\WooCommerce\Internal\Caches\ProductTransientsDeferrer;
+
+/**
+ * Tests for product transient deletion deferral.
+ */
+class ProductTransientsDeferrerTest extends \WC_Unit_Test_Case {
+
+ /**
+ * @testdox Product transient deferral coalesces repeated deletions until the outermost deferral ends.
+ */
+ public function test_deferral_coalesces_repeated_deletions_until_outermost_stop() {
+ $deleted_ids = array();
+ $track_hook = static function ( $product_id ) use ( &$deleted_ids ) {
+ $deleted_ids[] = (int) $product_id;
+ };
+
+ $deferrer = wc_get_container()->get( ProductTransientsDeferrer::class );
+ add_action( 'woocommerce_delete_product_transients', $track_hook );
+
+ try {
+ $deferrer->start_deferring();
+ $deferrer->start_deferring();
+
+ wc_delete_product_transients( 123 );
+ wc_delete_product_transients( 123 );
+ wc_delete_product_transients( 456 );
+ $this->assertSame( array(), $deleted_ids );
+
+ $deferrer->stop_deferring();
+ $this->assertSame( array(), $deleted_ids );
+
+ $deferrer->stop_deferring();
+ } finally {
+ remove_action( 'woocommerce_delete_product_transients', $track_hook );
+ $deferrer->stop_deferring();
+ }
+
+ $this->assertSame( array( 123, 456 ), $deleted_ids );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
index cc7749ec9be..ab28f843873 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
@@ -11,6 +11,28 @@ use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
* Tests for the internal ProductUtil class.
*/
class ProductUtilTest extends \WC_Unit_Test_Case {
+ /**
+ * @testdox delete_product_transients_for_products deletes fixed-name transients once and fires hooks once per product.
+ */
+ public function test_delete_product_transients_for_products_deletes_fixed_transients_and_fires_hooks() {
+ $product_ids = array( 0, 123, 123, 456 );
+ $deleted_ids = array();
+ $track_hook = static function ( $product_id ) use ( &$deleted_ids ) {
+ $deleted_ids[] = (int) $product_id;
+ };
+
+ set_transient( 'wc_products_onsale', 'foobar' );
+ add_action( 'woocommerce_delete_product_transients', $track_hook );
+ try {
+ wc_get_container()->get( ProductUtil::class )->delete_product_transients_for_products( $product_ids );
+ } finally {
+ remove_action( 'woocommerce_delete_product_transients', $track_hook );
+ }
+
+ $this->assertFalse( get_transient( 'wc_products_onsale' ) );
+ $this->assertSame( array( 0, 123, 456 ), $deleted_ids );
+ }
+
/**
* @testdox delete_product_specific_transients deletes the transients for a product that is not a variation.
*
@@ -53,4 +75,25 @@ class ProductUtilTest extends \WC_Unit_Test_Case {
$this->assertFalse( get_transient( $parent_transient_name ) );
$this->assertFalse( get_transient( $child_transient_name ) );
}
+
+ /**
+ * @testdox delete_product_specific_transients_for_products deletes parent variation transients once for multiple variations.
+ */
+ public function test_delete_product_specific_transients_for_products_coalesces_parent_variation_transient_deletes() {
+ $parent_product = ProductHelper::create_variation_product();
+ $child_ids = array_slice( $parent_product->get_children(), 0, 2 );
+ $delete_attempts = 0;
+ $track_deletes = static function () use ( &$delete_attempts ) {
+ ++$delete_attempts;
+ };
+
+ add_action( 'delete_transient_wc_product_children_' . $parent_product->get_id(), $track_deletes );
+ try {
+ wc_get_container()->get( ProductUtil::class )->delete_product_specific_transients_for_products( $child_ids );
+ } finally {
+ remove_action( 'delete_transient_wc_product_children_' . $parent_product->get_id(), $track_deletes );
+ }
+
+ $this->assertSame( 1, $delete_attempts );
+ }
}