Commit 71515e47090 for woocommerce

commit 71515e4709072febbc38ec51e471f70af9b1ca2b
Author: Ján Mikláš <neosinner@gmail.com>
Date:   Wed Nov 11 14:52:34 2026 +0100

    Add HPOS order metadata Site Health check (#68396)

    * Add HPOS order metadata Site Health check

    * Add changelog for HPOS metadata health check

    * Clarify estimated Site Health row counts

    * Use routed database for HPOS statistics

    * Test HPOS metadata statistics query

    * Report missing HPOS row estimates as inconclusive in the Site Health check

    Missing estimates no longer read as a passing result, the statistics test
    now fails when no estimates come back, and the description notes that
    estimates can be a day old. Estimates are read with a status query per
    table, so a database drop-in can route each one by table name.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_017HvTz3BNu4Ya3tLEcqQudZ

    * fix: clarify HPOS health results and coverage

    * fix: limit HPOS table statistics lookup to the named table

    Use `SHOW TABLE STATUS LIKE` instead of `WHERE Name =`. On MySQL 5.7 and
    MariaDB only the LIKE pattern narrows the table lookup; WHERE filters
    after reading status for every table in the schema.

    The table name is passed without esc_like(). After prepare() doubles the
    escape, wpdb::get_table_from_query() and drop-ins such as HyperDB read
    the name with a leftover backslash and can route the query to the wrong
    server (WordPress core ticket #57149). Because `_` then acts as a LIKE
    wildcard, only the returned row whose name matches the table is used.
    The match ignores case, since servers with lower_case_table_names report
    names in lowercase.

    Also say the row counts are estimates that may be out of date, since the
    one-day bound only holds for MySQL 8 defaults, and cover the case where
    the database returns no status row.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01NM9YXwBUkshUPVHEjN74Fk

    * test: exercise the HPOS-disabled health check

    * fix: drop repeated "estimated" from the order metadata Site Health notice

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01NM9YXrrrEAc27JhcHMxqoh

    * refactor: key HPOS table statistics by result name

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01NM9YXrrrEAc27JhcHMxqoh

    * fix: flag order metadata above 200 rows per order

    A ratio of 1000 let large stores with one bloated order pass, such as
    5k orders with 4M metadata rows. 200 still sits well above normal order
    metadata and costs no extra query.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01NM9YXrrrEAc27JhcHMxqoh

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/68305-order-meta-site-health b/plugins/woocommerce/changelog/68305-order-meta-site-health
new file mode 100644
index 00000000000..fbdc752d7a9
--- /dev/null
+++ b/plugins/woocommerce/changelog/68305-order-meta-site-health
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a Site Health recommendation for unusually large HPOS order metadata tables.
diff --git a/plugins/woocommerce/src/Internal/Admin/SiteHealth.php b/plugins/woocommerce/src/Internal/Admin/SiteHealth.php
index ad6e10a4c7b..01f0c8e55d1 100644
--- a/plugins/woocommerce/src/Internal/Admin/SiteHealth.php
+++ b/plugins/woocommerce/src/Internal/Admin/SiteHealth.php
@@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\Internal\Admin;
 use Automattic\WooCommerce\Enums\DefaultCustomerAddress;
 use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
 use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
 use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
 use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
@@ -33,6 +34,16 @@ class SiteHealth {
 	 */
 	private const REST_NAMESPACE = 'wc-site-health/v1';

+	/**
+	 * Minimum order metadata rows before the table size check can recommend investigation.
+	 */
+	private const ORDER_META_ROW_COUNT_THRESHOLD = 1000000;
+
+	/**
+	 * Minimum metadata rows per order before the table size check can recommend investigation.
+	 */
+	private const ORDER_META_ROWS_PER_ORDER_THRESHOLD = 200;
+
 	/**
 	 * Class instance.
 	 *
@@ -80,7 +91,7 @@ class SiteHealth {
 				$tests['direct'][ $test_id ] = array(
 					'label'     => $test['label'],
 					'test'      => $run,
-					'skip_cron' => true,
+					'skip_cron' => $test['skip_cron'] ?? true,
 				);
 				continue;
 			}
@@ -206,6 +217,7 @@ class SiteHealth {
 	 *   - badge: 'security' or 'performance'.
 	 *   - async: optional; true to run the test after the page loads, for checks that make loopback requests.
 	 *   - check: callable returning bool (true = good) or array (empty = good, otherwise context for description/actions).
+	 *   - skip_cron: whether to exclude a direct test from scheduled checks (defaults to true).
 	 *   - good: result data when the check passes (label, description).
 	 *   - fail: result data when the check fails (status defaults to 'recommended', label, description, optional actions).
 	 *     Label, status, and description may be callables that receive the check context.
@@ -213,7 +225,7 @@ class SiteHealth {
 	 * @return array<string, array>
 	 */
 	protected function get_woocommerce_site_health_tests(): array {
-		return array(
+		$tests = array(
 			'woocommerce_secure_connection'            => array(
 				'label' => __( 'WooCommerce secure connection', 'woocommerce' ),
 				'badge' => 'security',
@@ -520,6 +532,100 @@ class SiteHealth {
 				),
 			),
 		);
+
+		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+			$tests['woocommerce_order_meta_table_size'] = array(
+				'label'     => __( 'WooCommerce order metadata table size', 'woocommerce' ),
+				'badge'     => 'performance',
+				'skip_cron' => false,
+				'check'     => fn() => $this->get_order_meta_table_size_issue(),
+				'good'      => array(
+					'label'       => __( 'WooCommerce order metadata table passed the size check', 'woocommerce' ),
+					'description' => __( 'WooCommerce did not detect an unusually large order metadata table.', 'woocommerce' ),
+				),
+				'fail'      => array(
+					'label'       => static fn( array $context ) => ! empty( $context['unverified'] )
+						? __( 'WooCommerce could not check the order metadata table size', 'woocommerce' )
+						: __( 'WooCommerce order metadata table is unusually large', 'woocommerce' ),
+					'description' => static fn( array $context ) => ! empty( $context['unverified'] )
+						? __( 'The database did not return row estimates for the order tables, so WooCommerce could not check the order metadata table size.', 'woocommerce' )
+						: sprintf(
+							/* translators: 1: Estimated number of order metadata rows. 2: Estimated number of order rows. */
+							__( 'The row counts are %1$s for the order metadata table and %2$s for the orders table. These are estimates and may be out of date. Excess order metadata can slow down checkout and order management. Ask your hosting provider or support team to investigate before deleting data.', 'woocommerce' ),
+							number_format_i18n( $context['meta_rows'] ),
+							number_format_i18n( $context['order_rows'] )
+						),
+				),
+			);
+		}
+
+		return $tests;
+	}
+
+	/**
+	 * Get estimated row counts for the HPOS orders and metadata tables.
+	 *
+	 * @return array{order_rows: int, meta_rows: int}|null
+	 */
+	private function get_hpos_table_statistics(): ?array {
+		global $wpdb;
+
+		$tables     = array(
+			'order_rows' => OrdersTableDataStore::get_orders_table_name(),
+			'meta_rows'  => OrdersTableDataStore::get_meta_table_name(),
+		);
+		$row_counts = array();
+
+		foreach ( $tables as $key => $table ) {
+			// LIKE limits the lookup to the named table, unlike WHERE, which reads every table in the schema on MySQL 5.7 and MariaDB.
+			// Naming the table also lets a database drop-in route the query to the server that holds it. The name is not passed
+			// through esc_like() because drop-ins would then read it with a leftover backslash (see WordPress core ticket #57149).
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- This reads database-maintained table statistics during Site Health checks.
+			$statuses = $wpdb->get_results( $wpdb->prepare( 'SHOW TABLE STATUS LIKE %s', $table ), ARRAY_A );
+			$status   = null;
+
+			// LIKE treats `_` as a wildcard, so only use the row for this exact table.
+			foreach ( (array) $statuses as $row ) {
+				if ( isset( $row['Name'] ) && 0 === strcasecmp( $row['Name'], $table ) ) {
+					$status = $row;
+					break;
+				}
+			}
+
+			if ( ! is_array( $status ) || ! isset( $status['Rows'] ) || ! is_numeric( $status['Rows'] ) ) {
+				return null;
+			}
+
+			$row_counts[ $key ] = (int) $status['Rows'];
+		}
+
+		return array(
+			'order_rows' => $row_counts['order_rows'],
+			'meta_rows'  => $row_counts['meta_rows'],
+		);
+	}
+
+	/**
+	 * Get context when the order metadata table is unusually large, or when its size could not be checked.
+	 *
+	 * @return array{order_rows: int, meta_rows: int}|array{unverified: true}|array{}
+	 */
+	private function get_order_meta_table_size_issue(): array {
+		$table_statistics = $this->get_hpos_table_statistics();
+
+		if ( null === $table_statistics ) {
+			return array( 'unverified' => true );
+		}
+
+		if ( $table_statistics['meta_rows'] < self::ORDER_META_ROW_COUNT_THRESHOLD ) {
+			return array();
+		}
+
+		if ( 0 !== $table_statistics['order_rows'] && intdiv( $table_statistics['meta_rows'], $table_statistics['order_rows'] ) < self::ORDER_META_ROWS_PER_ORDER_THRESHOLD ) {
+			return array();
+		}
+
+		return $table_statistics;
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php
index fd2d9a85be2..1dd39d4cfb0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php
@@ -4,6 +4,8 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Internal\Admin;

 use Automattic\WooCommerce\Internal\Admin\SiteHealth;
+use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
 use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
 use WC_Admin_Notices;
 use WC_Unit_Test_Case;
@@ -92,6 +94,160 @@ class SiteHealthTest extends WC_Unit_Test_Case {
 		$this->assertSame( '<p>There is no completed approved download directory synchronization waiting for review.</p>', $result['description'], 'The result should confirm that no synchronization review is pending.' );
 	}

+	/**
+	 * @testdox Order metadata table size check runs during scheduled Site Health checks.
+	 */
+	public function test_order_meta_table_size_check_runs_during_cron(): void {
+		update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'yes' );
+
+		$tests = $this->sut->handle_site_status_tests( array() );
+
+		$this->assertFalse( $tests['direct']['woocommerce_order_meta_table_size']['skip_cron'], 'The order metadata table size check should run during scheduled Site Health checks.' );
+		$this->assertTrue( $tests['direct']['woocommerce_template_overrides']['skip_cron'], 'Existing direct checks should remain excluded from scheduled Site Health checks.' );
+	}
+
+	/**
+	 * @testdox Order metadata table size check uses both the absolute and per-order limits.
+	 * @testWith [0, 1000000, "recommended"]
+	 *           [5000, 1000000, "recommended"]
+	 *           [5000, 4000000, "recommended"]
+	 *           [1, 999999, "good"]
+	 *           [5001, 1000000, "good"]
+	 *           [0, 0, "good"]
+	 *
+	 * @param int    $order_rows     Estimated rows in the orders table.
+	 * @param int    $meta_rows      Estimated rows in the order metadata table.
+	 * @param string $expected_status Expected Site Health status.
+	 */
+	public function test_order_meta_table_size_check_thresholds( int $order_rows, int $meta_rows, string $expected_status ): void {
+		update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'yes' );
+		$this->use_hpos_table_statistics( $order_rows, $meta_rows );
+
+		$result = $this->sut->run_test( 'woocommerce_order_meta_table_size' );
+
+		$this->assertSame( $expected_status, $result['status'], 'The estimated table row counts should determine the Site Health status.' );
+	}
+
+	/**
+	 * @testdox Order metadata table size check explains an unusually large table without recommending deletion.
+	 */
+	public function test_order_meta_table_size_check_explains_unusual_size(): void {
+		update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'yes' );
+		$this->use_hpos_table_statistics( 44, 49800000 );
+
+		$result = $this->sut->run_test( 'woocommerce_order_meta_table_size' );
+
+		$this->assertSame( 'WooCommerce order metadata table is unusually large', $result['label'], 'The result should describe the table size without claiming duplicate metadata.' );
+		$this->assertSame( 'Performance', $result['badge']['label'], 'The result should use the performance badge.' );
+		$this->assertSame( '<p>The row counts are 49,800,000 for the order metadata table and 44 for the orders table. These are estimates and may be out of date. Excess order metadata can slow down checkout and order management. Ask your hosting provider or support team to investigate before deleting data.</p>', $result['description'], 'The result should explain the risk and recommend investigation rather than deletion.' );
+		$this->assertSame( '', $result['actions'], 'The result should not link to an unsafe generic cleanup action.' );
+	}
+
+	/**
+	 * @testdox Order metadata table size check is omitted when HPOS is disabled.
+	 */
+	public function test_order_meta_table_size_check_is_omitted_without_hpos(): void {
+		update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'no' );
+		$statistics_queries = 0;
+		$query_filter       = static function ( $query ) use ( &$statistics_queries ) {
+			if ( false !== strpos( $query, 'SHOW TABLE STATUS' ) ) {
+				++$statistics_queries;
+			}
+
+			return $query;
+		};
+		add_filter( 'query', $query_filter );
+
+		try {
+			$tests  = $this->sut->handle_site_status_tests( array() );
+			$result = $this->sut->run_test( 'woocommerce_order_meta_table_size' );
+		} finally {
+			remove_filter( 'query', $query_filter );
+		}
+
+		$this->assertArrayNotHasKey( 'woocommerce_order_meta_table_size', $tests['direct'], 'The HPOS-specific check should be omitted when HPOS is disabled.' );
+		$this->assertSame( array(), $result, 'Running the HPOS-specific check should return no result when HPOS is disabled.' );
+		$this->assertSame( 0, $statistics_queries, 'The HPOS table statistics query should not run when HPOS is disabled.' );
+	}
+
+	/**
+	 * @testdox Order metadata table size check is inconclusive when either table's statistics are unavailable.
+	 * @testWith [null, null]
+	 *           [null, 1000000]
+	 *           [1000, null]
+	 *
+	 * @param int|null $order_rows Estimated rows in the orders table.
+	 * @param int|null $meta_rows  Estimated rows in the order metadata table.
+	 */
+	public function test_order_meta_table_size_check_is_inconclusive_without_statistics( ?int $order_rows, ?int $meta_rows ): void {
+		update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'yes' );
+		$this->use_hpos_table_statistics( $order_rows, $meta_rows );
+
+		$result = $this->sut->run_test( 'woocommerce_order_meta_table_size' );
+
+		$this->assertSame( 'recommended', $result['status'], 'Unavailable table statistics should be reported as inconclusive.' );
+		$this->assertSame( 'WooCommerce could not check the order metadata table size', $result['label'], 'The result should not claim that unavailable statistics were verified.' );
+		$this->assertSame( '<p>The database did not return row estimates for the order tables, so WooCommerce could not check the order metadata table size.</p>', $result['description'], 'The result should explain why the table size could not be checked.' );
+	}
+
+	/**
+	 * @testdox Order metadata table size check is inconclusive when the database returns no status row.
+	 */
+	public function test_order_meta_table_size_check_is_inconclusive_without_status_row(): void {
+		update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'yes' );
+		add_filter(
+			'query',
+			static fn( $query ) => false === strpos( $query, 'SHOW TABLE STATUS' ) ? $query : 'SELECT 1 AS `Rows` FROM DUAL WHERE 1 = 0'
+		);
+
+		$result = $this->sut->run_test( 'woocommerce_order_meta_table_size' );
+
+		$this->assertSame( 'recommended', $result['status'], 'A missing status row should be reported as inconclusive.' );
+		$this->assertSame( 'WooCommerce could not check the order metadata table size', $result['label'], 'The result should not claim that a missing status row was verified.' );
+	}
+
+	/**
+	 * @testdox Order metadata table size check ignores other tables matched by the LIKE wildcard.
+	 */
+	public function test_order_meta_table_size_check_ignores_wildcard_matches(): void {
+		update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'yes' );
+		$orders_table = OrdersTableDataStore::get_orders_table_name();
+		$meta_table   = OrdersTableDataStore::get_meta_table_name();
+		$decoy_table  = str_replace( 'wc_orders_meta', 'wc1orders_meta', $meta_table );
+		add_filter(
+			'query',
+			static function ( $query ) use ( $orders_table, $meta_table, $decoy_table ) {
+				if ( false === strpos( $query, 'SHOW TABLE STATUS' ) ) {
+					return $query;
+				}
+
+				if ( false === strpos( $query, 'wc_orders_meta' ) ) {
+					return "SELECT '{$orders_table}' AS `Name`, 10 AS `Rows`";
+				}
+
+				return "SELECT '{$decoy_table}' AS `Name`, 50000000 AS `Rows` UNION ALL SELECT '{$meta_table}' AS `Name`, 10 AS `Rows`";
+			}
+		);
+
+		$result = $this->sut->run_test( 'woocommerce_order_meta_table_size' );
+
+		$this->assertSame( 'good', $result['status'], 'Only the row for the exact metadata table should be used.' );
+	}
+
+	/**
+	 * @testdox Order metadata table size check can read real table statistics.
+	 */
+	public function test_order_meta_table_size_check_can_read_table_statistics(): void {
+		global $wpdb;
+
+		update_option( CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'yes' );
+
+		$result = $this->sut->run_test( 'woocommerce_order_meta_table_size' );
+
+		$this->assertSame( '', $wpdb->last_error, 'The table statistics query should be valid for the test database.' );
+		$this->assertSame( 'good', $result['status'], 'Real row estimates should be returned and stay under the limits; missing estimates would be reported as inconclusive.' );
+	}
+
 	/**
 	 * @testdox Upload directory protection check is inconclusive when the HTTP request fails.
 	 */
