WooCommerce Code Reference

ProductCountCacheService.php

Source code

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