Commit 7274c56fbac for woocommerce
commit 7274c56fbac0cb40adb454444bc279078954f7ce
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date: Tue Jun 30 07:07:28 2026 +0200
[Performance] Introduce persistent product status counters (#65957)
Migrates the admin products page and \Automattic\WooCommerce\Internal\Utilities\ProductUtil::get_counts_for_type onto persistent counters components. With persistent object caching, this eliminates "SELECT post_status, COUNT(*) AS num_posts FROM wp_posts WHERE post_type = 'product' GROUP BY post_status" SQL, which causes degrading admin performance for stores with larger product catalogs.
diff --git a/plugins/woocommerce/changelog/performance-product-status-counters-components b/plugins/woocommerce/changelog/performance-product-status-counters-components
new file mode 100644
index 00000000000..5123b2dbe0f
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-product-status-counters-components
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Update the admin to use persistent product status counters to improve performance with large product catalogs.
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 26aaab999c3..a54924fc5d2 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
@@ -8,6 +8,7 @@
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
if ( ! defined( 'ABSPATH' ) ) {
exit;
@@ -57,14 +58,47 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
add_filter( 'views_edit-product', array( $this, 'product_views' ) );
add_filter( 'get_search_query', array( $this, 'search_label' ) );
add_filter( 'posts_clauses', array( $this, 'posts_clauses' ), 10, 2 );
- add_filter( 'the_posts', array( $this, 'prime_thumbnail_caches' ), 10, 2 );
add_action( 'manage_product_posts_custom_column', array( $this, 'add_sample_product_badge' ), 9, 2 );
+ // Use hooks to prime various caches and improve products page performance.
+ 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 );
$this->cogs_is_enabled = $cogs_controller->feature_is_enabled();
$this->use_cogs_lookup_column = $this->cogs_is_enabled && $cogs_controller->product_meta_lookup_table_cogs_value_columns_exist();
}
+ /**
+ * Pre-warm the wp_count_posts cache before the list table renders.
+ *
+ * @internal
+ * @since 11.0.0
+ *
+ * @return void
+ */
+ public function prime_status_counts_cache(): void {
+ $screen = get_current_screen();
+ if ( ! $screen || 'edit-product' !== $screen->id ) {
+ return;
+ }
+
+ // Performance note: the current listings architecture prevents us from isolating wp_count_posts calls.
+ // In the context of the products page, we can still isolate the underlying SQL by warming up the wp_count_posts cache.
+ $cache = (object) array_map(
+ static fn ( $count ) => $count ? (string) $count : (int) $count,
+ wc_get_container()->get( ProductUtil::class )->get_counts_for_type( 'product' )
+ );
+ // Trade-off: private-status tally may read slightly high for restricted roles (other users' privates included) — non-critical.
+ wp_cache_set_multiple(
+ array(
+ 'posts-product' => $cache,
+ 'posts-product_readable_' . get_current_user_id() => $cache,
+ ),
+ 'counts'
+ );
+ }
+
/**
* Prime featured image caches for product queries to avoid individual queries during rendering.
*
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index b7e140d9a26..fbfb1c68341 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -38,6 +38,7 @@ use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\{LoggingUtil, TimeUtil};
use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
use Automattic\WooCommerce\Caches\OrderCountCacheService;
+use Automattic\WooCommerce\Caches\ProductCountCacheService;
use Automattic\WooCommerce\Internal\Caches\ProductVersionStringInvalidator;
use Automattic\WooCommerce\Internal\Caches\OrdersVersionStringInvalidator;
use Automattic\WooCommerce\Internal\Caches\TaxRateVersionStringInvalidator;
@@ -375,6 +376,7 @@ final class WooCommerce {
$container->get( ComingSoonCacheInvalidator::class );
$container->get( ComingSoonRequestHandler::class );
$container->get( OrderCountCacheService::class );
+ $container->get( ProductCountCacheService::class );
$container->get( EmailImprovements::class );
$container->get( DeferredEmailQueue::class );
$container->get( AddressProviderController::class );
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 73c8d0183a0..67e04a60a64 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -55417,54 +55417,12 @@ parameters:
count: 1
path: src/Caches/OrderCacheController.php
- -
- message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCache\:\:decrement\(\) should return int but returns int\|false\.$#'
- identifier: return.type
- count: 1
- path: src/Caches/OrderCountCache.php
-
- -
- message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCache\:\:increment\(\) should return int but returns int\|false\.$#'
- identifier: return.type
- count: 1
- path: src/Caches/OrderCountCache.php
-
-
message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCacheService\:\:init\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: src/Caches/OrderCountCacheService.php
- -
- message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCacheService\:\:unschedule_background_actions\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: src/Caches/OrderCountCacheService.php
-
- -
- message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCacheService\:\:update_on_new_order\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: src/Caches/OrderCountCacheService.php
-
- -
- message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCacheService\:\:update_on_order_deleted\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: src/Caches/OrderCountCacheService.php
-
- -
- message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCacheService\:\:update_on_order_status_changed\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: src/Caches/OrderCountCacheService.php
-
- -
- message: '#^Method Automattic\\WooCommerce\\Caches\\OrderCountCacheService\:\:update_on_order_trashed\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: src/Caches/OrderCountCacheService.php
-
-
message: '#^Method Automattic\\WooCommerce\\Caching\\ObjectCache\:\:get_id_from_object_if_null\(\) never returns null so it can be removed from the return type\.$#'
identifier: return.unusedType
diff --git a/plugins/woocommerce/src/Caches/OrderCountCache.php b/plugins/woocommerce/src/Caches/OrderCountCache.php
index 23209e205a6..f8cfe41c116 100644
--- a/plugins/woocommerce/src/Caches/OrderCountCache.php
+++ b/plugins/woocommerce/src/Caches/OrderCountCache.php
@@ -4,10 +4,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Caches;
-use Automattic\WooCommerce\Caching\ObjectCache;
use Automattic\WooCommerce\Enums\OrderStatus;
-use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
-use Automattic\WooCommerce\Utilities\OrderUtil;
/**
* A class to cache counts for various order statuses.
@@ -89,6 +86,7 @@ class OrderCountCache {
*
* @param string $order_type The type of order.
* @param string $order_status The status of the order.
+ *
* @return string The cache key.
*/
private function get_cache_key( $order_type, $order_status ) {
@@ -96,7 +94,7 @@ class OrderCountCache {
}
/**
- * Get the cache key saved statuses of the given order type.
+ * Get the cache key for saved statuses of the given order type.
*
* @param string $order_type The type of order.
*
@@ -111,6 +109,7 @@ class OrderCountCache {
*
* @param string $order_type The type of order.
* @param string $order_status The status of the order.
+ *
* @return bool True if the cache has a value, false otherwise.
*/
public function is_cached( $order_type, $order_status ) {
@@ -124,6 +123,7 @@ class OrderCountCache {
* @param string $order_type The type of order.
* @param string $order_status The status slug of the order.
* @param int $value The value to set.
+ *
* @return bool True if the value was set, false otherwise.
*/
public function set( $order_type, $order_status, int $value ): bool {
@@ -136,11 +136,10 @@ class OrderCountCache {
/**
* Set the cache count value for multiple statuses at once.
*
- * @param string $order_type The order type being set.
- * @param array $counts Normalized counts keyed by status slug
- * (e.g. [ 'wc-processing' => 10, 'wc-pending' => 5 ]).
+ * @param string $order_type The order type being set.
+ * @param array<string,int> $counts Normalized counts keyed by status slug (e.g. [ 'wc-processing' => 10, 'wc-pending' => 5 ]).
*
- * @return array|bool[] Success map from wp_cache_set_multiple().
+ * @return array<string,bool> Success map from wp_cache_set_multiple().
*/
public function set_multiple( string $order_type, array $counts ) {
if ( empty( $counts ) ) {
@@ -162,8 +161,9 @@ class OrderCountCache {
* Get the cache value for a given order type and set of statuses.
*
* @param string $order_type The type of order.
- * @param string[] $order_statuses The statuses of the order.
- * @return null|array<string, int> The cache value.
+ * @param string[] $order_statuses The statuses to retrieve.
+ *
+ * @return array<string,int>|null The cache value.
*/
public function get( $order_type, $order_statuses = array() ) {
$order_type = (string) $order_type;
@@ -174,33 +174,36 @@ class OrderCountCache {
}
}
- $cache_keys = array_map( function( $order_statuses ) use ( $order_type ) {
- return $this->get_cache_key( $order_type, $order_statuses );
- }, $order_statuses );
+ $cache_keys = array_map(
+ fn( $order_statuses ) => $this->get_cache_key( $order_type, $order_statuses ),
+ $order_statuses
+ );
$cache_values = wp_cache_get_multiple( $cache_keys );
$status_values = array();
+ $cache_key_prefix = $this->get_cache_key( $order_type, '' );
foreach ( $cache_values as $key => $value ) {
// Return null for the entire cache if any of the requested statuses are not found because they fell out of cache.
- if ( $value === false ) {
+ if ( false === $value ) {
return null;
}
- $order_status = str_replace( $this->get_cache_key( $order_type, '' ), '', $key );
- $status_values[ $order_status ] = $value;
+ $status = substr( $key, strlen( $cache_key_prefix ) );
+ $status_values[ $status ] = $value;
}
return $status_values;
}
/**
- * Increment the cache value for a given order status.
+ * Increment the cache value for a given order type and status.
*
- * @param string $order_type The type of order.
+ * @param string $order_type The type of order.
* @param string $order_status The status of the order.
- * @param int $offset The amount to increment by.
- * @return int The new value of the cache.
+ * @param int $offset The amount to increment by.
+ *
+ * @return int|false The new value of the cache.
*/
public function increment( $order_type, $order_status, $offset = 1 ) {
$cache_key = $this->get_cache_key( $order_type, $order_status );
@@ -208,12 +211,13 @@ class OrderCountCache {
}
/**
- * Decrement the cache value for a given order status.
+ * Decrement the cache value for a given order type and status.
*
- * @param string $order_type The type of order.
+ * @param string $order_type The type of order.
* @param string $order_status The status of the order.
- * @param int $offset The amount to decrement by.
- * @return int The new value of the cache.
+ * @param int $offset The amount to decrement by.
+ *
+ * @return int|false The new value of the cache.
*/
public function decrement( $order_type, $order_status, $offset = 1 ) {
$cache_key = $this->get_cache_key( $order_type, $order_status );
@@ -223,8 +227,9 @@ class OrderCountCache {
/**
* Flush the cache for a given order type and statuses.
*
- * @param string $order_type The type of order.
- * @param string[] $order_statuses The statuses of the order.
+ * @param string $order_type The type of order.
+ * @param string[] $order_statuses The statuses to flush. Flushes all known statuses if empty.
+ *
* @return void
*/
public function flush( $order_type = 'shop_order', $order_statuses = array() ) {
@@ -235,9 +240,10 @@ class OrderCountCache {
$flush_saved_statuses = true;
}
- $cache_keys = array_map( function( $order_statuses ) use ( $order_type ) {
- return $this->get_cache_key( $order_type, $order_statuses );
- }, $order_statuses );
+ $cache_keys = array_map(
+ fn( $order_statuses ) => $this->get_cache_key( $order_type, $order_statuses ),
+ $order_statuses
+ );
if ( $flush_saved_statuses ) {
// If all statuses are being flushed, go ahead and flush the status list so any permanently removed statuses are cleared out.
diff --git a/plugins/woocommerce/src/Caches/OrderCountCacheService.php b/plugins/woocommerce/src/Caches/OrderCountCacheService.php
index 08786b6e8ad..bcf6f787d16 100644
--- a/plugins/woocommerce/src/Caches/OrderCountCacheService.php
+++ b/plugins/woocommerce/src/Caches/OrderCountCacheService.php
@@ -15,7 +15,7 @@ use Automattic\WooCommerce\Utilities\OrderUtil;
*/
class OrderCountCacheService {
- const BACKGROUND_EVENT_HOOK = 'woocommerce_refresh_order_count_cache';
+ public const BACKGROUND_EVENT_HOOK = 'woocommerce_refresh_order_count_cache';
/**
* OrderCountCache instance.
@@ -26,15 +26,17 @@ class OrderCountCacheService {
/**
* Array of order ids with their last transitioned status as key value pairs.
+ * Guarantees idempotency for order status transitions when multiple hooks fire for the same order.
*
- * @var array
+ * @var array<int,string>
*/
private $order_statuses = array();
/**
* Array of order ids with their initial status as key value pairs.
+ * Guarantees idempotency for order status transitions when multiple hooks fire for the same order.
*
- * @var array
+ * @var array<int,string>
*/
private $initial_order_statuses = array();
@@ -64,6 +66,7 @@ class OrderCountCacheService {
* @deprecated 10.7.0 Was used for handling `woocommerce_refresh_order_count_cache` actions.
*
* @param string $order_type The order type.
+ *
* @return void
*/
public function refresh_cache( $order_type ) {
@@ -79,6 +82,7 @@ class OrderCountCacheService {
* @since 10.7.0
*
* @param string $order_type The order type.
+ *
* @return void
*/
public function prime_cache_if_cold( $order_type ) {
@@ -108,6 +112,8 @@ class OrderCountCacheService {
*
* @since 10.0.0
* @internal
+ *
+ * @return void
*/
public function unschedule_background_actions() {
WC()->queue()->cancel_all( self::BACKGROUND_EVENT_HOOK );
@@ -117,7 +123,9 @@ class OrderCountCacheService {
* Update the cache when a new order is made.
*
* @param int $order_id Order id.
- * @param WC_Order $order The order.
+ * @param WC_Order $order The order.
+ *
+ * @return void
*/
public function update_on_new_order( $order_id, $order ) {
$order_type = $order->get_type();
@@ -146,7 +154,9 @@ class OrderCountCacheService {
* Update the cache when an order is trashed.
*
* @param int $order_id Order id.
- * @param WC_Order $order The order.
+ * @param WC_Order $order The order.
+ *
+ * @return void
*/
public function update_on_order_trashed( $order_id, $order ) {
$order_type = $order->get_type();
@@ -163,10 +173,12 @@ class OrderCountCacheService {
}
/**
- * Update the cache when an order is deleted.
+ * Update the cache when an order is permanently deleted.
*
* @param int $order_id Order id.
- * @param WC_Order $order The order.
+ * @param WC_Order $order The order.
+ *
+ * @return void
*/
public function update_on_order_deleted( $order_id, $order ) {
$order_type = $order->get_type();
@@ -180,12 +192,14 @@ class OrderCountCacheService {
}
/**
- * Update the cache whenver an order status changes.
+ * Update the cache whenever an order status changes.
*
- * @param int $order_id Order id.
- * @param string $previous_status the old WooCommerce order status.
- * @param string $next_status the new WooCommerce order status.
- * @param WC_Order $order The order.
+ * @param int $order_id Order id.
+ * @param string $previous_status The old WooCommerce order status.
+ * @param string $next_status The new WooCommerce order status.
+ * @param WC_Order $order The order.
+ *
+ * @return void
*/
public function update_on_order_status_changed( $order_id, $previous_status, $next_status, $order ) {
$order_type = $order->get_type();
@@ -203,7 +217,7 @@ class OrderCountCacheService {
}
$this->order_statuses[ $order_id ] = $next_status;
- $was_decremented = $this->order_count_cache->decrement( $order_type, $this->get_prefixed_status( $previous_status ) );
+ $was_decremented = false !== $this->order_count_cache->decrement( $order_type, $this->get_prefixed_status( $previous_status ) );
$this->order_count_cache->increment( $order_type, $this->get_prefixed_status( $next_status ) );
// Set the initial order status in case this is a new order and the previous status should not be decremented.
diff --git a/plugins/woocommerce/src/Caches/ProductCountCache.php b/plugins/woocommerce/src/Caches/ProductCountCache.php
new file mode 100644
index 00000000000..703acd1bc0f
--- /dev/null
+++ b/plugins/woocommerce/src/Caches/ProductCountCache.php
@@ -0,0 +1,228 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Caches;
+
+/**
+ * A class to cache counts for various product statuses.
+ */
+class ProductCountCache {
+
+ /**
+ * Cache prefix.
+ *
+ * @var string
+ */
+ private string $cache_prefix = 'product-count';
+
+ /**
+ * Default value for the duration of the objects in the cache, in seconds
+ * (may not be used depending on the cache engine used WordPress cache implementation).
+ *
+ * @var int
+ */
+ protected int $expiration = DAY_IN_SECONDS;
+
+ /**
+ * Retrieves the list of known statuses by product type. A cached array of statuses is saved per product type for
+ * improved backward compatibility with some of the extensions that don't register all statuses they use with
+ * WooCommerce.
+ *
+ * @param string $product_type The post type (e.g. 'product', 'product_variation').
+ *
+ * @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 ) );
+
+ return is_array( $statuses ) ? $statuses : array();
+ }
+
+ /**
+ * Adds the given statuses to the cached statuses array for the product type if they are not already stored.
+ *
+ * @param string $product_type The post type (e.g. 'product', 'product_variation').
+ * @param string[] $product_statuses One or more statuses to add.
+ *
+ * @return void
+ */
+ private function ensure_statuses_for_type( string $product_type, array $product_statuses ): void {
+ if ( empty( $product_statuses ) ) {
+ return;
+ }
+
+ $existing = $this->get_saved_statuses_for_type( $product_type );
+ $new_statuses = array_diff( $product_statuses, $existing );
+ if ( empty( $new_statuses ) ) {
+ return;
+ }
+
+ $merged = array_unique( array_merge( $existing, $new_statuses ) );
+ wp_cache_set( $this->get_saved_statuses_cache_key( $product_type ), $merged, '', $this->expiration );
+ }
+
+ /**
+ * Get the cache key for a given product type and status.
+ *
+ * @param string $product_type The post type (e.g. 'product', 'product_variation').
+ * @param string $product_status The status of the product.
+ *
+ * @return string
+ */
+ private function get_cache_key( string $product_type, string $product_status ): string {
+ return $this->cache_prefix . '_' . $product_type . '_' . $product_status;
+ }
+
+ /**
+ * Get the cache key for saved statuses of the given product type.
+ *
+ * @param string $product_type The post type (e.g. 'product', 'product_variation').
+ *
+ * @return string
+ */
+ private function get_saved_statuses_cache_key( string $product_type ): string {
+ return $this->cache_prefix . '_' . $product_type . '_statuses';
+ }
+
+ /**
+ * Check if the cache has a value for a given product type and status.
+ *
+ * @param string $product_type The post type (e.g. 'product', 'product_variation').
+ * @param string $product_status The status of the product.
+ *
+ * @return bool
+ */
+ public function is_cached( string $product_type, string $product_status ): bool {
+ return false !== wp_cache_get( $this->get_cache_key( $product_type, $product_status ) );
+ }
+
+ /**
+ * Set the cache value for a given product type and status.
+ *
+ * @param string $product_type The post type (e.g. 'product', 'product_variation').
+ * @param string $product_status The status slug of the product.
+ * @param int $value The value to set.
+ *
+ * @return bool
+ */
+ public function set( string $product_type, string $product_status, int $value ): bool {
+ $this->ensure_statuses_for_type( $product_type, array( $product_status ) );
+ return wp_cache_set( $this->get_cache_key( $product_type, $product_status ), $value, '', $this->expiration );
+ }
+
+ /**
+ * Set the cache count value for multiple statuses at once.
+ *
+ * @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();
+ foreach ( $counts as $status => $count ) {
+ $mapped_counts[ $this->get_cache_key( $product_type, $status ) ] = (int) $count;
+ }
+
+ return wp_cache_set_multiple( $mapped_counts, '', $this->expiration );
+ }
+
+ /**
+ * Get the cache value for a given product type and set of statuses.
+ *
+ * @param string $product_type The post type (e.g. 'product', 'product_variation').
+ * @param string[] $product_statuses The statuses to retrieve.
+ *
+ * @return array<string,int>|null
+ */
+ public function get( string $product_type, array $product_statuses = array() ) {
+ if ( empty( $product_statuses ) ) {
+ $product_statuses = $this->get_saved_statuses_for_type( $product_type );
+ if ( empty( $product_statuses ) ) {
+ return null;
+ }
+ }
+
+ $cache_keys = array_map(
+ fn( $product_status ) => $this->get_cache_key( $product_type, $product_status ),
+ $product_statuses
+ );
+
+ $cache_values = wp_cache_get_multiple( $cache_keys );
+ $status_values = array();
+
+ $cache_key_prefix = $this->get_cache_key( $product_type, '' );
+ foreach ( $cache_values as $key => $value ) {
+ // 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;
+ }
+
+ return $status_values;
+ }
+
+ /**
+ * Increment the cache value for a given product type and status.
+ *
+ * @param string $product_type The post type (e.g. 'product', 'product_variation').
+ * @param string $product_status The status of the product.
+ * @param int $offset The amount to increment by.
+ *
+ * @return int|false
+ */
+ public function increment( string $product_type, string $product_status, int $offset = 1 ) {
+ return wp_cache_incr( $this->get_cache_key( $product_type, $product_status ), $offset );
+ }
+
+ /**
+ * Decrement the cache value for a given product type and status.
+ *
+ * @param string $product_type The post type (e.g. 'product', 'product_variation').
+ * @param string $product_status The status of the product.
+ * @param int $offset The amount to decrement by.
+ *
+ * @return int|false
+ */
+ public function decrement( string $product_type, string $product_status, int $offset = 1 ) {
+ return wp_cache_decr( $this->get_cache_key( $product_type, $product_status ), $offset );
+ }
+
+ /**
+ * Flush the cache for a given product type and statuses.
+ *
+ * @param string $product_type The post type (e.g. 'product', 'product_variation').
+ * @param string[] $product_statuses The statuses to flush. Flushes all known statuses if empty.
+ *
+ * @return void
+ */
+ public function flush( string $product_type = 'product', array $product_statuses = array() ): void {
+ $flush_saved_statuses = false;
+ if ( empty( $product_statuses ) ) {
+ $product_statuses = $this->get_saved_statuses_for_type( $product_type );
+ $flush_saved_statuses = true;
+ }
+
+ $cache_keys = array_map(
+ fn ( $product_status ) => $this->get_cache_key( $product_type, $product_status ),
+ $product_statuses
+ );
+
+ if ( $flush_saved_statuses ) {
+ // If all statuses are being flushed, go ahead and flush the status list so any permanently removed statuses are cleared out.
+ $cache_keys[] = $this->get_saved_statuses_cache_key( $product_type );
+ }
+
+ wp_cache_delete_multiple( $cache_keys );
+ }
+}
diff --git a/plugins/woocommerce/src/Caches/ProductCountCacheService.php b/plugins/woocommerce/src/Caches/ProductCountCacheService.php
new file mode 100644
index 00000000000..2b673dbd21b
--- /dev/null
+++ b/plugins/woocommerce/src/Caches/ProductCountCacheService.php
@@ -0,0 +1,216 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Caches;
+
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
+use WC_Product;
+use WP_Post;
+
+/**
+ * A service class to help with updates to the aggregate product counts cache.
+ *
+ * @internal
+ */
+class ProductCountCacheService {
+
+ public const BACKGROUND_EVENT_HOOK = 'woocommerce_refresh_product_count_cache';
+
+ /**
+ * ProductCountCache instance.
+ *
+ * @var ProductCountCache
+ */
+ private ProductCountCache $product_count_cache;
+
+ /**
+ * Array of product IDs with their last transitioned status as key value pairs.
+ * Guarantees idempotency for product status transitions when multiple hooks fire for the same product.
+ *
+ * @var array<int,string>
+ */
+ private array $product_statuses = array();
+
+ /**
+ * Array of product IDs with their initial status as key value pairs.
+ * Guarantees idempotency for product status transitions when multiple hooks fire for the same product.
+ *
+ * @var array<int,string>
+ */
+ private array $initial_product_statuses = array();
+
+ /**
+ * Set of product IDs currently being created in this request (keyed by ID; detected via old_status='new').
+ *
+ * @var array<int,true>
+ */
+ private array $products_in_creation = array();
+
+ /**
+ * Class initialization, invoked by the DI container.
+ *
+ * @internal
+ */
+ final public function init(): void {
+ $this->product_count_cache = new ProductCountCache();
+
+ 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' ) );
+ }
+
+ // 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( 'woocommerce_new_product', array( $this, 'update_on_new_product' ), 10, 2 );
+ 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 );
+ }
+
+ /**
+ * Primes the product count cache for a given post type when it is cold.
+ *
+ * @param string $product_type The product post type.
+ * @return void
+ */
+ public function prime_cache_if_cold( string $product_type = 'product' ): void {
+ // 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 );
+ }
+ }
+
+ /**
+ * Register background caching for each product type.
+ *
+ * @return void
+ */
+ public function schedule_background_actions(): void {
+ $frequency = HOUR_IN_SECONDS * 12;
+ $timestamp = time() + $frequency;
+ as_schedule_recurring_action( $timestamp, $frequency, self::BACKGROUND_EVENT_HOOK, array( 'product' ), 'count', true );
+ }
+
+ /**
+ * Unschedules background actions.
+ *
+ * @return void
+ */
+ public function unschedule_background_actions(): void {
+ WC()->queue()->cancel_all( self::BACKGROUND_EVENT_HOOK );
+ }
+
+ /**
+ * Update the cache when a new product is created.
+ *
+ * @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 ] );
+ }
+
+ /**
+ * Update the cache whenever a product status changes.
+ *
+ * @param string $new_status The new post status.
+ * @param string $old_status The previous post status.
+ * @param WP_Post $post The post object.
+ *
+ * @return void
+ */
+ public function update_on_product_status_changed( string $new_status, string $old_status, WP_Post $post ): void {
+ if ( 'product' !== $post->post_type ) {
+ return;
+ }
+
+ $product_id = $post->ID;
+
+ // WordPress uses 'new' as old_status exclusively on the first transition_post_status of a newly inserted post.
+ if ( 'new' === $old_status ) {
+ $this->products_in_creation[ $product_id ] = true;
+ }
+
+ $is_new_cached = $this->product_count_cache->is_cached( 'product', $new_status );
+ $is_old_cached = $this->product_count_cache->is_cached( 'product', $old_status );
+ if ( ! $is_new_cached && ! $is_old_cached ) {
+ return;
+ }
+
+ // If the status count has already been incremented for this product, skip.
+ if ( ( $this->product_statuses[ $product_id ] ?? null ) === $new_status ) {
+ return;
+ }
+
+ $previously_tracked = isset( $this->product_statuses[ $product_id ] );
+ $this->product_statuses[ $product_id ] = $new_status;
+ $was_decremented = $is_old_cached && false !== $this->product_count_cache->decrement( 'product', $old_status );
+ if ( $is_new_cached ) {
+ $this->product_count_cache->increment( 'product', $new_status );
+ }
+
+ // Record old status for creation-time correction only; existing-product decrements are correct and must not be reversed.
+ // If $previously_tracked, an earlier transition already counted the old status — decrement is legitimate.
+ if ( ! $previously_tracked && $was_decremented && ! isset( $this->initial_product_statuses[ $product_id ] ) && isset( $this->products_in_creation[ $product_id ] ) ) {
+ $this->initial_product_statuses[ $product_id ] = $old_status;
+ } elseif ( ( $this->initial_product_statuses[ $product_id ] ?? null ) === $new_status ) {
+ unset( $this->initial_product_statuses[ $product_id ] );
+ }
+ }
+
+ /**
+ * Update the cache when a product is permanently deleted.
+ *
+ * @param int $post_id Post ID.
+ * @param WP_Post $post The post object.
+ *
+ * @return void
+ */
+ public function update_on_product_deleted( int $post_id, WP_Post $post ): void {
+ if ( 'product' === $post->post_type ) {
+ // Reverse any errant decrement from a mid-creation status transition that update_on_new_product will never get to correct.
+ $this->maybe_restore_initial_status_count( $post_id );
+
+ $product_status = $post->post_status;
+ if ( $this->product_count_cache->is_cached( 'product', $product_status ) ) {
+ $this->product_count_cache->decrement( 'product', $product_status );
+ }
+
+ unset( $this->product_statuses[ $post_id ], $this->products_in_creation[ $post_id ] );
+ }
+ }
+
+ /**
+ * Reverses an errant decrement recorded in initial_product_statuses for a given product, if any.
+ *
+ * @param int $product_id Product ID.
+ *
+ * @return void
+ */
+ private function maybe_restore_initial_status_count( int $product_id ): void {
+ if ( isset( $this->initial_product_statuses[ $product_id ] ) ) {
+ $initial_status = $this->initial_product_statuses[ $product_id ];
+ unset( $this->initial_product_statuses[ $product_id ] );
+ if ( $this->product_count_cache->is_cached( 'product', $initial_status ) ) {
+ $this->product_count_cache->increment( 'product', $initial_status );
+ }
+ }
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
index 3edf0a7bc7e..545d7aa3fa7 100644
--- a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
@@ -1,9 +1,11 @@
<?php
-declare(strict_types=1);
+declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Utilities;
+use Automattic\WooCommerce\Caches\ProductCountCache;
+
/**
* Class with general utility methods related to products.
*/
@@ -129,7 +131,7 @@ class ProductUtil {
}
/**
- * Counts per-status number of products.
+ * Counts per-status number of products of a given post type.
*
* @since 11.0.0
*
@@ -137,7 +139,18 @@ class ProductUtil {
* @return array<string,int>
*/
public function get_counts_for_type( string $post_type ): array {
- // Performance note: integration point for upcoming persistent counters solution.
- 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 ) {
+ $count_per_status = array_merge(
+ array_fill_keys( array_keys( get_post_stati() ), 0 ),
+ (array) wp_count_posts( $post_type )
+ );
+
+ $product_count_cache->set_multiple( $post_type, $count_per_status );
+ }
+
+ return array_map( 'intval', $count_per_status );
}
}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
index d005bd6009f..573143790c8 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
@@ -2,6 +2,8 @@
declare( strict_types = 1 );
use Automattic\WooCommerce\Admin\Notes\Note;
+use Automattic\WooCommerce\Caches\ProductCountCache;
+use Automattic\WooCommerce\Enums\ProductStatus;
/**
* Class WC_Install_Test.
@@ -209,7 +211,7 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
$supply_post_count = function () use ( &$post_count, &$counted_posts ) {
$counted_posts = true;
- return $post_count;
+ return (object) array( ProductStatus::PUBLISH => $post_count );
};
$supply_coming_soon = function () use ( &$coming_soon ) {
@@ -250,6 +252,7 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
$this->assertFalse( WC_Install::is_new_install(), 'We are not in a new install if the WC version is set and we have one or more products (even if the shop ID is not set).' );
$post_count = 0;
+ ( new ProductCountCache() )->flush( 'product' );
$this->assertTrue( WC_Install::is_new_install(), 'We are in a new install if the WC version is set but the shop ID is not set and we do not have any products.' );
$counted_posts = false;
@@ -330,7 +333,7 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
*/
public function test_order_stats_schema_includes_fulfillment_status_for_new_install_with_fulfillments_feature_enabled(): void {
// Mock is_new_install to return true.
- $version = null;
+ $version = false;
$shop_id = null;
$supply_version = function () use ( &$version ) {
@@ -380,7 +383,7 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
delete_option( 'woocommerce_feature_fulfillments_enabled' );
// Mock is_new_install to return true.
- $version = null;
+ $version = false;
$shop_id = null;
$supply_version = function () use ( &$version ) {
diff --git a/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheServiceTest.php b/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheServiceTest.php
new file mode 100644
index 00000000000..85aeb04d176
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheServiceTest.php
@@ -0,0 +1,339 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Caching;
+
+use WC_Helper_Product;
+use WC_Product_Simple;
+use Automattic\WooCommerce\Caches\ProductCountCache;
+use Automattic\WooCommerce\Caches\ProductCountCacheService;
+use Automattic\WooCommerce\Enums\ProductStatus;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
+
+/**
+ * Class ProductCountCacheServiceTest.
+ */
+final class ProductCountCacheServiceTest extends \WC_Unit_Test_Case {
+
+ /**
+ * ProductCountCache instance.
+ *
+ * @var ProductCountCache
+ */
+ private ProductCountCache $product_cache;
+
+ /**
+ * ProductUtil instance.
+ *
+ * @var ProductUtil
+ */
+ private ProductUtil $product_util;
+
+ /**
+ * Setup test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->product_cache = new ProductCountCache();
+ $this->product_cache->flush();
+ $this->product_util = wc_get_container()->get( ProductUtil::class );
+ }
+
+ /**
+ * @testdox Count is incremented when a new product is created.
+ */
+ public function test_count_incremented_on_product_create(): void {
+ $initial_count = $this->product_util->get_counts_for_type( 'product' )[ ProductStatus::PUBLISH ];
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_status( ProductStatus::PUBLISH );
+ $product->save();
+
+ $counts = $this->product_util->get_counts_for_type( 'product' );
+
+ $this->assertSame( $initial_count + 1, $counts[ ProductStatus::PUBLISH ] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Count is decremented when a product is permanently deleted.
+ */
+ public function test_count_decremented_on_product_delete(): void {
+ $initial_count = $this->product_util->get_counts_for_type( 'product' );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_status( ProductStatus::PUBLISH );
+ $product->save();
+ $product->delete( true );
+
+ $counts = $this->product_util->get_counts_for_type( 'product' );
+
+ $this->assertSame( $initial_count[ ProductStatus::PUBLISH ], $counts[ ProductStatus::PUBLISH ] );
+ }
+
+ /**
+ * @testdox Source and destination status counts are updated when a product status changes.
+ */
+ public function test_count_on_product_status_change(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_status( ProductStatus::PUBLISH );
+ $product->save();
+
+ $initial_count = $this->product_util->get_counts_for_type( 'product' );
+
+ $product->set_status( ProductStatus::DRAFT );
+ $product->save();
+
+ $count = $this->product_util->get_counts_for_type( 'product' );
+
+ $this->assertSame( $initial_count[ ProductStatus::PUBLISH ] - 1, $count[ ProductStatus::PUBLISH ] );
+ $this->assertSame( $initial_count[ ProductStatus::DRAFT ] + 1, $count[ ProductStatus::DRAFT ] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Only the final status is incremented when a new product is saved with a non-default initial status.
+ */
+ public function test_count_on_new_product_with_initial_status(): void {
+ $initial_count = $this->product_util->get_counts_for_type( 'product' );
+
+ $product = WC_Helper_Product::create_simple_product( true, array( 'status' => ProductStatus::PENDING ) );
+
+ $count = $this->product_util->get_counts_for_type( 'product' );
+
+ $this->assertSame( $initial_count[ ProductStatus::PUBLISH ], $count[ ProductStatus::PUBLISH ] );
+ $this->assertSame( $initial_count[ ProductStatus::PENDING ] + 1, $count[ ProductStatus::PENDING ] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Draft count is incremented correctly when transition_post_status fires during new product creation.
+ */
+ public function test_count_on_new_product_with_status_change(): void {
+ $initial_count = $this->product_util->get_counts_for_type( 'product' );
+
+ $product = new WC_Product_Simple();
+ $product->set_status( ProductStatus::DRAFT );
+ $product->save();
+
+ $count = $this->product_util->get_counts_for_type( 'product' );
+
+ $this->assertSame( $initial_count[ ProductStatus::DRAFT ] + 1, $count[ ProductStatus::DRAFT ] );
+ $this->assertSame( $initial_count[ ProductStatus::PUBLISH ], $count[ ProductStatus::PUBLISH ] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Only the final status is incremented when a product cycles through multiple statuses before being saved.
+ */
+ public function test_count_on_multiple_status_changes(): void {
+ $initial_count = $this->product_util->get_counts_for_type( 'product' );
+
+ $product = new WC_Product_Simple();
+ $product->set_status( ProductStatus::PUBLISH );
+ $product->set_status( ProductStatus::PENDING );
+ $product->save();
+
+ $count = $this->product_util->get_counts_for_type( 'product' );
+
+ $this->assertSame( $initial_count[ ProductStatus::PUBLISH ], $count[ ProductStatus::PUBLISH ] );
+ $this->assertSame( $initial_count[ ProductStatus::DRAFT ], $count[ ProductStatus::DRAFT ] );
+ $this->assertSame( $initial_count[ ProductStatus::PENDING ] + 1, $count[ ProductStatus::PENDING ] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Counts are not corrupted when a product cycles through publish and back to draft during creation.
+ */
+ public function test_count_not_corrupted_on_status_cycle_during_creation(): void {
+ $initial_count = $this->product_util->get_counts_for_type( 'product' );
+
+ $hook = null;
+ $hook = static function ( int $post_id ) use ( &$hook ): void {
+ remove_action( 'save_post_product', $hook, 1 );
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_status' => ProductStatus::PUBLISH,
+ )
+ );
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_status' => ProductStatus::DRAFT,
+ )
+ );
+ };
+ add_action( 'save_post_product', $hook, 1 );
+
+ $product = new WC_Product_Simple();
+ $product->set_status( ProductStatus::DRAFT );
+ $product->save();
+
+ $count = $this->product_util->get_counts_for_type( 'product' );
+
+ $this->assertSame( $initial_count[ ProductStatus::DRAFT ] + 1, $count[ ProductStatus::DRAFT ] );
+ $this->assertSame( $initial_count[ ProductStatus::PUBLISH ], $count[ ProductStatus::PUBLISH ] );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @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 {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_status( ProductStatus::DRAFT );
+ $product->save();
+
+ // Warm all status slots, then flush only the target to create a partially cold cache.
+ $this->product_util->get_counts_for_type( 'product' );
+ $draft_before = $this->product_cache->get( 'product', array( ProductStatus::DRAFT ) )[ ProductStatus::DRAFT ];
+
+ $this->product_cache->flush( 'product', array( ProductStatus::PUBLISH ) );
+
+ $product->set_status( ProductStatus::PUBLISH );
+ $product->save();
+
+ // The draft slot should be decremented even though the publish slot was cold.
+ $draft_after = $this->product_cache->get( 'product', array( ProductStatus::DRAFT ) )[ ProductStatus::DRAFT ];
+ $this->assertSame( $draft_before - 1, $draft_after );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @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 {
+ // 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 ];
+
+ $hook = null;
+ $hook = static function ( int $post_id ) use ( &$hook ): void {
+ remove_action( 'save_post_product', $hook, 1 );
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_status' => ProductStatus::PUBLISH,
+ )
+ );
+ };
+ add_action( 'save_post_product', $hook, 1 );
+
+ $product = new WC_Product_Simple();
+ $product->set_status( ProductStatus::DRAFT );
+ $product->save();
+
+ // Publish should be exactly +1: the product ended in PUBLISH, draft must be unchanged.
+ $publish_after = $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) )[ ProductStatus::PUBLISH ];
+ $this->assertSame( $publish_before + 1, $publish_after );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @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 {
+ // 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 ];
+ $publish_before = $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) )[ ProductStatus::PUBLISH ];
+
+ $hook = null;
+ $hook = static function ( int $post_id ) use ( &$hook ): void {
+ remove_action( 'save_post_product', $hook, 1 );
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_status' => ProductStatus::PUBLISH,
+ )
+ );
+ };
+ add_action( 'save_post_product', $hook, 1 );
+
+ $product = new WC_Product_Simple();
+ $product->set_status( ProductStatus::DRAFT );
+ $product->save();
+
+ // The product ended in PUBLISH, so draft must be unchanged and publish must be exactly +1.
+ $draft_after = $this->product_cache->get( 'product', array( ProductStatus::DRAFT ) )[ ProductStatus::DRAFT ];
+ $publish_after = $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) )[ ProductStatus::PUBLISH ];
+ $this->assertSame( $draft_before, $draft_after );
+ $this->assertSame( $publish_before + 1, $publish_after );
+
+ $product->delete( true );
+ }
+
+ /**
+ * @testdox Background recurring action is scheduled.
+ */
+ public function test_background_actions_scheduled(): void {
+ $product_count_cache_service = wc_get_container()->get( ProductCountCacheService::class );
+ $product_count_cache_service->schedule_background_actions();
+ $this->assertTrue( as_has_scheduled_action( 'woocommerce_refresh_product_count_cache' ) );
+ }
+
+ /**
+ * @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 {
+ global $_wp_using_ext_object_cache;
+ $_before = $_wp_using_ext_object_cache;
+ $_wp_using_ext_object_cache = true; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ $this->product_cache->flush();
+ $this->assertNull( $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) ) );
+
+ // We expect the cache to be populated with the relevant values.
+ $product_count_cache_service = wc_get_container()->get( ProductCountCacheService::class );
+ $product_count_cache_service->prime_cache_if_cold( 'product' );
+
+ $cached = $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) );
+ $this->assertNotNull( $cached );
+ $this->assertArrayHasKey( ProductStatus::PUBLISH, $cached );
+
+ $_wp_using_ext_object_cache = $_before; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ }
+
+ /**
+ * @testdox Cache values are preserved when the cache is already warm.
+ */
+ public function test_prime_cache_if_cold_when_cache_is_warm(): void {
+ global $_wp_using_ext_object_cache;
+ $_before = $_wp_using_ext_object_cache;
+ $_wp_using_ext_object_cache = true; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ $publish_count = $this->product_util->get_counts_for_type( 'product' )[ ProductStatus::PUBLISH ];
+ $this->product_cache->set( 'product', ProductStatus::PUBLISH, $publish_count + 10 );
+
+ // We expect the cached values to remain same as counting skipped for warm caches.
+ $product_count_cache_service = wc_get_container()->get( ProductCountCacheService::class );
+ $product_count_cache_service->prime_cache_if_cold( 'product' );
+
+ $this->assertSame( $publish_count + 10, $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) )[ ProductStatus::PUBLISH ] );
+
+ $_wp_using_ext_object_cache = $_before; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ }
+
+ /**
+ * @testdox Cache is not populated when no external object cache is active.
+ */
+ public function test_prime_cache_if_cold_when_object_cache_unavailable(): void {
+ $this->product_cache->flush();
+ $this->assertNull( $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) ) );
+
+ // We expect the cache to remain unpopulated as object caching is unavailable.
+ $product_count_cache_service = wc_get_container()->get( ProductCountCacheService::class );
+ $product_count_cache_service->prime_cache_if_cold( 'product' );
+
+ $this->assertNull( $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) ) );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheTest.php b/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheTest.php
new file mode 100644
index 00000000000..7761c717018
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Caching/ProductCountCacheTest.php
@@ -0,0 +1,96 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Tests\Caching;
+
+use Automattic\WooCommerce\Caches\ProductCountCache;
+use Automattic\WooCommerce\Enums\ProductStatus;
+
+/**
+ * Class ProductCountCacheTest.
+ */
+final class ProductCountCacheTest extends \WC_Unit_Test_Case {
+
+ /**
+ * ProductCountCache instance.
+ *
+ * @var ProductCountCache
+ */
+ private ProductCountCache $product_cache;
+
+ /**
+ * Setup test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->product_cache = new ProductCountCache();
+ $this->product_cache->flush( 'product' );
+ }
+
+ /**
+ * @testdox Known and third-party statuses can be set and retrieved by product type.
+ */
+ public function test_cache_product_counts(): void {
+ $unregistered_status = 'third-party-unregistered-status';
+ $counts = array(
+ ProductStatus::PUBLISH => 5,
+ ProductStatus::DRAFT => 10,
+ $unregistered_status => 20,
+ );
+
+ foreach ( $counts as $status => $count ) {
+ $this->product_cache->set( 'product', $status, $count );
+ }
+
+ $this->assertTrue( $this->product_cache->is_cached( 'product', ProductStatus::PUBLISH ) );
+ $this->assertTrue( $this->product_cache->is_cached( 'product', ProductStatus::DRAFT ) );
+ $this->assertTrue( $this->product_cache->is_cached( 'product', $unregistered_status ) );
+
+ $this->assertSame( 5, $this->product_cache->get( 'product', array( ProductStatus::PUBLISH ) )[ ProductStatus::PUBLISH ] );
+ $this->assertSame( 10, $this->product_cache->get( 'product', array( ProductStatus::DRAFT ) )[ ProductStatus::DRAFT ] );
+ $this->assertSame( 20, $this->product_cache->get( 'product', array( $unregistered_status ) )[ $unregistered_status ] );
+
+ // verify when a specific set of statuses isn't requested.
+ $this->assertSame( 5, $this->product_cache->get( 'product' )[ ProductStatus::PUBLISH ] );
+ $this->assertSame( 10, $this->product_cache->get( 'product' )[ ProductStatus::DRAFT ] );
+ $this->assertSame( 20, $this->product_cache->get( 'product' )[ $unregistered_status ] );
+ }
+
+ /**
+ * @testdox Specific status slots are invalidated when flushed.
+ */
+ public function test_flush_cache(): void {
+ $this->product_cache->set( 'product', ProductStatus::PUBLISH, 5 );
+ $this->product_cache->set( 'product', ProductStatus::DRAFT, 10 );
+ $this->product_cache->flush( 'product', array( ProductStatus::PUBLISH, ProductStatus::DRAFT ) );
+ $this->assertFalse( $this->product_cache->is_cached( 'product', ProductStatus::PUBLISH ) );
+ $this->assertFalse( $this->product_cache->is_cached( 'product', ProductStatus::DRAFT ) );
+ }
+
+ /**
+ * @testdox get() returns null when any of the requested status slots has been evicted.
+ */
+ public function test_get_returns_null_when_any_status_evicted(): void {
+ $this->product_cache->set( 'product', ProductStatus::PUBLISH, 5 );
+ $this->product_cache->set( 'product', ProductStatus::DRAFT, 10 );
+
+ // Evict one slot; the multi-status query must return null so callers fall back to DB.
+ $this->product_cache->flush( 'product', array( ProductStatus::DRAFT ) );
+
+ $this->assertNull( $this->product_cache->get( 'product', array( ProductStatus::PUBLISH, ProductStatus::DRAFT ) ) );
+ }
+
+ /**
+ * @testdox All registered statuses are returned when no specific statuses are requested.
+ */
+ public function test_cache_gets_all_statuses_when_no_statuses_are_provided(): void {
+ foreach ( array_keys( get_post_stati() ) as $status ) {
+ $this->product_cache->set( 'product', $status, 5 );
+ }
+
+ $cached = $this->product_cache->get( 'product' );
+ foreach ( ProductStatus::get_all() as $status ) {
+ $this->assertSame( 5, $cached[ $status ] );
+ }
+ }
+}