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]