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