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