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]