perf(2meet-data-optimizer): route sort-only searches from the flat table when every post has a row
- 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
This commit is contained in:
@@ -69,6 +69,9 @@ class TMDO_Post_Query_Router {
|
|||||||
/** Object cache group for found-rows counts. */
|
/** Object cache group for found-rows counts. */
|
||||||
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
|
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. */
|
/** 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' );
|
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( 'split_the_query', array( $this, 'split_the_query' ), 10, 4 );
|
||||||
add_filter( 'posts_request_ids', array( $this, 'posts_request' ), 10, 2 );
|
add_filter( 'posts_request_ids', array( $this, 'posts_request' ), 10, 2 );
|
||||||
add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 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();
|
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.
|
* 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 {
|
private static function flat_first( string $request, \WP_Query $query ): string {
|
||||||
$routed = $query->get( self::QUERY_VAR );
|
$routed = $query->get( self::QUERY_VAR );
|
||||||
$sort = $query->get( self::SORT_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;
|
return $request;
|
||||||
}
|
}
|
||||||
$post_type = (string) array_key_first( $routed );
|
if ( empty( $routed ) || ! is_array( $routed ) ) {
|
||||||
$groups = array_unique( array_column( $routed[ $post_type ], 'group' ) );
|
// Sort only: the LEFT JOIN keeps posts without a flat row, so the
|
||||||
$compares = array_diff( array_column( $routed[ $post_type ], 'compare' ), array( 'NOT EXISTS' ) );
|
// flat table may drive only when every post of the type has one.
|
||||||
if ( empty( $sort ) || 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) {
|
$post_type = (string) $sort['post_type'];
|
||||||
return $request;
|
$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 ) {
|
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 ) ) ) {
|
if ( ! empty( $query->get( $var ) ) ) {
|
||||||
@@ -640,8 +729,8 @@ class TMDO_Post_Query_Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $groups[0] );
|
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
|
||||||
$alias = self::table_alias( $post_type, $groups[0] );
|
$alias = self::table_alias( $post_type, $group );
|
||||||
$on = "(`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
|
$on = "(`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
|
||||||
$join = " LEFT JOIN `{$table}` AS `{$alias}` ON {$on}";
|
$join = " LEFT JOIN `{$table}` AS `{$alias}` ON {$on}";
|
||||||
if ( 1 !== substr_count( $request, $join ) ) {
|
if ( 1 !== substr_count( $request, $join ) ) {
|
||||||
@@ -657,6 +746,13 @@ class TMDO_Post_Query_Router {
|
|||||||
if ( 1 !== $replaced ) {
|
if ( 1 !== $replaced ) {
|
||||||
return $request;
|
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.
|
// Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat post_id.
|
||||||
$flat_first = preg_replace_callback(
|
$flat_first = preg_replace_callback(
|
||||||
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
|
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
|
||||||
|
|||||||
@@ -424,3 +424,135 @@ TMDO_Live::test(
|
|||||||
tmdo_live_assert_true( 0 === strpos( ltrim( $routed->request ), 'SELECT STRAIGHT_JOIN' ), 'flat-first: ' . $routed->request );
|
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<string, mixed> 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<string, mixed> $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 );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user