JezK
Edit File: ProductCountCacheService.php
<?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, 'unschedule_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. } /** * 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 { // Until persistent counters reactivated, this task is no-op. } /** * 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 ); } } } }