Commit b5f75681f02 for woocommerce

commit b5f75681f02e63fab157459cbd35e93328fced74
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date:   Tue Jul 21 08:23:01 2026 +0200

    [Performance] Audit variable product datastore (part 2) (#66675)

    Implements additional optimizations to speed up \WC_Product_Variable::get_variation_prices on warm caches hot-path and \WC_Product_Variable_Data_Store_CPT::read_price_data workflows.

diff --git a/plugins/woocommerce/changelog/performance-audit-variable-product-datastore-p2 b/plugins/woocommerce/changelog/performance-audit-variable-product-datastore-p2
new file mode 100644
index 00000000000..53393e7cdc7
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-audit-variable-product-datastore-p2
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Audit the variable product datastore and apply additional optimizations.
diff --git a/plugins/woocommerce/includes/class-wc-product-variable.php b/plugins/woocommerce/includes/class-wc-product-variable.php
index 0fa5a938edc..2dd266c1d73 100644
--- a/plugins/woocommerce/includes/class-wc-product-variable.php
+++ b/plugins/woocommerce/includes/class-wc-product-variable.php
@@ -41,6 +41,16 @@ class WC_Product_Variable extends WC_Product {
 	 */
 	protected $variation_attributes = null;

+	/**
+	 * Variations prices.
+	 *
+	 * @var array<string,array<string,array<int,float>>>
+	 */
+	private array $variation_prices = array(
+		'for_display:0' => array(),
+		'for_display:1' => array(),
+	);
+
 	/**
 	 * Get internal type.
 	 *
@@ -99,13 +109,16 @@ class WC_Product_Variable extends WC_Product {
 	 * @return array Array of RAW prices, regular prices, and sale prices with keys set to variation ID.
 	 */
 	public function get_variation_prices( $for_display = false ) {
+		/** @var array<string,array<int,float>> $prices */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
 		$prices = $this->data_store->read_price_data( $this, $for_display );

-		foreach ( $prices as $price_key => $variation_prices ) {
-			$prices[ $price_key ] = $this->sort_variation_prices( $variation_prices );
+		// Performance note: loose != compares key/value pairs regardless of order, so a re-sort is only triggered when prices actually change.
+		$cache_key = $for_display ? 'for_display:1' : 'for_display:0';
+		if ( $this->variation_prices[ $cache_key ] != $prices ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual
+			$this->variation_prices[ $cache_key ] = array_map( fn( $variation_prices ) => $this->sort_variation_prices( $variation_prices ), $prices );
 		}

-		return $prices;
+		return $this->variation_prices[ $cache_key ];
 	}

 	/**
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-variable-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-variable-data-store-cpt.php
index c424a01cf21..0bd489a0140 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-variable-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-variable-data-store-cpt.php
@@ -318,31 +318,41 @@ class WC_Product_Variable_Data_Store_CPT extends WC_Product_Data_Store_CPT imple
 	 */
 	public function read_price_data( &$product, $for_display = false ) {
 		/**
-		 * Transient name for storing prices for this product (note: Max transient length is 45)
+		 * If you are here to investigate performance of this method: yes, it is heavy in RAM and CPU usage overall.
 		 *
-		 * @since 2.5.0 a single transient is used per product for all prices, rather than many transients per product.
-		 */
-		$transient_name      = 'wc_var_prices_' . $product->get_id();
-		$transient_version   = WC_Cache_Helper::get_transient_version( 'product' );
-		$price_hash          = $this->get_price_hash( $product, $for_display );
-		$opposite_price_hash = $this->taxes_influence_price( $product ) ? null : $this->get_price_hash( $product, ! $for_display );
-
-		/**
-		 * $this->prices_array is an array of values which may have been modified from what is stored in transients - this may not match $transient_cached_prices_array.
-		 * If the value has already been generated, we don't need to grab the values again so just return them. They are already filtered.
+		 * The root cause is that variation object usage is hard-locked by multiple contracts (extension points / filters).
+		 * Those contracts are heavily used by popular extensions, rendering most optimization routes irrelevant at scale.
+		 *
+		 * Optimizations already in place:
+		 * - request-level cache ($this->prices_array)
+		 * - cross-request cache (transient wc_var_prices_<product_id>; sensitive to product transient invalidation through multiple workflows)
+		 * - cache priming (bulk-fetching data from DB) for the product and its variations
+		 * - object instance caching (request-level optimization for wc_get_product; applies across Woo core and extensions)
+		 *
+		 * That leaves two optimization routes:
+		 * - rewrite this method, if breaking the contracts is an option (it's not as per WooCommerce v11.1)
+		 * - verify transient wc_var_prices_<product_id> invalidation frequency and triggers if it gets critical in later releases
 		 */
+		$price_hash = $this->get_price_hash( $product, $for_display );
 		if ( empty( $this->prices_array[ $price_hash ] ) ) {
-			$transient_cached_prices_array = array_filter( (array) json_decode( strval( get_transient( $transient_name ) ), true ) );
+			/**
+			 * Transient name for storing prices for this product (note: Max transient length is 45)
+			 *
+			 * @since 2.5.0 a single transient is used per product for all prices, rather than many transients per product.
+			 */
+			$transient_name      = 'wc_var_prices_' . $product->get_id();
+			$transient_version   = WC_Cache_Helper::get_transient_version( 'product' );
+			$opposite_price_hash = $this->taxes_influence_price( $product ) ? null : $this->get_price_hash( $product, ! $for_display );

 			// If the prices are not valid, reset the transient cache.
+			$transient_cached_prices_array = array_filter( (array) json_decode( (string) get_transient( $transient_name ), true ) );
 			if ( ! $this->validate_prices_data( $transient_cached_prices_array, $transient_version ) ) {
 				$transient_cached_prices_array = array();
 			}

 			// If the prices are not stored for this hash, generate them and add to the transient.
 			// Check also the opposite price hash as it may have changed (see get_price_hash).
-			if ( empty( $transient_cached_prices_array[ $price_hash ] ) ||
-				( ! is_null( $opposite_price_hash ) && ( $opposite_price_hash !== $price_hash ) && empty( $transient_cached_prices_array[ $opposite_price_hash ] ) ) ) {
+			if ( empty( $transient_cached_prices_array[ $price_hash ] ) || ( null !== $opposite_price_hash && empty( $transient_cached_prices_array[ $opposite_price_hash ] ) ) ) {
 				$prices_array = array(
 					'price'         => array(),
 					'regular_price' => array(),
@@ -502,15 +512,11 @@ class WC_Product_Variable_Data_Store_CPT extends WC_Product_Data_Store_CPT imple
 							 * @param bool         $for_display  Whether prices are for display (with tax adjustments) or for calculations.
 							 */
 							$prices_array = apply_filters( 'woocommerce_variation_prices_array', $prices_array, $variation, $for_display );
-							if ( $opposite_price_hash ) {
-								// In principle, we know that prices for display and not for display are the same ones,
-								// but code hooking on woocommerce_variation_prices_array could make this different
-								// so we need to check.
-								$prices_array_hash = md5( wp_json_encode( $prices_array ) );
+							if ( null !== $opposite_price_hash ) {
+								// $for_display doesn't affect raw prices here, but a woocommerce_variation_prices_array hook might.
 								// phpcs:ignore WooCommerce.Commenting.CommentHooks
-								$opposite_prices_array      = apply_filters( 'woocommerce_variation_prices_array', $original_prices_array, $variation, ! $for_display );
-								$opposite_prices_array_hash = md5( wp_json_encode( $opposite_prices_array ) );
-								if ( $opposite_prices_array_hash !== $prices_array_hash ) {
+								$opposite_prices_array = apply_filters( 'woocommerce_variation_prices_array', $original_prices_array, $variation, ! $for_display );
+								if ( $opposite_prices_array !== $prices_array ) {
 									$opposite_price_hash = null;
 								}
 							}
@@ -521,7 +527,7 @@ class WC_Product_Variable_Data_Store_CPT extends WC_Product_Data_Store_CPT imple
 				// Add all pricing data to the transient array.
 				foreach ( $prices_array as $key => $values ) {
 					$transient_cached_prices_array[ $price_hash ][ $key ] = $values;
-					if ( ! is_null( $opposite_price_hash ) && $opposite_price_hash !== $price_hash ) {
+					if ( null !== $opposite_price_hash ) {
 						$transient_cached_prices_array[ $opposite_price_hash ][ $key ] = $values;
 					}
 				}
@@ -552,7 +558,7 @@ class WC_Product_Variable_Data_Store_CPT extends WC_Product_Data_Store_CPT imple
 			 * @param bool       $for_display  Whether prices are being retrieved for display.
 			 */
 			$this->prices_array[ $price_hash ] = apply_filters( 'woocommerce_variation_prices', $transient_cached_prices_array[ $price_hash ], $product, $for_display );
-			if ( ! is_null( $opposite_price_hash ) && $opposite_price_hash !== $price_hash ) {
+			if ( null !== $opposite_price_hash && $opposite_price_hash !== $price_hash ) {
 				// phpcs:ignore WooCommerce.Commenting.CommentHooks
 				$this->prices_array[ $opposite_price_hash ] = apply_filters( 'woocommerce_variation_prices', $transient_cached_prices_array[ $opposite_price_hash ], $product, ! $for_display );
 			}
@@ -616,7 +622,7 @@ class WC_Product_Variable_Data_Store_CPT extends WC_Product_Data_Store_CPT imple
 			$price_hash = array(
 				get_option( 'woocommerce_tax_display_shop', TaxDisplayMode::EXCLUSIVE ),
 				WC_Tax::get_rates(),
-				empty( WC()->customer ) ? false : WC()->customer->is_vat_exempt(),
+				! empty( WC()->customer ) && WC()->customer->is_vat_exempt(),
 			);
 		}

diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index a6847dfae7e..4ef0b29d9bc 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -17955,7 +17955,7 @@ parameters:
 		-
 			message: '#^Parameter \#1 \$str of function md5 expects string, string\|false given\.$#'
 			identifier: argument.type
-			count: 3
+			count: 1
 			path: includes/data-stores/class-wc-product-variable-data-store-cpt.php

 		-
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php b/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
index 144f358331c..d1895097255 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
@@ -347,6 +347,67 @@ class WC_Product_Variable_Test extends \WC_Unit_Test_Case {
 		$this->assertSame( '', $available_variation['gallery_images_html'] );
 	}

+	/**
+	 * @testdox get_variation_prices sorts on first call, skips re-sorting on repeat calls, and treats float and string prices as equal via loose comparison.
+	 */
+	public function test_get_variation_prices_skips_sort_on_repeated_call_and_with_equivalent_types(): void {
+		$product = WC_Helper_Product::create_variation_product();
+		$sut     = new class( $product->get_id() ) extends WC_Product_Variable {
+			public int $sort_count = 0; // phpcs:ignore Squiz.Commenting.VariableComment.Missing
+
+			protected function sort_variation_prices( $prices ) { // phpcs:ignore Squiz.Commenting.FunctionComment.Missing
+				++$this->sort_count;
+				return parent::sort_variation_prices( $prices );
+			}
+		};
+
+		// Ensure the store-level cache is not interfering the test.
+		$invalidate_cache = static fn ( array $hash ) => array( ...$hash, wp_rand() );
+		add_filter( 'woocommerce_get_variation_prices_hash', $invalidate_cache );
+
+		try {
+			// First call: price data will be initially populated, including sorting. 3 is a number of sort calls on initial cache population.
+			$sut->get_variation_prices();
+			$this->assertSame( 3, $sut->sort_count );
+
+			// Second call: price data is unchanged and cache update being skipped. 3 is a number of sort calls, not changed since initial cache population.
+			$sut->get_variation_prices();
+			$this->assertSame( 3, $sut->sort_count );
+
+			// Modify price data type, while keeping the price same.
+			foreach ( $product->get_children() as $child_id ) {
+				foreach ( array( '_price', '_regular_price' ) as $meta_key ) {
+					$value = get_post_meta( $child_id, $meta_key, true );
+					if ( '' !== $value ) {
+						update_post_meta( $child_id, $meta_key, number_format( (float) $value, 2, '.', '' ) );
+					}
+				}
+			}
+
+			// Third call: price data is unchanged (data type is) and cache update being skipped. 3 is a number of sort calls, not changed since initial cache population.
+			$sut->get_variation_prices();
+			$this->assertSame( 3, $sut->sort_count );
+
+			// Modify price.
+			foreach ( $product->get_children() as $child_id ) {
+				foreach ( array( '_price', '_regular_price' ) as $meta_key ) {
+					$value = get_post_meta( $child_id, $meta_key, true );
+					if ( '' !== $value ) {
+						update_post_meta( $child_id, $meta_key, (float) $value + 0.01 );
+					}
+				}
+			}
+
+			// Fourth call: price data change detected — cache being updated. 6 is a number of sort calls: 3 on initial cache population + 3 on cache refresh.
+			$sut->get_variation_prices();
+			$this->assertSame( 6, $sut->sort_count );
+		} finally {
+			remove_filter( 'woocommerce_get_variation_prices_hash', $invalidate_cache );
+		}
+
+		$product->delete( true );
+	}
+
 	/**
 	 * Create a real image attachment that passes `wp_attachment_is_image()`.
 	 *