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
This commit is contained in:
2026-09-27 23:12:34 +08:00
parent 2aa9058ee9
commit 8ee3dce0fe
4 changed files with 141 additions and 15 deletions
@@ -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;
+54 -10
View File
@@ -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
+50 -1
View File
@@ -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();
+27 -2
View File
@@ -82,9 +82,34 @@ class PostStatusSyncTest extends TestCase {
return $out;
}
private function has_index( string $table ): bool {
/** @return array<string, string[]> 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 {