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.
*/