Commit 29c4f1f4620 for woocommerce
commit 29c4f1f46201f59ab24b2ad90e8b65e8251ca42e
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Fri Oct 16 11:02:58 2026 +0300
Speed up product lookup stock quantity regeneration (#69321)
* perf(products): Reorder managed-stock lookup update
The stock-column rebuild scans every lookup row even when few
products manage stock. Drive the update from managed-stock metadata
in the core database path, while retaining the original SQL for
custom database classes and query observers.
Performance program session: 2026-09-27-perf-program, B-12 H-1
* test(products): Cover managed-stock lookup rebuild parity
Compare legacy and optimized stock updates on one changed-state fixture. Duplicate and malformed metadata, missing stock, unmanaged products, and variations preserve values and affected-row counts on the tested database. The SQL-shape assertion fails on the frozen base and passes on this branch.
Performance program session: 2026-09-27-perf-program, B-12 H-1
* chore(products): Add stock lookup performance changelog
Describe the merchant-visible improvement from the managed-stock-first lookup rebuild.
Performance program session: 2026-09-27-perf-program, B-12 H-1
* perf(products): Run managed-stock updates under SQL observers
Use one managed-stock-first statement for lookup stock regeneration when
core SQL observers or a database drop-in are present. Preserve the same
managed products, stock values and affected lookup rows.
Retain STRAIGHT_JOIN because plain INNER JOIN chooses a full lookup scan
on the frozen large fixture and measures about 6.5 times slower. Remove
the generic observer guard and duplicated legacy statement.
Test stale, duplicate, missing, fractional and malformed metadata against
the legacy statement, with a query observer on core and transparent
subclass database objects.
* test(products): Keep stock lookup regeneration test to value checks
The regeneration test compared the new statement against an inline copy
of the old one, swapped the global database object for a reflection-
built subclass, counted observed statements and matched the SQL text for
the join hint. Those checks documented how the change was verified, not
what the function does, and the subclass variant exercised a fallback
the code no longer has.
Keep one test that seeds stale lookup values and asserts the resulting
quantities: managed products get their stock, a managed product without
stock meta gets NULL, unmanaged products and products without manage
meta are left alone, duplicate meta resolves to one of its values, and a
malformed value does not abort the statement.
* test(products): Strengthen stock lookup regeneration coverage
The regeneration test did not distinguish stock-management metadata
from unrelated yes-valued metadata, and subsequent reads cleared the
UPDATE error before it was checked.
Seed sold-individually metadata on unmanaged fixtures and assert the
database error immediately after regeneration. Document why the forced
join order must remain in place to preserve the measured speedup.
* perf(products): Narrow stock lookup regeneration with a managed-stock subquery
Review on the PR pointed out that STRAIGHT_JOIN hard-codes one execution
plan and is MySQL-only syntax, while the real problem is a query shape
that leaves the optimizer free to scan the whole lookup table.
Keep the lookup table as the driving table and restrict the update to
managed-stock product IDs through an IN subquery on postmeta. The
subquery is the only place the optimizer has a choice (meta_key index
range or a postmeta scan); the outer update is bound to the lookup
table's primary key, so a full lookup-table scan is never the cheapest
plan. MariaDB rewrites it as a semi-join and picks the same order the
hint forced, at 4%, 50% and 100% managed stock, with the same timing and
byte-identical results from a stale start.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/perf-b-12-managed-stock-first b/plugins/woocommerce/changelog/perf-b-12-managed-stock-first
new file mode 100644
index 00000000000..a957f5d3872
--- /dev/null
+++ b/plugins/woocommerce/changelog/perf-b-12-managed-stock-first
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Speed up product stock lookup regeneration on large stores.
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index 76b581f09a1..ccf5c9cb691 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -2297,16 +2297,18 @@ function wc_update_product_lookup_tables_column( $column ) {
);
break;
case 'stock_quantity':
+ // The subquery narrows the update to managed-stock products by primary key, so the optimizer never scans the whole lookup table.
$wpdb->query(
"
UPDATE
{$wpdb->wc_product_meta_lookup} lookup_table
- LEFT JOIN {$wpdb->postmeta} meta1 ON lookup_table.product_id = meta1.post_id AND meta1.meta_key = '_manage_stock'
- LEFT JOIN {$wpdb->postmeta} meta2 ON lookup_table.product_id = meta2.post_id AND meta2.meta_key = '_stock'
+ LEFT JOIN {$wpdb->postmeta} meta ON lookup_table.product_id = meta.post_id AND meta.meta_key = '_stock'
SET
- lookup_table.stock_quantity = meta2.meta_value
+ lookup_table.stock_quantity = meta.meta_value
WHERE
- meta1.meta_value = 'yes'
+ lookup_table.product_id IN (
+ SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_manage_stock' AND meta_value = 'yes'
+ )
"
);
break;
diff --git a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
index da19eb8041c..d2861406113 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -984,6 +984,83 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
$this->assertSame( $expected_rating, $actual_rating, 'Average rating lookup data should only update when reviews are enabled.' );
}
+ /**
+ * @testdox Stock lookup regeneration copies managed stock quantities and leaves unmanaged products alone.
+ */
+ public function test_stock_lookup_regeneration_sets_quantities_from_managed_stock_meta(): void {
+ global $wpdb;
+
+ $stock_meta = array(
+ 'positive' => array( '7.5' ),
+ 'missing' => array(),
+ 'unmanaged' => array( '4' ),
+ 'no_manage_meta' => array( '5' ),
+ 'duplicate_stock' => array( '2', '9' ),
+ 'duplicate_stock_reverse' => array( '9', '2' ),
+ 'duplicate_manage' => array( '-3' ),
+ 'malformed' => array( 'not-a-number' ),
+ 'variation' => array( '0.25' ),
+ );
+ $manage_meta = array(
+ 'unmanaged' => array( 'no' ),
+ 'no_manage_meta' => array(),
+ 'duplicate_manage' => array( 'yes', 'no', 'yes' ),
+ );
+ $fixtures = array();
+ foreach ( array_keys( $stock_meta ) as $case ) {
+ $fixtures[ $case ] = 'variation' === $case
+ ? WC_Helper_Product::create_variation_product()->get_children()[0]
+ : WC_Helper_Product::create_simple_product()->get_id();
+ }
+
+ foreach ( $fixtures as $case => $product_id ) {
+ delete_post_meta( $product_id, '_manage_stock' );
+ delete_post_meta( $product_id, '_stock' );
+ foreach ( $manage_meta[ $case ] ?? array( 'yes' ) as $value ) {
+ add_post_meta( $product_id, '_manage_stock', $value );
+ }
+ foreach ( $stock_meta[ $case ] as $value ) {
+ add_post_meta( $product_id, '_stock', $value );
+ }
+ $this->assertNotFalse( $wpdb->update( $wpdb->wc_product_meta_lookup, array( 'stock_quantity' => 99 ), array( 'product_id' => $product_id ) ), "Failed to seed lookup row for {$case}." );
+ }
+
+ update_post_meta( $fixtures['unmanaged'], '_sold_individually', 'yes' );
+ update_post_meta( $fixtures['no_manage_meta'], '_sold_individually', 'yes' );
+
+ wc_update_product_lookup_tables_column( 'stock_quantity' );
+ $this->assertSame( '', $wpdb->last_error, 'Regeneration must complete without a database error.' );
+
+ $values = $this->read_stock_lookup_values( $fixtures );
+ $this->assertSame( 7.5, $values['positive'] );
+ $this->assertNull( $values['missing'], 'A managed product without stock meta gets NULL.' );
+ $this->assertSame( 99.0, $values['unmanaged'], 'A product with manage_stock set to no is left alone.' );
+ $this->assertSame( 99.0, $values['no_manage_meta'], 'A product without manage_stock meta is left alone.' );
+ $this->assertContains( $values['duplicate_stock'], array( 2.0, 9.0 ) );
+ $this->assertContains( $values['duplicate_stock_reverse'], array( 2.0, 9.0 ) );
+ $this->assertSame( -3.0, $values['duplicate_manage'] );
+ $this->assertNotSame( 99.0, $values['malformed'], 'A malformed stock value must not abort the statement.' );
+ $this->assertSame( 0.25, $values['variation'] );
+ }
+
+ /**
+ * Read stock lookup values for a named product fixture.
+ *
+ * @param array<string, int> $fixtures Named product IDs.
+ * @return array<string, float|null>
+ */
+ private function read_stock_lookup_values( array $fixtures ): array {
+ global $wpdb;
+
+ $values = array();
+ foreach ( $fixtures as $case => $product_id ) {
+ $row = $wpdb->get_row( $wpdb->prepare( "SELECT stock_quantity FROM {$wpdb->wc_product_meta_lookup} WHERE product_id = %d", $product_id ), ARRAY_A );
+ $this->assertIsArray( $row, "Missing lookup row for {$case}." );
+ $values[ $case ] = null === $row['stock_quantity'] ? null : (float) $row['stock_quantity'];
+ }
+ return $values;
+ }
+
/**
* @testdox Queued rating count batches do not run when reviews are disabled.
* @testWith ["yes", 3, true]