Commit f827e5cd084 for woocommerce
commit f827e5cd084863c5b6854d74981bd245779f616f
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date: Thu Aug 27 16:57:52 2026 +0200
[Performance] Cap variable product prices transient size (#68058)
Caps the transient size so it doesn't grow unbound over the 30-day TTL.
diff --git a/plugins/woocommerce/changelog/performance-67913-cap-prices-transient-size b/plugins/woocommerce/changelog/performance-67913-cap-prices-transient-size
new file mode 100644
index 00000000000..4bd8de00a49
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-67913-cap-prices-transient-size
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Cap variable product prices transient size.
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 bef26047e16..1c0f53bb3c6 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
@@ -343,10 +343,10 @@ class WC_Product_Variable_Data_Store_CPT extends WC_Product_Data_Store_CPT imple
* - 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)
+ * - capping the transient size so it doesn't grow unbounded over the 30-day retention period
*
- * That leaves two optimization routes:
+ * That leaves single optimization route:
* - 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 ] ) ) {
@@ -539,17 +539,31 @@ 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 ( null !== $opposite_price_hash ) {
- $transient_cached_prices_array[ $opposite_price_hash ][ $key ] = $values;
+ // Add all pricing data to the transient array: ensure the hashes always pushed to the end.
+ foreach ( array_filter( array( $price_hash, $opposite_price_hash ) ) as $hash ) {
+ unset( $transient_cached_prices_array[ $hash ] );
+ foreach ( $prices_array as $key => $values ) {
+ $transient_cached_prices_array[ $hash ][ $key ] = $values;
}
}
// Validate the prices data before storing it in the transient.
if ( $this->validate_prices_data( $transient_cached_prices_array, $transient_version ) ) {
- set_transient( $transient_name, wp_json_encode( $transient_cached_prices_array ), DAY_IN_SECONDS * 30 );
+ $json = wp_json_encode( $transient_cached_prices_array );
+
+ // Cap the transient size — hash churn (e.g. real-time or role-based pricing) can bloat it over the 30-day TTL.
+ // Size-based cap rather than count-based, as the number of variations per hash varies widely.
+ // Read-optimized: 8KB keeps the wp_options row inline in InnoDB; 64KB stays within a single Memcached slab.
+ $transient_size_cap = wp_using_ext_object_cache() ? 65536 : 8192;
+ $transient_size = strlen( (string) $json );
+ $cached_hashes_count = count( $transient_cached_prices_array );
+ if ( $transient_size > $transient_size_cap && $cached_hashes_count > 4 ) {
+ $cached_hashes_to_keep = max( 4, (int) floor( $transient_size_cap / ( $transient_size / $cached_hashes_count ) ) );
+ $transient_cached_prices_array = array_slice( $transient_cached_prices_array, -$cached_hashes_to_keep, null, true );
+ $json = wp_json_encode( $transient_cached_prices_array );
+ }
+
+ set_transient( $transient_name, $json, DAY_IN_SECONDS * 30 );
}
}
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variable-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variable-data-store-cpt-test.php
index 0fd0d8a4fce..83bd4cb2040 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variable-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-product-variable-data-store-cpt-test.php
@@ -1931,4 +1931,65 @@ class WC_Product_Variable_Data_Store_CPT_Test extends WC_Unit_Test_Case {
$product->delete( true );
}
+
+ /**
+ * @testdox read_price_data prunes the transient when hash buckets exceed the size cap.
+ */
+ public function test_read_price_data_prunes_unbound_transient(): void {
+ $product = WC_Helper_Product::create_variation_product();
+ $product_id = $product->get_id();
+ $version = WC_Cache_Helper::get_transient_version( 'product' );
+
+ // Compute the current price hash so we can place it first in the seeded transient.
+ $data_store = new class() extends WC_Product_Variable_Data_Store_CPT {
+ public function __construct() { // phpcs:ignore Squiz.Commenting.FunctionComment.Missing
+ $this->prices_array = array();
+ }
+
+ public function get_price_hash( &$product, $for_display = false ) { // phpcs:ignore Generic.CodeAnalysis.UselessOverridingMethod.Found, Squiz.Commenting.FunctionComment.Missing
+ return parent::get_price_hash( $product, $for_display );
+ }
+ };
+ $current_hash = $data_store->get_price_hash( $product, false );
+
+ // Seed an unbound transient with the current hash near the head to verify pruning moves it to the tail.
+ $entry = array(
+ 'price' => array(),
+ 'regular_price' => array(),
+ 'sale_price' => array(),
+ 'version' => $version,
+ );
+ foreach ( $product->get_children() as $child_id ) {
+ $entry['price'][ $child_id ] = '10.00';
+ $entry['regular_price'][ $child_id ] = '15.00';
+ $entry['sale_price'][ $child_id ] = '10.00';
+ }
+
+ $unbound = array();
+ $unbound[ md5( 'hash_first' ) ] = $entry;
+ $unbound[ $current_hash ] = $entry;
+ for ( $i = 0; $i < 38; $i++ ) {
+ $unbound[ md5( 'hash_' . $i ) ] = $entry;
+ }
+ $stored = wp_json_encode( $unbound );
+ set_transient( 'wc_var_prices_' . $product_id, $stored, DAY_IN_SECONDS * 30 );
+
+ // Before pruning: 40 buckets, exceeds 8KB, current hash is near the head (position 2).
+ $this->assertSame( 40, count( $unbound ), 'Seeded transient should contain 40 hash buckets before pruning.' );
+ $this->assertGreaterThan( 8192, strlen( $stored ), 'Seeded transient JSON should exceed 8KB before pruning.' );
+
+ // Enable taxes so the missing opposite hash triggers recalculation + pruning.
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ $display_hash = $data_store->get_price_hash( $product, true );
+ $data_store->read_price_data( $product, true );
+ update_option( 'woocommerce_calc_taxes', 'no' );
+
+ // After pruning: fewer buckets, under 8KB, both hashes moved from head to tail.
+ $stored = json_decode( (string) get_transient( 'wc_var_prices_' . $product_id ), true );
+ $this->assertLessThan( count( $unbound ), count( $stored ), 'Unbound transient should be pruned to fewer hash buckets.' );
+ $this->assertLessThanOrEqual( 8192, strlen( wp_json_encode( $stored ) ), 'Pruned transient JSON should not exceed 8KB.' );
+ $this->assertSame( array( $display_hash, $current_hash ), array_slice( array_keys( $stored ), -2 ), 'Display and opposite price hashes should be the last two entries after pruning.' );
+
+ $product->delete( true );
+ }
}