Commit 22af34971b2 for woocommerce
commit 22af34971b26c7852057cb4f5585204bbe010e44
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date: Fri May 22 22:01:19 2026 +0100
Fix order milestone easter egg performance (#65258)
* Fix order milestone easter egg performance
* Add changelog for order milestone performance fix
diff --git a/plugins/woocommerce/changelog/fix-order-milestone-easter-egg-performance b/plugins/woocommerce/changelog/fix-order-milestone-easter-egg-performance
new file mode 100644
index 00000000000..3c244706d74
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-order-milestone-easter-egg-performance
@@ -0,0 +1,4 @@
+Significance: patch
+Type: performance
+
+Improve order edit page performance by resolving milestone easter egg orders with a bounded HPOS ID query instead of hydrating order objects.
diff --git a/plugins/woocommerce/src/Internal/Admin/OrderMilestoneEasterEgg.php b/plugins/woocommerce/src/Internal/Admin/OrderMilestoneEasterEgg.php
index 7dabd8e01ae..9119f0fb9bd 100644
--- a/plugins/woocommerce/src/Internal/Admin/OrderMilestoneEasterEgg.php
+++ b/plugins/woocommerce/src/Internal/Admin/OrderMilestoneEasterEgg.php
@@ -4,6 +4,9 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Admin;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+
/**
* Displays a full-screen animated piñata overlay when a merchant opens a
* milestone order (1st, 100th, or 1000th real order) in the admin.
@@ -21,14 +24,14 @@ class OrderMilestoneEasterEgg {
private const MILESTONE_CACHE_OPTION = '_wc_order_milestone_egg_order_ids';
/**
- * Maximum number of qualifying orders needed to resolve all milestones.
+ * Option key used to track whether all milestone order IDs have been found.
*/
- private const MAX_QUALIFYING_ORDERS = 1000;
+ private const MILESTONES_COMPLETE_OPTION = '_wc_order_milestone_egg_milestones_complete';
/**
- * Number of orders to inspect per milestone scan batch.
+ * Maximum number of qualifying orders needed to resolve all milestones.
*/
- private const QUERY_BATCH_SIZE = 100;
+ private const MAX_QUALIFYING_ORDERS = 1000;
/**
* Milestone positions mapped to milestone message keys.
@@ -57,11 +60,19 @@ class OrderMilestoneEasterEgg {
}
/**
- * Clears the cached milestone order IDs.
+ * Clears cached milestone order IDs until all milestones are complete.
+ *
+ * Once the 1st, 100th, and 1000th qualifying order IDs have been found,
+ * later orders cannot create additional milestone overlays, so keep the cache
+ * stable and avoid recomputing it after routine order changes.
*
* @internal
*/
public function clear_milestone_cache(): void {
+ if ( wc_string_to_bool( get_option( self::MILESTONES_COMPLETE_OPTION, 'no' ) ) ) {
+ return;
+ }
+
delete_option( self::MILESTONE_CACHE_OPTION );
}
@@ -109,7 +120,7 @@ class OrderMilestoneEasterEgg {
return;
}
- if ( ! function_exists( 'wc_get_orders' ) ) {
+ if ( ! function_exists( 'wc_get_order' ) ) {
return;
}
@@ -129,7 +140,7 @@ class OrderMilestoneEasterEgg {
return;
}
- // Only run the order query on the HPOS order edit page to avoid overhead on every admin page.
+ // Only run milestone logic on the HPOS order edit page to avoid overhead on every admin page.
$is_order_edit_page = 'wc-orders' === $page_param && 'edit' === $action_param;
if ( ! $is_debug_preview && ! $is_order_edit_page ) {
@@ -137,9 +148,13 @@ class OrderMilestoneEasterEgg {
}
// For real order pages: check cheaply whether the current order qualifies
- // before running the more expensive milestone count query.
+ // before running the milestone lookup. The lookup relies on HPOS columns.
if ( ! $is_debug_preview ) {
- if ( $id_param <= 0 || ! $this->is_qualifying_order( $id_param ) ) {
+ if (
+ ! OrderUtil::custom_orders_table_usage_is_enabled()
+ || $id_param <= 0
+ || ! $this->is_qualifying_order( $id_param )
+ ) {
return;
}
}
@@ -234,6 +249,7 @@ class OrderMilestoneEasterEgg {
if ( null === $milestone_order_ids ) {
$milestone_order_ids = $this->compute_milestone_order_ids();
update_option( self::MILESTONE_CACHE_OPTION, $milestone_order_ids, false );
+ $this->update_milestones_complete_option( $milestone_order_ids );
}
$messages = $this->get_milestone_messages();
@@ -280,48 +296,53 @@ class OrderMilestoneEasterEgg {
}
/**
- * Computes milestone order IDs by scanning qualifying orders in chronological order.
+ * Updates the complete option when all milestone IDs have been found.
*
- * @return array<string, int>
+ * @param array<string, int> $milestone_order_ids Milestone order IDs keyed by milestone name.
*/
- private function compute_milestone_order_ids(): array {
- $qualifying_order_ids = array();
- $qualifying_order_ids_count = 0;
- $page = 1;
-
- while ( $qualifying_order_ids_count < self::MAX_QUALIFYING_ORDERS ) {
- $batch = (array) wc_get_orders(
- array(
- 'limit' => self::QUERY_BATCH_SIZE,
- 'paged' => $page,
- 'orderby' => 'date',
- 'order' => 'ASC',
- 'status' => array( 'processing', 'completed' ),
- 'return' => 'objects',
- )
- );
-
- if ( empty( $batch ) ) {
- break;
- }
+ private function update_milestones_complete_option( array $milestone_order_ids ): void {
+ if ( count( $milestone_order_ids ) === count( self::MILESTONE_POSITIONS ) ) {
+ update_option( self::MILESTONES_COMPLETE_OPTION, 'yes', false );
+ return;
+ }
- foreach ( $batch as $order ) {
- if ( $order instanceof \WC_Order && '' !== $order->get_transaction_id() ) {
- $qualifying_order_ids[] = $order->get_id();
- ++$qualifying_order_ids_count;
- if ( $qualifying_order_ids_count >= self::MAX_QUALIFYING_ORDERS ) {
- break 2;
- }
- }
- }
+ delete_option( self::MILESTONES_COMPLETE_OPTION );
+ }
- if ( count( $batch ) < self::QUERY_BATCH_SIZE ) {
- break;
- }
+ /**
+ * Computes milestone order IDs from HPOS without hydrating order objects.
+ *
+ * @return array<string, int>
+ */
+ private function compute_milestone_order_ids(): array {
+ global $wpdb;
- ++$page;
+ if ( ! OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ return array();
}
+ $qualifying_order_ids = array_map(
+ 'absint',
+ $wpdb->get_col(
+ $wpdb->prepare(
+ 'SELECT id
+ FROM %i
+ WHERE type = %s
+ AND status IN ( %s, %s )
+ AND transaction_id IS NOT NULL
+ AND transaction_id <> %s
+ ORDER BY date_created_gmt ASC, id ASC
+ LIMIT %d',
+ OrdersTableDataStore::get_orders_table_name(),
+ 'shop_order',
+ 'wc-processing',
+ 'wc-completed',
+ '',
+ self::MAX_QUALIFYING_ORDERS
+ )
+ )
+ );
+
$milestone_order_ids = array();
foreach ( self::MILESTONE_POSITIONS as $pos => $key ) {
if ( isset( $qualifying_order_ids[ $pos ] ) ) {
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/OrderMilestoneEasterEggTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/OrderMilestoneEasterEggTest.php
index a6139dcb46e..d3d29628657 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/OrderMilestoneEasterEggTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/OrderMilestoneEasterEggTest.php
@@ -5,6 +5,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\Admin;
use Automattic\WooCommerce\Internal\Admin\OrderMilestoneEasterEgg;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
@@ -38,6 +39,7 @@ class OrderMilestoneEasterEggTest extends \WC_Unit_Test_Case {
*/
public function tearDown(): void {
delete_option( $this->get_cache_option_name() );
+ delete_option( $this->get_complete_option_name() );
remove_action( 'admin_enqueue_scripts', array( $this->sut, 'handle_admin_enqueue_scripts' ) );
remove_action( 'wp_ajax_wc_egg_dismiss', array( $this->sut, 'handle_ajax_dismiss' ) );
remove_action( 'wp_ajax_wc_egg_opt_out', array( $this->sut, 'handle_ajax_opt_out' ) );
@@ -225,6 +227,68 @@ class OrderMilestoneEasterEggTest extends \WC_Unit_Test_Case {
$this->assertEquals( 'llama', $map[ $order->get_id() ]['variant'] );
}
+ /**
+ * @testdox get_milestone_map identifies the first, hundredth, and thousandth qualifying orders.
+ */
+ public function test_get_milestone_map_identifies_first_hundredth_and_thousandth_orders(): void {
+ $base_id = 900000000;
+ for ( $i = 0; $i < 1000; ++$i ) {
+ $this->insert_hpos_order( $base_id + $i, gmdate( 'Y-m-d H:i:s', strtotime( '2020-01-01 00:00:00' ) + $i ) );
+ }
+
+ $map = $this->get_milestone_map_via_filter();
+
+ $this->assertSame( 'llama', $map[ $base_id ]['variant'] );
+ $this->assertSame( 'octo', $map[ $base_id + 99 ]['variant'] );
+ $this->assertSame( 'whale', $map[ $base_id + 999 ]['variant'] );
+ $this->assertSame( 'yes', get_option( $this->get_complete_option_name() ) );
+ }
+
+ /**
+ * @testdox get_milestone_map ignores orders without transaction IDs.
+ */
+ public function test_get_milestone_map_ignores_orders_without_transaction_ids(): void {
+ $this->insert_hpos_order( 900001000, '2020-01-01 00:00:00', 'wc-processing', '' );
+ $this->insert_hpos_order( 900001001, '2020-01-01 00:00:01', 'wc-processing', 'txn_live_900001001' );
+
+ $map = $this->get_milestone_map_via_filter();
+
+ $this->assertArrayNotHasKey( 900001000, $map );
+ $this->assertArrayHasKey( 900001001, $map );
+ }
+
+ /**
+ * @testdox get_milestone_map uses a bounded number of DB queries for sparse qualifying orders.
+ */
+ public function test_get_milestone_map_uses_bounded_queries_for_sparse_qualifying_orders(): void {
+ $base_id = 900002000;
+ $orders = array();
+
+ for ( $i = 0; $i < 2000; ++$i ) {
+ $order_id = $base_id + $i;
+ $orders[] = array(
+ 'id' => $order_id,
+ 'status' => 'wc-processing',
+ 'type' => 'shop_order',
+ 'date_created_gmt' => gmdate( 'Y-m-d H:i:s', strtotime( '2020-01-01 00:00:00' ) + $i ),
+ 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s', strtotime( '2020-01-01 00:00:00' ) + $i ),
+ 'transaction_id' => 0 === $i % 2 ? 'txn_live_' . $order_id : '',
+ );
+ }
+
+ $this->insert_hpos_orders( $orders );
+
+ global $wpdb;
+ $queries_before = $wpdb->num_queries;
+ $map = $this->get_milestone_map_via_filter();
+ $queries_used = $wpdb->num_queries - $queries_before;
+
+ $this->assertLessThanOrEqual( 10, $queries_used );
+ $this->assertSame( 'llama', $map[ $base_id ]['variant'] );
+ $this->assertSame( 'octo', $map[ $base_id + 198 ]['variant'] );
+ $this->assertSame( 'whale', $map[ $base_id + 1998 ]['variant'] );
+ }
+
/**
* @testdox get_milestone_map applies the wc_order_milestone_egg_map filter.
*/
@@ -258,6 +322,7 @@ class OrderMilestoneEasterEggTest extends \WC_Unit_Test_Case {
array( 'first' => $order->get_id() ),
get_option( $this->get_cache_option_name(), array() )
);
+ $this->assertFalse( get_option( $this->get_complete_option_name(), false ) );
}
/**
@@ -272,9 +337,9 @@ class OrderMilestoneEasterEggTest extends \WC_Unit_Test_Case {
}
/**
- * @testdox clear_milestone_cache deletes cached milestone order IDs.
+ * @testdox clear_milestone_cache deletes cached milestone order IDs until all milestones are complete.
*/
- public function test_clear_milestone_cache_deletes_cached_milestone_order_ids(): void {
+ public function test_clear_milestone_cache_deletes_cached_milestone_order_ids_until_all_milestones_are_complete(): void {
update_option( $this->get_cache_option_name(), array( 'first' => 12345 ), false );
$this->sut->clear_milestone_cache();
@@ -282,6 +347,23 @@ class OrderMilestoneEasterEggTest extends \WC_Unit_Test_Case {
$this->assertFalse( get_option( $this->get_cache_option_name(), false ) );
}
+ /**
+ * @testdox clear_milestone_cache keeps cached milestone order IDs after all milestones are complete.
+ */
+ public function test_clear_milestone_cache_keeps_cached_milestone_order_ids_after_all_milestones_are_complete(): void {
+ $cached = array(
+ 'first' => 12345,
+ 'hundred' => 12346,
+ 'thousand' => 12347,
+ );
+ update_option( $this->get_cache_option_name(), $cached, false );
+ update_option( $this->get_complete_option_name(), 'yes', false );
+
+ $this->sut->clear_milestone_cache();
+
+ $this->assertSame( $cached, get_option( $this->get_cache_option_name(), array() ) );
+ }
+
// -------------------------------------------------------------------------
// Opt-out gate (handle_admin_enqueue_scripts)
// -------------------------------------------------------------------------
@@ -375,6 +457,67 @@ class OrderMilestoneEasterEggTest extends \WC_Unit_Test_Case {
return $order;
}
+ /**
+ * Inserts a minimal HPOS order row for milestone computation tests.
+ *
+ * @param int $order_id Order ID.
+ * @param string $date_created_gmt Order creation date in GMT.
+ * @param string $status Order status.
+ * @param string|null $transaction_id Transaction ID. Null creates a default transaction ID.
+ */
+ private function insert_hpos_order( int $order_id, string $date_created_gmt, string $status = 'wc-processing', ?string $transaction_id = null ): void {
+ global $wpdb;
+
+ if ( null === $transaction_id ) {
+ $transaction_id = 'txn_live_' . $order_id;
+ }
+
+ $this->insert_hpos_orders(
+ array(
+ array(
+ 'id' => $order_id,
+ 'status' => $status,
+ 'type' => 'shop_order',
+ 'date_created_gmt' => $date_created_gmt,
+ 'date_updated_gmt' => $date_created_gmt,
+ 'transaction_id' => $transaction_id,
+ ),
+ )
+ );
+ }
+
+ /**
+ * Inserts minimal HPOS order rows for milestone computation tests.
+ *
+ * @param array<int, array<string, int|string>> $orders Order rows to insert.
+ */
+ private function insert_hpos_orders( array $orders ): void {
+ global $wpdb;
+
+ $values = array();
+ foreach ( $orders as $order ) {
+ $values[] = $wpdb->prepare(
+ '(%d,%s,%s,%s,%s,%s)',
+ $order['id'],
+ $order['status'],
+ $order['type'],
+ $order['date_created_gmt'],
+ $order['date_updated_gmt'],
+ $order['transaction_id']
+ );
+ }
+
+ $sql = $wpdb->prepare(
+ 'INSERT INTO %i (id,status,type,date_created_gmt,date_updated_gmt,transaction_id) VALUES ',
+ OrdersTableDataStore::get_orders_table_name()
+ );
+ $sql .= implode( ',', $values );
+
+ $result = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name and row values are prepared above.
+
+ $this->assertNotFalse( $result, $wpdb->last_error );
+ }
+
/**
* Returns the private milestone cache option name.
*
@@ -385,6 +528,16 @@ class OrderMilestoneEasterEggTest extends \WC_Unit_Test_Case {
return (string) $ref->getConstant( 'MILESTONE_CACHE_OPTION' );
}
+ /**
+ * Returns the private milestones complete option name.
+ *
+ * @return string
+ */
+ private function get_complete_option_name(): string {
+ $ref = new \ReflectionClass( OrderMilestoneEasterEgg::class );
+ return (string) $ref->getConstant( 'MILESTONES_COMPLETE_OPTION' );
+ }
+
/**
* Calls get_milestone_map() via a filter that captures the result before it's returned.
*