From ca2cbdab659ac93603065b1b080fcc6bcee68f91 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:46:54 +0800 Subject: [PATCH] perf(2meet-data-optimizer): keep cached routed counts across unrelated post meta writes - cached counts followed posts' last_changed, which WordPress moves on any post meta write; themes write meta on page views (on dev36 Blocksy saved a dynamic-CSS descriptor for each listing a crawler visited), so the cache was invalidated every few seconds and keyword/filter searches recounted - counts now follow their own last_changed group (tmdo_posts), bumped on clean_post_cache (post saved or deleted; hooked in every mode) and on flat writes through the Hook Bus, plus terms' last_changed; a routed search reads no unmanaged meta - Verify: PostQueryRouterFoundRowsTest (unrelated meta write keeps the count, save or flat write recounts), integration 588, unit 703, phpcs, stan; dev36 front-end - PR: PR-117 (2.3 acceptance) --- includes/class-tmdo-core.php | 3 +++ includes/engine/class-tmdo-hook-bus.php | 1 + .../query/class-tmdo-post-query-router.php | 23 ++++++++++++++++--- .../PostQueryRouterFoundRowsTest.php | 9 ++++++-- tests/integration/bootstrap.php | 1 + 5 files changed, 32 insertions(+), 5 deletions(-) diff --git a/includes/class-tmdo-core.php b/includes/class-tmdo-core.php index 3a116bb..976c922 100644 --- a/includes/class-tmdo-core.php +++ b/includes/class-tmdo-core.php @@ -261,6 +261,9 @@ class TMDO_Core { $post_router = new TMDO_Post_Query_Router(); $post_router->register_hooks(); } + if ( class_exists( 'TMDO_Post_Query_Router' ) ) { + add_action( 'clean_post_cache', array( 'TMDO_Post_Query_Router', 'bump_count_version' ) ); + } // Zone B: Warm cleanup cron handler. add_action( 'wpdo_warm_cleanup', array( $this, 'cleanup_warm_zone' ) ); diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php index 46f001a..545dcae 100644 --- a/includes/engine/class-tmdo-hook-bus.php +++ b/includes/engine/class-tmdo-hook-bus.php @@ -912,6 +912,7 @@ final class TMDO_Hook_Bus { private static function touch_posts_last_changed( string $type ): void { if ( 'post' === $type ) { wp_cache_set_posts_last_changed(); + TMDO_Post_Query_Router::bump_count_version(); } } diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index b05e5cb..ede1648 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -31,8 +31,11 @@ * * 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 - * same query, kept in the object cache until posts' last_changed moves (core - * post saves, and flat writes via TMDO_Hook_Bus). + * same query, kept in the object cache until a post is saved or deleted, a + * flat write goes through TMDO_Hook_Bus (bump_count_version()) or terms' + * last_changed moves. Not posts' last_changed: WordPress moves it on any post + * meta write, which themes do on page views (Blocksy's dynamic CSS), and a + * routed search reads no unmanaged meta. * * 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes * only entity_type='post'. @@ -69,6 +72,9 @@ class TMDO_Post_Query_Router { /** Object cache group for found-rows counts. */ private const COUNT_CACHE_GROUP = 'tmdo_found_rows'; + /** The last_changed group the cached counts follow (bump_count_version()). */ + public const COUNT_VERSION_GROUP = 'tmdo_posts'; + /** Seconds a "every post of the type has a flat row" answer is reused. */ private const COVERAGE_TTL = 900; @@ -436,7 +442,7 @@ class TMDO_Post_Query_Router { // Keyword searches carry wpdb's placeholder escape for "%", which is new // in every request: without it the key would never match again. $stable = method_exists( $wpdb, 'remove_placeholder_escape' ) ? $wpdb->remove_placeholder_escape( $count_sql ) : $count_sql; - $key = md5( $stable ) . ':' . wp_cache_get_last_changed( 'posts' ); + $key = md5( $stable ) . ':' . wp_cache_get_last_changed( self::COUNT_VERSION_GROUP ) . ':' . wp_cache_get_last_changed( 'terms' ); $total = wp_cache_get( $key, self::COUNT_CACHE_GROUP ); if ( false === $total ) { $total = (int) $wpdb->get_var( $count_sql ); @@ -452,6 +458,17 @@ class TMDO_Post_Query_Router { // Helpers // ───────────────────────────────────────────────────────────────────────── + /** + * A post was saved or deleted, or a managed key written: cached routed + * counts are stale. Hooked on clean_post_cache whatever the mode, so a + * count cached before the router was last active is not reused. + * + * @return void + */ + public static function bump_count_version(): void { + wp_cache_set_last_changed( self::COUNT_VERSION_GROUP ); + } + /** * Whether the router is mode-active (post mode is shadow_read or aeav_only). * diff --git a/tests/integration/PostQueryRouterFoundRowsTest.php b/tests/integration/PostQueryRouterFoundRowsTest.php index c593b65..a806a54 100644 --- a/tests/integration/PostQueryRouterFoundRowsTest.php +++ b/tests/integration/PostQueryRouterFoundRowsTest.php @@ -128,7 +128,10 @@ class PostQueryRouterFoundRowsTest extends TestCase { $wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 2' ); $this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'cached' ); - wp_cache_set_posts_last_changed(); + wp_cache_set_posts_last_changed(); // Any post meta write, e.g. a theme's CSS descriptor on a page view. + $this->assertSame( '2', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'still cached' ); + + TMDO_Post_Query_Router::bump_count_version(); // A post saved, or a managed key written. $this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' ); } @@ -153,7 +156,7 @@ class PostQueryRouterFoundRowsTest extends TestCase { $this->assertSame( '2', $count( '1' ), 'one cached count serves every exclusion list; 1 does not match' ); $this->assertSame( '1', $count( '2' ) ); - wp_cache_set_posts_last_changed(); + TMDO_Post_Query_Router::bump_count_version(); $this->assertSame( '0', $count( '2' ), 'recounted' ); } @@ -203,6 +206,7 @@ class PostQueryRouterFoundRowsTest extends TestCase { public function test_flat_writes_and_deletes_move_posts_last_changed(): void { $GLOBALS['_wp_current_filter'] = 'update_post_metadata'; $before = wp_cache_get_last_changed( 'posts' ); + $version = wp_cache_get_last_changed( TMDO_Post_Query_Router::COUNT_VERSION_GROUP ); WPDO_Hook_Bus::intercept_update( null, 1, 'fr_price', '11', '' ); $after_write = wp_cache_get_last_changed( 'posts' ); @@ -211,6 +215,7 @@ class PostQueryRouterFoundRowsTest extends TestCase { $this->assertNotSame( $before, $after_write ); $this->assertNotSame( $after_write, wp_cache_get_last_changed( 'posts' ) ); + $this->assertNotSame( $version, wp_cache_get_last_changed( TMDO_Post_Query_Router::COUNT_VERSION_GROUP ), 'routed counts follow flat writes' ); } // ── flat-first: a filtered search ordered by a flat column ───────────── diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 99679d6..eddae3c 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -434,6 +434,7 @@ if ( ! function_exists( 'wp_mkdir_p' ) ) { $GLOBALS['_wp_cache'] = []; if ( ! function_exists( 'wp_cache_get_last_changed' ) ) { function wp_cache_get_last_changed( $group ) { return $GLOBALS['_wp_last_changed'][ $group ] ??= '0'; } } +if ( ! function_exists( 'wp_cache_set_last_changed' ) ) { function wp_cache_set_last_changed( $group ) { $GLOBALS['_wp_last_changed'][ $group ] = (string) hrtime( true ); return $GLOBALS['_wp_last_changed'][ $group ]; } } if ( ! function_exists( 'wp_cache_set_posts_last_changed' ) ) { function wp_cache_set_posts_last_changed() { $GLOBALS['_wp_last_changed']['posts'] = (string) hrtime( true ); } } if ( ! function_exists( 'wp_cache_get' ) ) { function wp_cache_get( $key, $group = '' ) { return $GLOBALS['_wp_cache'][ $group ][ $key ] ?? false; } } if ( ! function_exists( 'wp_cache_set' ) ) { function wp_cache_set( $key, $value, $group = '', $ttl = 0 ): bool { $GLOBALS['_wp_cache'][ $group ][ $key ] = $value; return true; } }