Commit f52be067ce9 for woocommerce

commit f52be067ce99dca499195e002629cd6cbb19294c
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date:   Mon Aug 10 12:07:22 2026 +0200

    [Performance] Re-enable persistent product counters (#67366)

diff --git a/plugins/woocommerce/changelog/performance-reenable-persistent-product-counters b/plugins/woocommerce/changelog/performance-reenable-persistent-product-counters
new file mode 100644
index 00000000000..2c9e7fc46f7
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-reenable-persistent-product-counters
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Re-enable persistent product status counters.
diff --git a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
index 88c7fb61a31..c3f30b92403 100644
--- a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
+++ b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
@@ -61,7 +61,7 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 		add_filter( 'posts_clauses', array( $this, 'posts_clauses' ), 10, 2 );

 		// Use hooks to prime various caches and improve products page performance.
-		// Until persistent counters reactivated, disable callback for load-edit.php action.
+		add_action( 'load-edit.php', array( $this, 'prime_status_counts_cache' ) );
 		add_filter( 'the_posts', array( $this, 'prime_thumbnail_caches' ), 10, 2 );

 		$cogs_controller              = wc_get_container()->get( CostOfGoodsSoldController::class );
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 564ffa721cf..b5a68b02e37 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -345,6 +345,7 @@ class WC_Install {
 		'11.1.0'   => array(
 			'wc_update_1110_delete_dashboard_outofstock_count_transient',
 			'wc_update_1110_cleanup_block_email_posts',
+			'wc_update_1110_flush_product_count_cache',
 		),
 	);

diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 820dab3caa9..dff8e3a251a 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -3607,3 +3607,16 @@ function wc_update_1110_delete_dashboard_outofstock_count_transient() {
 function wc_update_1110_cleanup_block_email_posts(): bool {
 	return WCEmailPostsCleanup::run();
 }
+
+/**
+ * Flush the persistent product count cache to purge potentially drifted counter values from v11.0-RC1.
+ *
+ * @since 11.1.0
+ *
+ * @return void
+ */
+function wc_update_1110_flush_product_count_cache() {
+	if ( class_exists( \Automattic\WooCommerce\Caches\ProductCountCache::class ) ) {
+		( new \Automattic\WooCommerce\Caches\ProductCountCache() )->flush( 'product' );
+	}
+}
diff --git a/plugins/woocommerce/src/Caches/ProductCountCache.php b/plugins/woocommerce/src/Caches/ProductCountCache.php
index 703acd1bc0f..c0b41587d4e 100644
--- a/plugins/woocommerce/src/Caches/ProductCountCache.php
+++ b/plugins/woocommerce/src/Caches/ProductCountCache.php
@@ -34,7 +34,13 @@ class ProductCountCache {
 	 * @return string[]
 	 */
 	private function get_saved_statuses_for_type( string $product_type ): array {
-		$statuses = wp_cache_get( $this->get_saved_statuses_cache_key( $product_type ) );
+		$cache_key = $this->get_saved_statuses_cache_key( $product_type );
+		$statuses  = wp_cache_get( $cache_key );
+		// Defensive perimeter: purge corrupted cache entries (external cache modification).
+		if ( false !== $statuses && ( ! is_array( $statuses ) || array_filter( $statuses, 'is_string' ) !== $statuses ) ) {
+			$statuses = false;
+			wp_cache_delete( $cache_key );
+		}

 		return is_array( $statuses ) ? $statuses : array();
 	}
@@ -114,24 +120,20 @@ class ProductCountCache {
 	/**
 	 * Set the cache count value for multiple statuses at once.
 	 *
+	 * @deprecated 11.1.0
+	 *
 	 * @param string            $product_type The post type (e.g. 'product', 'product_variation').
 	 * @param array<string,int> $counts       Counts keyed by status slug (e.g. [ 'publish' => 10, 'draft' => 5 ]).
 	 *
 	 * @return array<string,bool>
 	 */
 	public function set_multiple( string $product_type, array $counts ) {
-		if ( empty( $counts ) ) {
-			return array();
-		}
-
-		$this->ensure_statuses_for_type( $product_type, array_keys( $counts ) );
-
-		$mapped_counts = array();
+		$results = array();
 		foreach ( $counts as $status => $count ) {
-			$mapped_counts[ $this->get_cache_key( $product_type, $status ) ] = (int) $count;
+			$results[ $this->get_cache_key( $product_type, (string) $status ) ] = $this->set( $product_type, (string) $status, (int) $count );
 		}

-		return wp_cache_set_multiple( $mapped_counts, '', $this->expiration );
+		return $results;
 	}

 	/**
@@ -160,13 +162,20 @@ class ProductCountCache {

 		$cache_key_prefix = $this->get_cache_key( $product_type, '' );
 		foreach ( $cache_values as $key => $value ) {
+			// Defensive perimeter: purge corrupted cache entries (external cache modification);
+			// As some object caching plugins are pushing integer but pulling string, we use is_numeric here.
+			if ( false !== $value && ! is_numeric( $value ) ) {
+				$value = false;
+				wp_cache_delete( $key );
+			}
+
 			// Return null for the entire cache if any of the requested statuses are not found because they fell out of cache.
 			if ( false === $value ) {
 				return null;
 			}

 			$status                   = substr( $key, strlen( $cache_key_prefix ) );
-			$status_values[ $status ] = $value;
+			$status_values[ $status ] = (int) $value;
 		}

 		return $status_values;
diff --git a/plugins/woocommerce/src/Caches/ProductCountCacheService.php b/plugins/woocommerce/src/Caches/ProductCountCacheService.php
index a1df2cc3dc4..0ba24083660 100644
--- a/plugins/woocommerce/src/Caches/ProductCountCacheService.php
+++ b/plugins/woocommerce/src/Caches/ProductCountCacheService.php
@@ -55,13 +55,16 @@ class ProductCountCacheService {
 	final public function init(): void {
 		$this->product_count_cache = new ProductCountCache();

-		add_action( 'action_scheduler_ensure_recurring_actions', array( $this, 'unschedule_background_actions' ) );
+		add_action( 'action_scheduler_ensure_recurring_actions', array( $this, 'schedule_background_actions' ) );
 		add_action( self::BACKGROUND_EVENT_HOOK, array( $this, 'prime_cache_if_cold' ) );
 		if ( defined( 'WC_PLUGIN_BASENAME' ) ) {
 			add_action( 'deactivate_' . WC_PLUGIN_BASENAME, array( $this, 'unschedule_background_actions' ) );
 		}

-		// Until persistent counters reactivated, disable callbacks for woocommerce_new_product, transition_post_status, before_delete_post hooks.
+		// transition_post_status owns all mid-lifecycle status changes; woocommerce_new_product corrects for creation-time
+		// ephemeral transitions before the final status is committed; before_delete_post closes the lifecycle.
+		add_action( 'transition_post_status', array( $this, 'update_on_product_status_changed' ), 10, 3 );
+		add_action( 'before_delete_post', array( $this, 'update_on_product_deleted' ), 10, 2 );
 	}

 	/**
@@ -71,7 +74,11 @@ class ProductCountCacheService {
 	 * @return void
 	 */
 	public function prime_cache_if_cold( string $product_type = 'product' ): void {
-		// Until persistent counters reactivated, this task is no-op.
+		// Cache warm-up is only effective when an object cache plugin is active, and the cache entry is missing.
+		if ( wp_using_ext_object_cache() && null === $this->product_count_cache->get( $product_type ) ) {
+			$this->product_count_cache->flush( $product_type );
+			wc_get_container()->get( ProductUtil::class )->get_counts_for_type( $product_type );
+		}
 	}

 	/**
@@ -97,26 +104,16 @@ class ProductCountCacheService {
 	/**
 	 * Update the cache when a new product is created.
 	 *
+	 * @deprecated 11.1
+	 *
 	 * @param int        $product_id Product ID.
 	 * @param WC_Product $product    The product.
 	 * @return void
 	 */
 	public function update_on_new_product( int $product_id, WC_Product $product ): void {
-		// transition_post_status already counted this product — reverse any errant decrement from a cold step 1 and stop.
-		// In-memory status may diverge from DB after a mid-creation wp_update_post; do not increment here.
-		if ( isset( $this->product_statuses[ $product_id ] ) ) {
-			$this->maybe_restore_initial_status_count( $product_id );
-			unset( $this->products_in_creation[ $product_id ] );
-			return;
-		}
-
-		// Cache was cold throughout creation — transition_post_status never fired; use in-memory status as the sole count.
-		$product_status = $product->get_status();
-		if ( $this->product_count_cache->is_cached( 'product', $product_status ) ) {
-			$this->product_statuses[ $product_id ] = $product_status;
-			$this->product_count_cache->increment( 'product', $product_status );
-		}
-		unset( $this->products_in_creation[ $product_id ] );
+		// This was implemented to address a potential concurrency issue, but upon review, it was determined to be a false positive.
+		// - Specifically, cache warming by a concurrent request between transition_post_status and woocommerce_new_product was considered.
+		// - However, this scenario is a false positive because it occurs within the same PHP process, where hooks are executed sequentially.
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
index 1d9acc2a517..c5d5b8633f7 100644
--- a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
@@ -144,15 +144,27 @@ class ProductUtil {
 	}

 	/**
-	 * Counts per-status number of products.
+	 * Counts per-status number of products of a given post type.
 	 *
 	 * @since 11.0.0
 	 *
 	 * @param string $post_type Post type (e.g. 'product', 'product_variation').
-	 * @return array<string,int>
+	 * @return array<string|int,int>
 	 */
 	public function get_counts_for_type( string $post_type ): array {
-		// Until persistent counters reactivated, switch back to WordPress API for accessing post status counters.
-		return array_map( 'intval', (array) wp_count_posts( $post_type ) );
+		$product_count_cache = wc_get_container()->get( ProductCountCache::class );
+		$count_per_status    = $product_count_cache->get( $post_type );
+
+		if ( null === $count_per_status ) {
+			// Defensive perimeter: dirty product data (running/crushed product data import/migration/mocking utilities).
+			// We passed on adding wc_doing_it_wrong as it'll add assymetric friction to already running customers fleet.
+			// Enforce strict typing to prevent PHP from silently casting numeric-string array keys to integers, and to normalize inconsistent types from WordPress APIs.
+			$count_per_status = (array) wp_count_posts( $post_type ) + array_fill_keys( array_keys( get_post_stati() ), 0 );
+			foreach ( $count_per_status as $status => $count ) {
+				$product_count_cache->set( $post_type, (string) $status, (int) $count );
+			}
+		}
+
+		return array_map( 'intval', $count_per_status );
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheServiceTest.php b/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheServiceTest.php
index fb1bbbd1ee0..85aeb04d176 100644
--- a/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheServiceTest.php
+++ b/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheServiceTest.php
@@ -187,8 +187,6 @@ final class ProductCountCacheServiceTest extends \WC_Unit_Test_Case {
 	 * @testdox Source status count is decremented correctly when only the source slot is warm and the destination slot is cold.
 	 */
 	public function test_count_decremented_when_only_source_status_is_cached(): void {
-		$this->markTestSkipped( 'Until persistent counters reactivated, skip this test.' );
-
 		$product = WC_Helper_Product::create_simple_product();
 		$product->set_status( ProductStatus::DRAFT );
 		$product->save();
@@ -213,8 +211,6 @@ final class ProductCountCacheServiceTest extends \WC_Unit_Test_Case {
 	 * @testdox Final status is not double-incremented when a plugin permanently changes product status during creation.
 	 */
 	public function test_count_not_double_incremented_on_new_product_with_mid_creation_status_change(): void {
-		$this->markTestSkipped( 'Until persistent counters reactivated, skip this test.' );
-
 		// Warm all status slots and record the publish count before the test.
 		$this->product_util->get_counts_for_type( 'product' );
 		$publish_before = $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) )[ ProductStatus::PUBLISH ];
@@ -246,8 +242,6 @@ final class ProductCountCacheServiceTest extends \WC_Unit_Test_Case {
 	 * @testdox Source status count is not corrupted when a plugin permanently changes product status during creation.
 	 */
 	public function test_source_count_not_corrupted_on_new_product_with_mid_creation_status_change(): void {
-		$this->markTestSkipped( 'Until persistent counters reactivated, skip this test.' );
-
 		// Warm all status slots and record both counts before the test.
 		$this->product_util->get_counts_for_type( 'product' );
 		$draft_before   = $this->product_cache->get( 'product', array( ProductStatus::DRAFT ) )[ ProductStatus::DRAFT ];
@@ -291,8 +285,6 @@ final class ProductCountCacheServiceTest extends \WC_Unit_Test_Case {
 	 * @testdox Cache is populated when it is cold and an external object cache is active.
 	 */
 	public function test_prime_cache_if_cold_when_cache_is_cold(): void {
-		$this->markTestSkipped( 'Until persistent counters reactivated, skip this test.' );
-
 		global $_wp_using_ext_object_cache;
 		$_before                    = $_wp_using_ext_object_cache;
 		$_wp_using_ext_object_cache = true; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
diff --git a/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheTest.php b/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheTest.php
index 7761c717018..cc8679553ee 100644
--- a/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheTest.php
+++ b/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheTest.php
@@ -93,4 +93,66 @@ final class ProductCountCacheTest extends \WC_Unit_Test_Case {
 			$this->assertSame( 5, $cached[ $status ] );
 		}
 	}
+
+	/**
+	 * Data provider for corrupted statuses values scenarios.
+	 *
+	 * @return array
+	 */
+	public function provider_corrupted_statuses_values(): array {
+		return array(
+			'integers mixed with strings' => array( array( 'publish', 0, 'draft', 42 ) ),
+			'all integers'                => array( array( 0, 1, 2 ) ),
+			'non-array string'            => array( 'not-an-array' ),
+			'non-array integer'           => array( 123 ),
+		);
+	}
+
+	/**
+	 * @testdox Corrupted saved statuses cache is purged and get() falls back to null.
+	 * @dataProvider provider_corrupted_statuses_values
+	 *
+	 * @param mixed $corrupted_value The corrupted value to inject into the statuses cache.
+	 */
+	public function test_corrupted_statuses_cache_is_purged( $corrupted_value ): void {
+		$this->product_cache->set( 'product', ProductStatus::PUBLISH, 5 );
+		$this->assertNotNull( $this->product_cache->get( 'product' ) );
+
+		wp_cache_set( 'product-count_product_statuses', $corrupted_value );
+
+		$this->assertNull( $this->product_cache->get( 'product' ) );
+		$this->assertFalse( wp_cache_get( 'product-count_product_statuses' ) );
+	}
+
+	/**
+	 * Data provider for corrupted count value scenarios.
+	 *
+	 * @return array
+	 */
+	public function provider_corrupted_count_values(): array {
+		return array(
+			'string value'  => array( 'not-an-integer' ),
+			'boolean value' => array( true ),
+			'array value'   => array( array( 1 ) ),
+		);
+	}
+
+	/**
+	 * @testdox Corrupted count value is purged and get() falls back to null.
+	 * @dataProvider provider_corrupted_count_values
+	 *
+	 * @param mixed $corrupted_value The corrupted value to inject into a count cache slot.
+	 */
+	public function test_corrupted_count_value_is_purged( $corrupted_value ): void {
+		$this->product_cache->set( 'product', ProductStatus::PUBLISH, 5 );
+		$this->product_cache->set( 'product', ProductStatus::DRAFT, 10 );
+
+		wp_cache_set( 'product-count_product_publish', $corrupted_value );
+
+		$result = $this->product_cache->get( 'product', array( ProductStatus::PUBLISH, ProductStatus::DRAFT ) );
+		$this->assertNull( $result );
+
+		$this->assertFalse( wp_cache_get( 'product-count_product_publish' ) );
+		$this->assertSame( 10, wp_cache_get( 'product-count_product_draft' ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
index 9fa416a2dbc..c1f838aeebd 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Automattic\WooCommerce\Tests\Internal\Utilities;

 use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Caches\ProductCountCache;
 use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;

@@ -33,6 +34,51 @@ class ProductUtilTest extends \WC_Unit_Test_Case {
 		$pending->delete( true );
 	}

+	/**
+	 * Data provider for injected status types that PHP may coerce when used as array keys.
+	 *
+	 * @return array
+	 */
+	public function provider_injected_status_types(): array {
+		return array(
+			'integer status (42)'   => array( 42, 7 ),
+			'string status (42)'    => array( '42', 7 ),
+			'string status (valid)' => array( 'custom-status', 25 ),
+		);
+	}
+
+	/**
+	 * @testdox get_counts_for_type normalizes injected status keys to strings and populates cache with integer values.
+	 * @dataProvider provider_injected_status_types
+	 *
+	 * @param mixed $injected_status The status key to inject via wp_count_posts filter.
+	 * @param int   $injected_count  The count value for the injected status.
+	 */
+	public function test_get_counts_for_type_normalizes_and_caches_injected_status( $injected_status, int $injected_count ): void {
+		$cache = wc_get_container()->get( ProductCountCache::class );
+		$cache->flush( 'product' );
+
+		$filter = function ( $counts ) use ( $injected_status, $injected_count ) {
+			$counts->{$injected_status} = $injected_count;
+			return $counts;
+		};
+		add_filter( 'wp_count_posts', $filter, 10, 1 );
+		wp_cache_delete( 'posts-product', 'counts' );
+
+		try {
+			$result = wc_get_container()->get( ProductUtil::class )->get_counts_for_type( 'product' );
+		} finally {
+			remove_filter( 'wp_count_posts', $filter, 10 );
+		}
+
+		// Second call without the filter — if the injected status is present, it came from cache.
+		$cached = wc_get_container()->get( ProductUtil::class )->get_counts_for_type( 'product' );
+
+		$this->assertSame( $result, $cached );
+		$this->assertSame( $injected_count, $cached[ $injected_status ] );
+		$this->assertSame( $injected_count, $cached[ (string) $injected_status ] );
+	}
+
 	/**
 	 * @testdox delete_product_transients_for_products deletes fixed-name transients once and fires hooks once per product.
 	 */