From 8ee3dce0fe96c2fe918393e5f4de54df90f5b03f Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 23:12:34 +0800 Subject: [PATCH] perf(2meet-data-optimizer): let the status/date copy route stop at the LIMIT - Copy route: drop WordPress's GROUP BY when the flat row is the only join, and read posts.ID tie-breaks as the flat post_id (own direction, once) - Post status sync: index (post_status, post_date, post_id) replaces the two-column idx_post_status_date (added / dropped by post-status-sync) - dev36, 235k of 1.3M listings in a price range: main query 1.7 s -> 3 ms - Verify: composer test (704), test:integration (597), phpcs, stan - PR: PR-128 --- .../engine/class-tmdo-post-status-sync.php | 12 +++- .../query/class-tmdo-post-query-router.php | 64 ++++++++++++++++--- tests/integration/PostQueryRouterCopyTest.php | 51 ++++++++++++++- tests/integration/PostStatusSyncTest.php | 29 ++++++++- 4 files changed, 141 insertions(+), 15 deletions(-) diff --git a/includes/engine/class-tmdo-post-status-sync.php b/includes/engine/class-tmdo-post-status-sync.php index 1e91217..5d3cb5b 100644 --- a/includes/engine/class-tmdo-post-status-sync.php +++ b/includes/engine/class-tmdo-post-status-sync.php @@ -29,7 +29,10 @@ final class TMDO_Post_Status_Sync { public const OPTION = 'tmdo_post_status_columns'; - private const INDEX = 'idx_post_status_date'; + private const INDEX = 'idx_post_status_date_id'; + + /** The 2.2–2.3 (post_status, post_date) index, replaced by INDEX. */ + private const LEGACY_INDEX = 'idx_post_status_date'; /** * Hook the sync into WordPress. @@ -203,7 +206,12 @@ final class TMDO_Post_Status_Sync { $changes[] = 'ADD COLUMN `post_date` DATETIME DEFAULT NULL'; } if ( ! in_array( self::INDEX, $indexes, true ) ) { - $changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`)'; + // Ends with post_id: the router reads the date route's ID tie-break + // from it, so MySQL walks the index and stops at the LIMIT (2.4 PR-128). + $changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`, `post_id`)'; + } + if ( in_array( self::LEGACY_INDEX, $indexes, true ) ) { + $changes[] = 'DROP INDEX `' . self::LEGACY_INDEX . '`'; } if ( $changes && false === $wpdb->query( "ALTER TABLE `{$table}` " . implode( ', ', $changes ) ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Fixed DDL on a registry table name. return false; diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index 21839d9..db3d49e 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -26,8 +26,9 @@ * post_date copies are ready (TMDO_Post_Status_Sync) and none is missing, * a single-post-type search with explicit statuses and date ordering * INNER JOINs the table, filters the copies and orders by the copied - * post_date, so MySQL can walk `idx_post_status_date` and stop at the - * LIMIT. Not with NOT EXISTS: posts without a flat row must still match. + * post_date (ID tie-breaks read as the flat post_id), so MySQL can walk + * `idx_post_status_date_id` and stop at the LIMIT. Not with NOT EXISTS: + * posts without a flat row must still match. * * Found rows (O-4): routed searches drop SQL_CALC_FOUND_ROWS (it counts every * match inside the page query); found_posts_query answers from a COUNT of the @@ -749,7 +750,56 @@ class TMDO_Post_Query_Router { * @return string */ private static function routed_request( string $request, \WP_Query $query ): string { - return self::flat_first( self::without_found_rows( $request ), $query ); + return self::copy_ordered( self::flat_first( self::without_found_rows( $request ), $query ), $query ); + } + + /** + * The copy route's request without WordPress's GROUP BY (when the flat row + * is the only join) and with posts.ID tie-breaks read as the flat post_id, + * each in its own direction, once. MariaDB then walks the + * (post_status, post_date, post_id) index and stops at the LIMIT instead + * of sorting every match (dev36, 235k of 1.3M listings: 1.7 s → 3 ms). + * + * @param string $request SELECT statement. + * @param \WP_Query $query Current WP_Query. + * @return string + */ + private static function copy_ordered( string $request, \WP_Query $query ): string { + $copy = $query->get( self::COPY_VAR ); + if ( empty( $copy ) ) { + return $request; + } + global $wpdb; + $tie = '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_id` '; + return (string) preg_replace_callback( + '/\bORDER BY\b(?!.*\bORDER BY\b).*$/s', + static function ( $m ) use ( $wpdb, $tie ) { + $order = (string) preg_replace_callback( + '/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)(?:\s+(ASC|DESC)\b)?/i', + static fn( $id ) => $tie . ( 'DESC' === strtoupper( $id[1] ?? '' ) ? 'DESC' : 'ASC' ), + $m[0] + ); + return (string) preg_replace( '/(,\s*' . preg_quote( $tie, '/' ) . '(ASC|DESC))(\s*,\s*' . preg_quote( $tie, '/' ) . '\2)+/', '$1', $order ); + }, + self::without_group_by( $request ) + ); + } + + /** + * WordPress groups by posts.ID for any tax or meta query. With a flat row + * (one per post) as the only join there is nothing to collapse, and the + * GROUP BY makes MySQL sort the whole match in a temporary table + * (WooCommerce's product_visibility NOT IN on searches is a subquery). + * + * @param string $request SELECT statement. + * @return string + */ + private static function without_group_by( string $request ): string { + global $wpdb; + if ( 1 !== preg_match_all( '/\bJOIN\b/i', $request ) ) { + return $request; + } + return (string) preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', $request, 1 ); } /** @@ -818,13 +868,7 @@ class TMDO_Post_Query_Router { if ( 1 !== $replaced ) { return $request; } - // WordPress groups by posts.ID for any tax or meta query. With the flat row - // (one per post) as the only join there is nothing to collapse, and the - // GROUP BY makes MySQL sort the whole match in a temporary table - // (WooCommerce's product_visibility NOT IN on searches is a subquery). - if ( 1 === preg_match_all( '/\bJOIN\b/i', (string) $flat_first ) ) { - $flat_first = preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', (string) $flat_first, 1 ); - } + $flat_first = self::without_group_by( (string) $flat_first ); // Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat // post_id, once, in the sort's direction: a (column, post_id) index then // returns rows in order with no filesort. Ties are ordered by ID the same diff --git a/tests/integration/PostQueryRouterCopyTest.php b/tests/integration/PostQueryRouterCopyTest.php index 2ce525a..629b928 100644 --- a/tests/integration/PostQueryRouterCopyTest.php +++ b/tests/integration/PostQueryRouterCopyTest.php @@ -31,7 +31,7 @@ class PostQueryRouterCopyTest extends TestCase { protected function setUp(): void { global $wpdb; $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); - $wpdb->query( 'CREATE TABLE `' . self::TABLE . "` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, rc_price DECIMAL(18,6) DEFAULT NULL, post_status VARCHAR(20) DEFAULT NULL, post_date DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id), KEY idx_post_status_date (post_status, post_date) ) ENGINE=InnoDB" ); + $wpdb->query( 'CREATE TABLE `' . self::TABLE . "` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, rc_price DECIMAL(18,6) DEFAULT NULL, post_status VARCHAR(20) DEFAULT NULL, post_date DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id), KEY idx_post_status_date_id (post_status, post_date, post_id) ) ENGINE=InnoDB" ); $wpdb->query( 'INSERT INTO `' . self::TABLE . "` (post_id, rc_price, post_status, post_date) VALUES (1, 10, 'publish', '2026-01-01 00:00:00')" ); $GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'ready' ) ); @@ -58,6 +58,55 @@ class PostQueryRouterCopyTest extends TestCase { $mode->setValue( null, null ); } + /** + * The full request WordPress would build for the routed search, after posts_request. + * + * @param string $extra_join Another JOIN (a tax query's). + * @param string $ties ID tie-breaks plugins appended. + */ + private function request( string $extra_join = '', string $ties = ', {posts}.ID DESC, {posts}.ID DESC' ): string { + global $wpdb; + $ties = str_replace( '{posts}', $wpdb->posts, $ties ); + $query = new WP_Query(); + foreach ( array( + 'post_type' => 'rc_item', + 'post_status' => 'publish', + 'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ), + ) as $k => $v ) { + $query->set( $k, $v ); + } + $router = new TMDO_Post_Query_Router(); + $router->pre_get_posts( $query ); + $request = 'SELECT SQL_CALC_FOUND_ROWS wp_posts.ID FROM wp_posts ' . $router->posts_join( $extra_join, $query ) + . ' WHERE 1=1 ' . $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ) + . " GROUP BY {$wpdb->posts}.ID ORDER BY " . $router->posts_orderby( 'wp_posts.post_date DESC', $query ) . $ties . ' LIMIT 0, 10'; + return $router->posts_request( $request, $query ); + } + + /** + * 2.4 PR-128: with GROUP BY or a posts.ID tie-break MariaDB sorts every + * match (dev36, 235k of 1.3M listings: 1.7 s); without them it walks + * (post_status, post_date, post_id) backwards and stops at the LIMIT (3 ms). + */ + public function test_copy_route_orders_by_the_flat_date_and_post_id_without_group_by(): void { + $sql = $this->request(); + + $this->assertStringNotContainsString( 'GROUP BY', $sql, 'one flat row per post: nothing to collapse' ); + $this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC, ' . self::ALIAS . '.`post_id` DESC LIMIT 0, 10', $sql ); + } + + public function test_copy_route_keeps_each_tie_break_direction(): void { + $this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC, ' . self::ALIAS . '.`post_id` ASC LIMIT 0, 10', $this->request( '', ', {posts}.ID ASC' ) ); + $this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC LIMIT 0, 10', $this->request( '', '' ) ); + } + + public function test_copy_route_keeps_group_by_with_another_join(): void { + global $wpdb; + $sql = $this->request( " LEFT JOIN wp_term_relationships ON ({$wpdb->posts}.ID = wp_term_relationships.object_id)" ); + + $this->assertStringContainsString( "GROUP BY {$wpdb->posts}.ID", $sql ); + } + /** @return array{join:string, where:string, orderby:string} */ private function sql( array $vars ): array { $query = new WP_Query(); diff --git a/tests/integration/PostStatusSyncTest.php b/tests/integration/PostStatusSyncTest.php index bd72132..fb85738 100644 --- a/tests/integration/PostStatusSyncTest.php +++ b/tests/integration/PostStatusSyncTest.php @@ -82,9 +82,34 @@ class PostStatusSyncTest extends TestCase { return $out; } - private function has_index( string $table ): bool { + /** @return array index name → columns */ + private function indexes( string $table ): array { global $wpdb; - return in_array( 'idx_post_status_date', array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ), true ); + $out = array(); + foreach ( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ) as $row ) { + $out[ $row['Key_name'] ][ (int) $row['Seq_in_index'] - 1 ] = $row['Column_name']; + } + return $out; + } + + private function has_index( string $table ): bool { + return array( 'post_status', 'post_date', 'post_id' ) === ( $this->indexes( $table )['idx_post_status_date_id'] ?? null ); + } + + /** + * 2.4 PR-128: the date route's ID tie-break is read from post_id, which the + * index must end with; the 2.2 two-column index is replaced, not kept beside it. + */ + public function test_run_replaces_the_two_column_index_of_existing_tables(): void { + global $wpdb; + $wpdb->query( 'ALTER TABLE `' . self::A . '` ADD COLUMN post_status VARCHAR(20) COLLATE utf8mb4_unicode_520_ci DEFAULT NULL, ADD COLUMN post_date DATETIME DEFAULT NULL, ADD INDEX idx_post_status_date (post_status, post_date)' ); + $GLOBALS['_wp_options'][ TMDO_Post_Status_Sync::OPTION ] = array( 'ps_a' => 'ready' ); + + TMDO_Post_Status_Sync::run(); + + $this->assertTrue( $this->has_index( self::A ) ); + $this->assertArrayNotHasKey( 'idx_post_status_date', $this->indexes( self::A ) ); + $this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) ); } public function test_run_adds_the_columns_copies_every_row_and_marks_groups_ready(): void {