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()`.
*