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.
 	 *