@@ -376,4 +532,27 @@ class SiteHealthTest extends WC_Unit_Test_Case {
 			remove_filter( 'upload_dir', $upload_dir_callback );
 		}
 	}
+
+	/**
+	 * Replace HPOS table statistics with predictable estimates.
+	 *
+	 * @param int|null $order_rows Estimated rows in the orders table.
+	 * @param int|null $meta_rows  Estimated rows in the order metadata table.
+	 */
+	private function use_hpos_table_statistics( ?int $order_rows, ?int $meta_rows ): void {
+		$order_rows_sql = null === $order_rows ? 'NULL' : (string) $order_rows;
+		$meta_rows_sql  = null === $meta_rows ? 'NULL' : (string) $meta_rows;
+		$query_filter   = static function ( $query ) use ( $order_rows_sql, $meta_rows_sql ) {
+			if ( false === strpos( $query, 'SHOW TABLE STATUS' ) ) {
+				return $query;
+			}
+
+			$is_meta_query = false !== strpos( $query, 'wc_orders_meta' );
+			$table         = $is_meta_query ? OrdersTableDataStore::get_meta_table_name() : OrdersTableDataStore::get_orders_table_name();
+
+			return "SELECT '{$table}' AS `Name`, " . ( $is_meta_query ? $meta_rows_sql : $order_rows_sql ) . ' AS `Rows`';
+		};
+
+		add_filter( 'query', $query_filter );
+	}
 }