Commit 1c1c9bb6ed7 for woocommerce

commit 1c1c9bb6ed7e63c985ba5d746b0488e41c426066
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Sat Nov 7 13:29:02 2026 +0200

    Count orders pending HPOS synchronization in one joined pass (#69360)

    * perf: Improve the full pending order sync count

    The full pending count joins orders and posts separately for missing
    and changed records. Large stores repeat the same lookups while
    checking synchronization progress.

    Aggregate both predicates over one left join for the authoritative
    storage. Keep separate sums so a filtered placeholder can contribute
    twice, and preserve runtime types, deletion counts and the existence
    path. Exercise exact public results and the counts cache under both
    authorities.

    * chore: Add changelog for faster pending sync counts

    * refactor: Simplify pending order sync count SQL

    The full pending-sync count uses conditional sums wrapped in COALESCE
    for both storage authorities. COUNT can express each predicate directly
    while returning zero for empty input.

    Replace the sums with COUNT(CASE ... THEN 1 END), retaining separate
    missing and changed counts so overlapping orders still count twice.
    Keep the authority-specific joins and existence-check path unchanged.

    The DataSynchronizer suite passes with its existing skip. Interleaved
    benchmarks on one million orders show identical counts and query plans,
    with median timing differences below 1% under both authorities.

    Refs #69360

    * docs: Explain pending sync count SQL assumptions

    Explain why placeholder orders can contribute to both counts and why
    the repeated IN clauses need duplicate preparation arguments. These
    comments preserve the reasons behind otherwise tempting simplifications.

    Refs #69360

    * test: Add pending sync count regression cases

    The pending-count fixtures covered newer authoritative records and
    placeholder posts, but missed several predicates in the rewritten SQL.
    Five targeted regressions still passed the full test class.

    Add cases for older authoritative timestamps and missing auto-drafts
    under both storage authorities. Cover HPOS orders with no backup post,
    including custom types with absent or placeholder posts.

    All five regressions now fail on the new assertions. The restored suite
    passes in normal and randomized order: 48 tests, 160 assertions, and
    one existing skip. Production code is unchanged.

    Refs #69360

    * fix: Preserve pending sync counts across storage modes

    Keep the joined aggregate for HPOS full counts, but restore the
    original posts query to avoid scanning unrelated content on stores
    with many products or pages.

    Normalize filtered order types before duplicating SQL arguments so
    string keys cannot collapse placeholders. Cover keyed custom types,
    placeholder overlap, and unregistered HPOS types with regression tests.

    * chore: Narrow pending sync count changelog to HPOS

    * chore: Align pending sync code with project conventions

    Use the shared auto-draft status constant in the HPOS count query
    and its fixture, keeping the prepared SQL value unchanged. Rename
    the full-count data provider to match the test naming convention.

    * perf: Check HPOS timestamps before post types

    Synchronized orders dominate the HPOS pending-count scan. Check whether
    the HPOS timestamp is newer before comparing the backup post type,
    allowing those rows to skip the type comparison.

    Keep both additive counts and all existing predicates so missing, changed,
    and overlapping order classifications retain their current results.

diff --git a/plugins/woocommerce/changelog/perf-b-10-one-pass-pending-count b/plugins/woocommerce/changelog/perf-b-10-one-pass-pending-count
new file mode 100644
index 00000000000..718b7915aca
--- /dev/null
+++ b/plugins/woocommerce/changelog/perf-b-10-one-pass-pending-count
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Show pending order synchronization counts faster when high-performance order storage is selected.
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php b/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php
index c8a6e964e4b..929958b31e6 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Internal\DataStores\Orders;

 use Automattic\WooCommerce\Caches\OrderCacheController;
 use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
+use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Internal\Admin\Orders\EditLock;
 use Automattic\WooCommerce\Internal\BatchProcessing\{ BatchProcessingController, BatchProcessorInterface };
 use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
@@ -527,7 +528,7 @@ class DataSynchronizer implements BatchProcessorInterface {
 	private function query_orders_pending_sync_count( $full_count = true ) {
 		global $wpdb;

-		$order_post_types = wc_get_order_types( 'cot-migration' );
+		$order_post_types = array_values( wc_get_order_types( 'cot-migration' ) );

 		$order_post_type_placeholder = implode( ', ', array_fill( 0, count( $order_post_types ), '%s' ) );

@@ -561,21 +562,40 @@ class DataSynchronizer implements BatchProcessorInterface {
 			return $count;
 		}

-		if ( $this->custom_orders_table_is_authoritative() ) {
-			$missing_orders_count_sql = $wpdb->prepare(
+		$orders_table_is_authoritative = $this->custom_orders_table_is_authoritative();
+
+		// Under posts authority, the aggregate can scan large amounts of non-order content.
+		if ( $full_count && $orders_table_is_authoritative ) {
+			// A placeholder registered as an order type can be both missing and changed, so count each condition separately.
+			$sql = $wpdb->prepare(
 				"
+SELECT
+ COUNT(CASE WHEN (posts.post_type IS NULL OR posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "')
+  AND orders.status <> %s AND orders.type IN ($order_post_type_placeholder) THEN 1 END)
+ + COUNT(CASE WHEN orders.date_updated_gmt > posts.post_modified_gmt
+  AND posts.post_type IN ($order_post_type_placeholder) THEN 1 END) AS count
+FROM $orders_table orders
+LEFT JOIN $wpdb->posts posts ON posts.ID = orders.id",
+				// Both IN clauses need their own copy of the order-type arguments.
+				array_merge( array( OrderStatus::AUTO_DRAFT ), $order_post_types, $order_post_types )
+			);
+			$pending_count = (int) $wpdb->get_var( $sql );
+		} else {
+			if ( $orders_table_is_authoritative ) {
+				$missing_orders_count_sql = $wpdb->prepare(
+					"
 SELECT $count_clause FROM $wpdb->posts posts
 RIGHT JOIN $orders_table orders ON posts.ID=orders.id
 WHERE (posts.post_type IS NULL OR posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "')
  AND orders.status NOT IN ( 'auto-draft' )
  AND orders.type IN ($order_post_type_placeholder)
 $limit_clause",
-				$order_post_types
-			);
-			$operator                 = '>';
-		} else {
-			$missing_orders_count_sql = $wpdb->prepare(
-				"
+					$order_post_types
+				);
+				$operator                 = '>';
+			} else {
+				$missing_orders_count_sql = $wpdb->prepare(
+					"
 SELECT $count_clause FROM $wpdb->posts posts
 LEFT JOIN $orders_table orders ON posts.ID=orders.id
 WHERE
@@ -583,29 +603,28 @@ WHERE
   AND posts.post_status != 'auto-draft'
   AND orders.id IS NULL
 $limit_clause",
-				$order_post_types
-			);
+					$order_post_types
+				);

-			$operator = '<';
-		}
+				$operator = '<';
+			}

-		$changed_orders_count_sql = $wpdb->prepare(
-			"
+			$changed_orders_count_sql = $wpdb->prepare(
+				"
 SELECT $count_clause FROM $orders_table orders
 JOIN $wpdb->posts posts ON posts.ID = orders.id
 WHERE
   posts.post_type IN ($order_post_type_placeholder)
   AND orders.date_updated_gmt $operator posts.post_modified_gmt
 $limit_clause",
-			$order_post_types
-		);
-		// phpcs:enable
+				$order_post_types
+			);

-		// MySQL cannot reference the same temporary table twice in one query.
-		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query prepared above.
-		$pending_count = (int) $wpdb->get_var( $missing_orders_count_sql );
-		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query prepared above.
-		$pending_count += (int) $wpdb->get_var( $changed_orders_count_sql );
+			// MySQL cannot reference the same temporary table twice in one query.
+			$pending_count  = (int) $wpdb->get_var( $missing_orders_count_sql );
+			$pending_count += (int) $wpdb->get_var( $changed_orders_count_sql );
+		}
+		// phpcs:enable

 		$deleted_from_table = $this->get_current_deletion_record_meta_value();

diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php
index 3bb990120b5..3651b0e238b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/DataSynchronizerTests.php
@@ -99,6 +99,211 @@ class DataSynchronizerTests extends \HposTestCase {
 		parent::tearDown();
 	}

+	/**
+	 * @testdox Should preserve exact full pending counts and their cache under either authority.
+	 * @dataProvider data_provider_test_full_pending_count
+	 * @param bool   $hpos              Whether HPOS is authoritative.
+	 * @param string $shape             The pending-order fixture.
+	 * @param int    $expected          The exact pending count.
+	 * @param bool   $string_keyed_type Whether the added order type has a string key.
+	 */
+	public function test_full_pending_count( bool $hpos, string $shape, int $expected, bool $string_keyed_type = false ): void {
+		global $wpdb;
+
+		update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, $hpos ? 'yes' : 'no' );
+		update_option( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION, 'no' );
+		$this->assertSame( 0, $this->sut->get_current_orders_pending_sync_count(), 'The fixture should start without pending orders.' );
+
+		if ( 'deletion' === $shape ) {
+			$meta_table = OrdersTableDataStore::get_meta_table_name();
+			$authority  = $hpos ? DataSynchronizer::DELETED_FROM_ORDERS_META_VALUE : DataSynchronizer::DELETED_FROM_POSTS_META_VALUE;
+			foreach ( array( $authority, $authority, $hpos ? DataSynchronizer::DELETED_FROM_POSTS_META_VALUE : DataSynchronizer::DELETED_FROM_ORDERS_META_VALUE ) as $deleted_from ) {
+				$wpdb->query(
+					$wpdb->prepare(
+						// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Trusted table name from the order data store.
+						"INSERT INTO $meta_table (order_id, meta_key, meta_value) VALUES (%d, %s, %s)",
+						987654,
+						DataSynchronizer::DELETED_RECORD_META_KEY,
+						$deleted_from
+					)
+				);
+			}
+		} elseif ( 'empty' !== $shape ) {
+			$post_type = 'overlap' === $shape || ( $hpos && 'missing' === $shape ) ? DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE : 'shop_order';
+			if ( 'custom' === $shape || 'overlap' === $shape ) {
+				$post_type = 'custom' === $shape ? 'custom_order' : $post_type;
+				add_filter(
+					'wc_order_types',
+					static function ( $types, $context ) use ( $post_type, $string_keyed_type ) {
+						if ( 'cot-migration' === $context ) {
+							if ( $string_keyed_type ) {
+								$types[ $post_type ] = $post_type;
+							} else {
+								$types[] = $post_type;
+							}
+						}
+						return $types;
+					},
+					10,
+					2
+				);
+			}
+			$post_id = $this->factory->post->create(
+				array(
+					'post_type'   => $post_type,
+					'post_status' => OrderInternalStatus::COMPLETED,
+				)
+			);
+			$wpdb->update( $wpdb->posts, array( 'post_modified_gmt' => $hpos ? '2024-01-01 00:00:00' : '2024-01-02 00:00:00' ), array( 'ID' => $post_id ) );
+			if ( $hpos || 'changed' === $shape ) {
+				$wpdb->insert(
+					OrdersTableDataStore::get_orders_table_name(),
+					array(
+						'id'               => $post_id,
+						'type'             => 'custom' === $shape ? $post_type : 'shop_order',
+						'status'           => 'changed' === $shape ? OrderStatus::AUTO_DRAFT : OrderInternalStatus::COMPLETED,
+						'date_updated_gmt' => $hpos ? '2024-01-02 00:00:00' : '2024-01-01 00:00:00',
+					)
+				);
+			}
+		}
+
+		$this->assertSame( $expected, $this->sut->get_current_orders_pending_sync_count(), $shape . ' should have the exact pending count.' );
+		$this->assertSame( $expected, wp_cache_get( 'woocommerce_hpos_pending_sync_count', 'counts' ), 'The counts cache should hold the exact integer.' );
+	}
+
+	/**
+	 * @testdox Should not count an order whose authoritative record is older than its backup.
+	 * @testWith [true]
+	 *           [false]
+	 * @param bool $hpos Whether HPOS is authoritative.
+	 */
+	public function test_older_authoritative_orders_are_not_pending_sync( bool $hpos ): void {
+		global $wpdb;
+
+		update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, $hpos ? 'yes' : 'no' );
+		update_option( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION, 'no' );
+		$post_id = $this->factory->post->create(
+			array(
+				'post_type'   => 'shop_order',
+				'post_status' => OrderInternalStatus::COMPLETED,
+			)
+		);
+		$wpdb->update( $wpdb->posts, array( 'post_modified_gmt' => $hpos ? '2024-01-02 00:00:00' : '2024-01-01 00:00:00' ), array( 'ID' => $post_id ) );
+		$wpdb->insert(
+			OrdersTableDataStore::get_orders_table_name(),
+			array(
+				'id'               => $post_id,
+				'type'             => 'shop_order',
+				'status'           => OrderInternalStatus::COMPLETED,
+				'date_updated_gmt' => $hpos ? '2024-01-01 00:00:00' : '2024-01-02 00:00:00',
+			)
+		);
+
+		$this->assertSame( 0, $this->sut->get_current_orders_pending_sync_count(), 'A newer backup must not make the authoritative order pending sync.' );
+	}
+
+	/**
+	 * @testdox Should exclude missing auto-draft orders from the pending sync count.
+	 * @testWith [true]
+	 *           [false]
+	 * @param bool $hpos Whether HPOS is authoritative.
+	 */
+	public function test_missing_auto_drafts_are_not_pending_sync( bool $hpos ): void {
+		global $wpdb;
+
+		update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, $hpos ? 'yes' : 'no' );
+		update_option( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION, 'no' );
+		$post_id = $this->factory->post->create(
+			array(
+				'post_type'   => $hpos ? DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE : 'shop_order',
+				'post_status' => OrderStatus::AUTO_DRAFT,
+			)
+		);
+		if ( $hpos ) {
+			$wpdb->insert(
+				OrdersTableDataStore::get_orders_table_name(),
+				array(
+					'id'     => $post_id,
+					'type'   => 'shop_order',
+					'status' => OrderStatus::AUTO_DRAFT,
+				)
+			);
+		}
+
+		$this->assertSame( 0, $this->sut->get_current_orders_pending_sync_count(), 'An auto-draft must not be pending sync just because its backup is missing.' );
+	}
+
+	/**
+	 * @testdox Should count only registered missing HPOS orders with absent or placeholder posts.
+	 * @testWith ["shop_order", false]
+	 *           ["custom_order", false]
+	 *           ["custom_order", true]
+	 *           ["unregistered_order", false, 0]
+	 *           ["unregistered_order", true, 0]
+	 * @param string $order_type      The order type to store.
+	 * @param bool   $has_placeholder Whether a placeholder post exists.
+	 * @param int    $expected        The expected pending count.
+	 */
+	public function test_missing_hpos_orders_are_pending_sync( string $order_type, bool $has_placeholder, int $expected = 1 ): void {
+		global $wpdb;
+
+		update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'yes' );
+		update_option( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION, 'no' );
+		if ( 'custom_order' === $order_type ) {
+			add_filter(
+				'wc_order_types',
+				static function ( $types, $context ) use ( $order_type ) {
+					if ( 'cot-migration' === $context ) {
+						$types[] = $order_type;
+					}
+					return $types;
+				},
+				10,
+				2
+			);
+		}
+		$post_id = $this->factory->post->create( array( 'post_type' => DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE ) );
+		$wpdb->insert(
+			OrdersTableDataStore::get_orders_table_name(),
+			array(
+				'id'     => $post_id,
+				'type'   => $order_type,
+				'status' => OrderInternalStatus::COMPLETED,
+			)
+		);
+		if ( ! $has_placeholder ) {
+			// Remove only the backup row without triggering order deletion hooks.
+			$wpdb->delete( $wpdb->posts, array( 'ID' => $post_id ) );
+		}
+
+		$this->assertSame( $expected, $this->sut->get_current_orders_pending_sync_count(), 'Only registered HPOS order types without a real backup post should be pending sync.' );
+	}
+
+	/**
+	 * Provide full-count fixtures for each authority and overlapping predicates.
+	 *
+	 * @return array
+	 */
+	public function data_provider_test_full_pending_count(): array {
+		return array(
+			'hpos missing'       => array( true, 'missing', 1 ),
+			'posts missing'      => array( false, 'missing', 1 ),
+			'hpos changed'       => array( true, 'changed', 1 ),
+			'posts changed'      => array( false, 'changed', 1 ),
+			'hpos deletion'      => array( true, 'deletion', 2 ),
+			'posts deletion'     => array( false, 'deletion', 2 ),
+			'overlap'            => array( true, 'overlap', 2 ),
+			'hpos custom'        => array( true, 'custom', 1 ),
+			'posts custom'       => array( false, 'custom', 1 ),
+			'hpos empty'         => array( true, 'empty', 0 ),
+			'posts empty'        => array( false, 'empty', 0 ),
+			'hpos keyed custom'  => array( true, 'custom', 1, true ),
+			'posts keyed custom' => array( false, 'custom', 1, true ),
+			'keyed overlap'      => array( true, 'overlap', 2, true ),
+		);
+	}
+
 	/**
 	 * @testdox Should count and detect missing and changed orders when HPOS uses a temporary table.
 	 * @testWith [false, true]