From 84f6e3291371ef96ae11e7863dd467e8434ac751 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 08:31:47 +0800 Subject: [PATCH] perf(2meet-data-optimizer): route sort-only searches from the flat table when every post has a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A sort-only search (HivePress _sort, no routed filter) LEFT JOINs the flat table to keep posts without a row; it now drives from the flat table (STRAIGHT_JOIN, INNER JOIN) when every post of the type has a flat row. The router gives a new post of a type with its own groups an empty row (wp_insert_post); coverage is one anti-join per group, cached 15 min and per post-mode change (forget_coverage()). - A flat-first search drops the GROUP BY posts.ID WordPress adds for any tax query when the flat row is the only join (WooCommerce's product_visibility NOT IN on searches is a subquery); a joining tax query keeps it. - Verify: live router suite (5 new cases, RED before); dev36 rating__desc page, ids identical: native ~3.3 s, 2.1 routing ~1.5–1.9 s, now ~0.51–0.85 s (main query 440 → 46 ms, COUNT 400 → 160 ms). p50 ≤ 300 ms is still not met: page rendering and HivePress's own get_posts() in pre_get_posts (suppress_filters, ~100 ms) remain. - PR: PR-079 --- .../query/class-tmdo-post-query-router.php | 112 +++++++++++++-- tests/live/suites/router.php | 132 ++++++++++++++++++ 2 files changed, 236 insertions(+), 8 deletions(-) diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index 2fe44e8..db90998 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -69,6 +69,9 @@ class TMDO_Post_Query_Router { /** Object cache group for found-rows counts. */ private const COUNT_CACHE_GROUP = 'tmdo_found_rows'; + /** Seconds a "every post of the type has a flat row" answer is reused. */ + private const COVERAGE_TTL = 900; + /** Comparison operators the router can express on a flat column. */ private const COMPARES = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' ); @@ -95,6 +98,30 @@ class TMDO_Post_Query_Router { add_filter( 'split_the_query', array( $this, 'split_the_query' ), 10, 4 ); add_filter( 'posts_request_ids', array( $this, 'posts_request' ), 10, 2 ); add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 ); + add_action( 'wp_insert_post', array( $this, 'add_flat_rows' ), 10, 3 ); + } + + /** + * A new post of a type with its own groups gets an empty row in each, so + * that "every post of the type has a flat row" keeps holding and sort-only + * searches can drive from the flat table (see flat_first()). + * + * @param int $post_id Post ID. + * @param \WP_Post $post Post. + * @param bool $update Whether an existing post was updated. + * @return void + */ + public function add_flat_rows( $post_id, $post, $update ): void { + if ( $update || ! $post instanceof \WP_Post ) { + return; + } + global $wpdb; + foreach ( TMDO_Post_Fields::groups_for_post_type( $post->post_type ) as $group ) { + $table = TMDO_Schema_Manager::get_table_name( 'post', $group ); + if ( TMDO_Schema_Manager::table_exists( $table ) ) { + $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$table}` (`post_id`) VALUES (%d)", (int) $post_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + } } /** @@ -422,6 +449,57 @@ class TMDO_Post_Query_Router { self::$copies_usable = array(); } + /** + * Every post of the type has a row in the group's flat table. One + * anti-join per group, reused for COVERAGE_TTL and per post-mode change. + * A post inserted by direct SQL (no wp_insert_post) is seen at the next + * check at the latest; the `tmdo_router_flat_first` filter turns the + * flat-first route off. + * + * @param string $post_type Post type. + * @param string $group Entity group name. + * @return bool + */ + private static function covers_all( string $post_type, string $group ): bool { + $key = self::coverage_key( $post_type, $group ); + $hit = get_transient( $key ); + if ( false === $hit ) { + global $wpdb; + $table = TMDO_Schema_Manager::get_table_name( 'post', $group ); + $missing = $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->posts} p LEFT JOIN `{$table}` f ON f.post_id = p.ID WHERE p.post_type = %s AND f.post_id IS NULL LIMIT 1", $post_type ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $hit = null === $missing ? 'yes' : 'no'; + set_transient( $key, $hit, self::COVERAGE_TTL ); + } + return 'yes' === $hit; + } + + /** + * Forget every cached coverage answer (tests, or after bulk writes that + * bypass wp_insert_post). + * + * @return void + */ + public static function forget_coverage(): void { + foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) { + foreach ( TMDO_Entity_Registry::get_group_post_types( 'post', $group ) as $post_type ) { + delete_transient( self::coverage_key( $post_type, $group ) ); + } + } + } + + /** + * Transient name of a coverage answer; a post-mode change starts a new one. + * + * @param string $post_type Post type. + * @param string $group Entity group name. + * @return string + */ + private static function coverage_key( string $post_type, string $group ): string { + $entered = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() ); + $since = is_array( $entered ) ? (int) ( $entered['post'] ?? 0 ) : 0; + return 'tmdo_cov_' . md5( "{$post_type}:{$group}:{$since}" ); + } + /** * The status / date copy route for a routed query, or [] when it does not apply. * @@ -621,14 +699,25 @@ class TMDO_Post_Query_Router { private static function flat_first( string $request, \WP_Query $query ): string { $routed = $query->get( self::QUERY_VAR ); $sort = $query->get( self::SORT_VAR ); - if ( empty( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) { + if ( empty( $sort ) || ! is_array( $sort ) || ! empty( $query->get( self::COPY_VAR ) ) ) { return $request; } - $post_type = (string) array_key_first( $routed ); - $groups = array_unique( array_column( $routed[ $post_type ], 'group' ) ); - $compares = array_diff( array_column( $routed[ $post_type ], 'compare' ), array( 'NOT EXISTS' ) ); - if ( empty( $sort ) || 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) { - return $request; + if ( empty( $routed ) || ! is_array( $routed ) ) { + // Sort only: the LEFT JOIN keeps posts without a flat row, so the + // flat table may drive only when every post of the type has one. + $post_type = (string) $sort['post_type']; + $group = (string) $sort['group']; + if ( ! self::covers_all( $post_type, $group ) ) { + return $request; + } + } else { + $post_type = (string) array_key_first( $routed ); + $groups = array_unique( array_column( $routed[ $post_type ], 'group' ) ); + $compares = array_diff( array_column( $routed[ $post_type ], 'compare' ), array( 'NOT EXISTS' ) ); + if ( 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) { + return $request; + } + $group = (string) $groups[0]; } foreach ( array( 'p', 'page_id', 'name', 'pagename', 'attachment_id', 'post__in', 'post_parent', 'post_parent__in', 'author', 'author__in', 'author_name' ) as $var ) { if ( ! empty( $query->get( $var ) ) ) { @@ -640,8 +729,8 @@ class TMDO_Post_Query_Router { } global $wpdb; - $table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $groups[0] ); - $alias = self::table_alias( $post_type, $groups[0] ); + $table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group ); + $alias = self::table_alias( $post_type, $group ); $on = "(`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)"; $join = " LEFT JOIN `{$table}` AS `{$alias}` ON {$on}"; if ( 1 !== substr_count( $request, $join ) ) { @@ -657,6 +746,13 @@ 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 ); + } // Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat post_id. $flat_first = preg_replace_callback( '/\bORDER BY\b(?!.*\bORDER BY\b).*$/s', diff --git a/tests/live/suites/router.php b/tests/live/suites/router.php index 147516d..40f326c 100644 --- a/tests/live/suites/router.php +++ b/tests/live/suites/router.php @@ -424,3 +424,135 @@ TMDO_Live::test( tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request ); } ); + +/** + * HivePress-style sort-only search (rating__order), fields ids, first page. + * + * @param string $order ASC | DESC. + * @return array WP_Query args. + */ +function tmdo_live_router_rating_sort( string $order ): array { + return array( + 'post_type' => 'hp_listing', + 'post_status' => 'publish', + 'posts_per_page' => 10, + 'fields' => 'ids', + 'suppress_filters' => false, + 'meta_query' => array( + array( + 'relation' => 'OR', + 'rating__order' => array( 'key' => 'hp_rating', 'type' => 'DECIMAL', 'compare' => 'NOT EXISTS' ), + array( 'key' => 'hp_rating', 'type' => 'DECIMAL', 'compare' => 'EXISTS' ), + ), + ), + 'orderby' => 'rating__order', + 'order' => $order, + ); +} + +/** + * Native and routed runs of the same query. + * + * @param array $args Query args. + * @return array{0:WP_Query, 1:WP_Query} + */ +function tmdo_live_router_native_and_routed( array $args ): array { + $native = new WP_Query( $args ); + $router = new TMDO_Post_Query_Router(); + $router->register_hooks(); + try { + $routed = new WP_Query( $args ); + } finally { + tmdo_live_router_unhook( $router ); + } + return array( $native, $routed ); +} + +TMDO_Live::test( + 'a sort-only search drives from the flat table when every post of the type has a flat row (2.2 PR-079)', + static function () { + TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + TMDO_Post_Query_Router::forget_coverage(); + foreach ( array( 'DESC', 'ASC' ) as $order ) { + list( $native, $routed ) = tmdo_live_router_native_and_routed( tmdo_live_router_rating_sort( $order ) ); + tmdo_live_assert_same( array_map( 'intval', $native->posts ), array_map( 'intval', $routed->posts ), "{$order} ids" ); + tmdo_live_assert_same( $native->found_posts, $routed->found_posts, "{$order} found_posts" ); + tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), "{$order} flat-first: " . $routed->request ); + } + } +); + +TMDO_Live::test( + 'a sort-only search keeps the LEFT JOIN while a post of the type has no flat row (2.2 PR-079)', + static function () { + global $wpdb; + TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + $bare = TMDO_Live_Fixtures::post( 'hp_listing' ); + $wpdb->delete( TMDO_Schema_Manager::get_table_name( 'post', 'hp_listing_core' ), array( 'post_id' => $bare ) ); + TMDO_Post_Query_Router::forget_coverage(); + try { + list( $native, $routed ) = tmdo_live_router_native_and_routed( tmdo_live_router_rating_sort( 'DESC' ) ); + } finally { + TMDO_Post_Query_Router::forget_coverage(); + } + tmdo_live_assert_same( array_map( 'intval', $native->posts ), array_map( 'intval', $routed->posts ), 'ids' ); + tmdo_live_assert_true( false === strpos( $routed->request, 'STRAIGHT_JOIN' ), 'not flat-first: ' . $routed->request ); + } +); + +TMDO_Live::test( + 'a post created while the router is active gets its empty flat row (2.2 PR-079)', + static function () { + global $wpdb; + TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + $router = new TMDO_Post_Query_Router(); + $router->register_hooks(); + try { + $id = TMDO_Live_Fixtures::post( 'hp_listing' ); + } finally { + tmdo_live_router_unhook( $router ); + } + $table = TMDO_Schema_Manager::get_table_name( 'post', 'hp_listing_core' ); + tmdo_live_assert_same( '1', $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE post_id = %d", $id ) ), 'flat row created' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } +); + +TMDO_Live::test( + 'a flat-first search drops the GROUP BY WordPress adds for a subquery-only tax query (2.2 PR-079)', + static function () { + TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + TMDO_Post_Query_Router::forget_coverage(); + // Like WooCommerce's product_visibility exclusion on front-end searches: NOT IN + // is a subquery, yet WordPress groups by posts.ID for any tax query. + $args = tmdo_live_router_rating_sort( 'DESC' ) + array( + 'tax_query' => array( array( 'taxonomy' => 'hp_listing_category', 'field' => 'term_id', 'terms' => array( 999999999 ), 'operator' => 'NOT IN' ) ), + ); + list( $native, $routed ) = tmdo_live_router_native_and_routed( $args ); + tmdo_live_assert_same( array_map( 'intval', $native->posts ), array_map( 'intval', $routed->posts ), 'ids' ); + tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request ); + tmdo_live_assert_true( false === stripos( $routed->request, 'GROUP BY' ), 'no GROUP BY: ' . $routed->request ); + } +); + +TMDO_Live::test( + 'a flat-first search keeps the GROUP BY when a tax query joins term relationships (2.2 PR-079)', + static function () { + TMDO_Live_Fixtures::field( 'post', 'hp_rating', 'hp_listing' ); + TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) ); + TMDO_Post_Query_Router::forget_coverage(); + $terms = get_terms( array( 'taxonomy' => 'hp_listing_category', 'number' => 2, 'hide_empty' => true, 'fields' => 'ids' ) ); + if ( count( (array) $terms ) < 2 ) { + throw new TMDO_Live_Skip( 'needs two used hp_listing_category terms' ); + } + $args = tmdo_live_router_rating_sort( 'DESC' ) + array( + 'tax_query' => array( array( 'taxonomy' => 'hp_listing_category', 'field' => 'term_id', 'terms' => $terms, 'operator' => 'IN' ) ), + ); + list( $native, $routed ) = tmdo_live_router_native_and_routed( $args ); + tmdo_live_assert_same( array_map( 'intval', $native->posts ), array_map( 'intval', $routed->posts ), 'ids' ); + tmdo_live_assert_true( false !== stripos( $routed->request, 'GROUP BY' ), 'GROUP BY kept: ' . $routed->request ); + } +);