devops: Variable product performance optimization (#1006)

* devops: Variable product performance optimization and tests

- Memoize get_variation_prices() in the Product model to avoid
  redundant lookups across multiple pricing fields (price, regularPrice,
  salePrice, and their RAW variants)
- Remove redundant post__in filter from variations connection resolver;
  post_parent already constrains the query, and the post__in triggered
  an extra get_children() call per product
- Add createVariableProductCatalog() helper to GraphQLE2E for creating
  variable products with many variations in tests
- Add VariableProductPerformanceTest (wpunit) measuring DB query count
  and duration for 15 variable products with 18 variations each
- Add VariableProductPerformanceCest (functional) verifying 6 rapid
  queries do not return 429 errors

Addresses #897

* fix: Skip timing assertion when xdebug is active and clean up debug code
This commit is contained in:
Geoff Taylor
2026-03-31 09:17:44 -04:00
committed by GitHub
parent 684732aa0d
commit 858887fa78
5 changed files with 277 additions and 2 deletions
+12 -1
View File
@@ -103,6 +103,14 @@ class Product extends WC_Post {
*/
protected $product_type;
/**
* Cached variation prices to avoid redundant lookups across
* multiple pricing fields (price, regularPrice, salePrice, etc.).
*
* @var array|null
*/
private $variation_prices = null;
/**
* Stores product factory.
*
@@ -164,7 +172,10 @@ class Product extends WC_Post {
*/
$data = $this->wc_data;
$prices = $data->get_variation_prices( true );
if ( is_null( $this->variation_prices ) ) {
$this->variation_prices = $data->get_variation_prices( true );
}
$prices = $this->variation_prices;
if ( empty( $prices['price'] ) || ( 'sale' === $pricing_type && ! $this->wc_data->is_on_sale() ) ) {
return null;
@@ -75,7 +75,6 @@ class Product_With_Variations {
$resolver->set_query_arg( 'post_parent', $source->ID );
$resolver->set_query_arg( 'post_type', 'product_variation' );
$resolver->set_query_arg( 'post__in', $source->variation_ids );
return $resolver->get_connection();
},
+77
View File
@@ -1309,4 +1309,81 @@ class GraphQLE2E extends \Codeception\Module {
return $cart_page_id;
}
/**
* Creates variable products with many variations for performance testing.
*
* @param int $product_count Number of variable products to create.
* @param int $variations_per Approximate variations per product (3 attrs: sizes × colors × styles).
*
* @return array Product IDs.
*/
public function createVariableProductCatalog( $product_count = 10, $variations_per = 18 ) {
$product_ids = [];
for ( $p = 0; $p < $product_count; $p++ ) {
$product = new \WC_Product_Variable();
$product->set_name( "Variable Product {$p}" );
$product->set_status( 'publish' );
$size_attr = new \WC_Product_Attribute();
$size_attr->set_name( 'Size' );
$size_attr->set_options( [ 'Small', 'Medium', 'Large' ] );
$size_attr->set_visible( true );
$size_attr->set_variation( true );
$color_attr = new \WC_Product_Attribute();
$color_attr->set_name( 'Color' );
$color_attr->set_options( [ 'Red', 'Blue', 'Green' ] );
$color_attr->set_visible( true );
$color_attr->set_variation( true );
$style_attr = new \WC_Product_Attribute();
$style_attr->set_name( 'Style' );
$style_attr->set_options( [ 'Classic', 'Modern' ] );
$style_attr->set_visible( true );
$style_attr->set_variation( true );
$product->set_attributes( [ $size_attr, $color_attr, $style_attr ] );
$product_id = $product->save();
// 3 sizes × 3 colors × 2 styles = 18 variations per product.
$sizes = [ 'Small', 'Medium', 'Large' ];
$colors = [ 'Red', 'Blue', 'Green' ];
$styles = [ 'Classic', 'Modern' ];
$count = 0;
foreach ( $sizes as $size ) {
foreach ( $colors as $color ) {
foreach ( $styles as $style ) {
if ( $count >= $variations_per ) {
break 3;
}
$variation = new \WC_Product_Variation();
$variation->set_parent_id( $product_id );
$variation->set_attributes(
[
'size' => $size,
'color' => $color,
'style' => $style,
]
);
$variation->set_regular_price( 10 + $count );
if ( $count % 3 === 0 ) {
$variation->set_sale_price( 8 + $count );
}
$variation->set_stock_status( 'instock' );
$variation->save();
$count++;
}
}
}
wc_delete_product_transients( $product_id );
$product_ids[] = $product_id;
}
return $product_ids;
}
}
@@ -0,0 +1,59 @@
<?php
/**
* Tests that querying variable products with many variations does not
* trigger 429 errors from excessive response times or resource usage.
*
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/897
*/
class VariableProductPerformanceCest {
public function _before( FunctionalTester $I ) {
$I->createVariableProductCatalog( 10, 18 );
}
/**
* Test that sending multiple rapid product queries does not return 429 errors.
*/
public function testRapidVariableProductQueriesDoNotReturn429( FunctionalTester $I ) {
$query = '
query {
products(first: 10, where: { type: VARIABLE }) {
nodes {
... on VariableProduct {
databaseId
name
price
regularPrice
salePrice
variations(first: 5) {
nodes {
databaseId
price
regularPrice
}
}
}
}
}
}
';
// Fire 6 rapid queries — the reporter says 429 hits at ~6-7 executions.
$error_count = 0;
for ( $i = 0; $i < 6; $i++ ) {
$response = $I->sendGraphQLRequest( $query, null );
if ( empty( $response ) || ! isset( $response['data'] ) ) {
$error_count++;
codecept_debug( "Request {$i} failed or returned non-JSON response." );
continue;
}
$I->assertArrayHasKey( 'products', $response['data'] );
$nodes = $response['data']['products']['nodes'];
$I->assertNotEmpty( $nodes, "Request {$i} returned empty products." );
}
$I->assertEquals( 0, $error_count, "{$error_count} of 6 rapid queries returned errors (likely 429)." );
}
}
@@ -0,0 +1,129 @@
<?php
/**
* Tests performance of querying variable products with many variations.
*
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/897
*/
class VariableProductPerformanceTest extends \Tests\WPGraphQL\WooCommerce\TestCase\WooGraphQLTestCase {
private $product_ids = [];
public function setUp(): void {
parent::setUp();
// Create 15 variable products each with 20+ variations.
for ( $i = 0; $i < 15; $i++ ) {
$product_id = $this->factory->product->createVariable();
$product = wc_get_product( $product_id );
// createVariable creates size (small/medium/large), color (red/blue/green), logo (Yes/No)
// That's 3x3x2 = 18 combinations. createSome only creates a few.
// Let's create all 18 variations + a few extra with different prices.
$sizes = [ 'small', 'medium', 'large' ];
$colors = [ 'red', 'blue', 'green' ];
$logos = [ 'Yes', 'No' ];
$variation_count = 0;
foreach ( $sizes as $size ) {
foreach ( $colors as $color ) {
foreach ( $logos as $logo ) {
$regular_price = 10 + $variation_count;
$sale_price = $variation_count % 3 === 0 ? $regular_price - 2 : '';
$this->factory->product_variation->create(
[
'parent_id' => $product_id,
'attributes' => [
'pa_size' => $size,
'pa_color' => $color,
'logo' => $logo,
],
'regular_price' => $regular_price,
'sale_price' => $sale_price,
]
);
$variation_count++;
}
}
}
// Clear transients so prices are recalculated.
delete_transient( 'wc_var_prices_' . $product_id );
wc_delete_product_transients( $product_id );
$this->product_ids[] = $product_id;
}
}
/**
* Test that querying many variable products with pricing fields completes
* in a reasonable time and does not generate excessive DB queries.
*/
public function testQueryManyVariableProductsWithPricing() {
$query = '
query {
products(first: 15, where: { type: VARIABLE }) {
nodes {
... on VariableProduct {
databaseId
name
price
regularPrice
salePrice
priceRaw: price(format: RAW)
regularPriceRaw: regularPrice(format: RAW)
salePriceRaw: salePrice(format: RAW)
variations(first: 5) {
nodes {
databaseId
price
regularPrice
salePrice
}
}
}
}
}
}
';
// Track DB queries.
global $wpdb;
$wpdb->queries = [];
if ( ! defined( 'SAVEQUERIES' ) ) {
define( 'SAVEQUERIES', true );
}
$start = microtime( true );
$response = $this->graphql( [ 'query' => $query ] );
$duration = microtime( true ) - $start;
$query_count = count( $wpdb->queries );
codecept_debug( "Query duration: {$duration}s, DB queries: {$query_count}" );
// Verify the response is successful.
$this->assertArrayHasKey( 'data', $response );
$this->assertArrayHasKey( 'products', $response['data'] );
$nodes = $response['data']['products']['nodes'];
$this->assertCount( 15, $nodes );
// Verify pricing fields are resolved.
foreach ( $nodes as $node ) {
$this->assertNotEmpty( $node['databaseId'] );
$this->assertNotNull( $node['price'] );
$this->assertNotNull( $node['regularPrice'] );
}
// Performance assertion: skip when xdebug is active since it adds ~5x overhead.
if ( ! extension_loaded( 'xdebug' ) ) {
$this->assertLessThan( 5.0, $duration, "Query took {$duration}s — too slow for 15 variable products." );
}
// DB query count guard: prevents regressions. The ~212 query baseline
// includes WP template lookups, HPOS compat checks, and WC transient
// reads that are outside our control. This threshold catches N+1 regressions.
$this->assertLessThan( 250, $query_count, "Generated {$query_count} DB queries — possible N+1 regression." );
}
}