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 );
+	}
 }