diff --git a/docs/BENCHMARK-SCALE.md b/docs/BENCHMARK-SCALE.md index d522e1b..ef27d17 100644 --- a/docs/BENCHMARK-SCALE.md +++ b/docs/BENCHMARK-SCALE.md @@ -55,6 +55,7 @@ SQL 的形狀與實際程式一致:原生是 WP_Query 的 meta_query(每個 2. **扁平表的價值集中在「搜尋與篩選」。** 單筆或批次讀 meta(情境 4–6)三種形式都在 1 ms 內,差距可以忽略,而且實務上還有 object cache。 3. **寬範圍搜尋仍有瓶頸(情境 1,p50 約 0.6 秒、p95 約 3.7 秒)。** 命中 18 萬筆時,優化器選擇全表掃描;再加上 `SQL_CALC_FOUND_ROWS` 要算總數、`ORDER BY post_date` 要排序,還要 JOIN 回 `posts` 過濾狀態。p50 和 p95 差很多,是因為不同的價格區間讓優化器選了不同的執行計畫。 - **PR-028**:post 群組表可存 `post_status`、`post_date` 副本與 `(post_status, post_date)` 索引(`wp tmdo post-status-sync`)。1M 列實測:加欄位 0.05 秒(instant)、建索引 5.3 秒、分批(1,000 列)回填 32 秒,之後與 `posts` 0 筆不一致。欄位 collation 必須跟 `posts.post_status` 相同,否則比對會出現 illegal mix of collations。查詢改寫在 PR-029。 + - **PR-029**:副本 ready 且無 NULL 時,單一 post type、明確狀態、依日期排序的搜尋改為 INNER JOIN、`post_status IN (…)`、`ORDER BY` 副本 `post_date`、不 GROUP BY(bench 變體 `entity+copy`,`--iterations=5`)。仍帶 `SQL_CALC_FOUND_ROWS` 時:情境 1 p50 670→770 ms、**p95 3,739→773 ms**;情境 2 p50 356→448 ms(變慢,因為算總數仍要掃完所有符合列)。拿掉 `SQL_CALC_FOUND_ROWS` 後同形狀情境 1 只要 **8 ms**(走 `idx_post_status_date`、LIMIT 提早結束)→ 完整效益要搭配 PR-030。 4. **Hot 表的索引規則有漏洞。** `add_covering_indexes()` 只替 decimal、tinyint,以及名稱以 `_time` 結尾的 bigint 建索引,所以 `hp_vendor` 沒有索引,情境 3 要 3.4 秒,比 Entity 慢約 12,000 倍。 5. **Entity 表只有單欄索引。** 情境 2 輸給有複合索引的 Hot 表(337 ms 對 277 ms)。 - **PR-027 之後**:群組可用 `register_group( ..., array( 'indexes' => ... ) )` 宣告複合索引。在 1M 列的 Entity 表加上 `cidx_verified_price (hp_verified, hp_price)` 花 8.1 秒;重跑情境 2(`--iterations=10`),Entity p50 從 337 ms 降到 **278 ms**(p95 285 ms),Hot 是 269 ms(p95 280 ms),差距在量測誤差內。執行計畫改走 `e:cidx_verified_price`。 diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index 8060ebc..2b486f4 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -20,6 +20,13 @@ * 3. posts_where — append WHERE conditions targeting flat columns * 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication * + * Status / date on the flat table (O-1): when the group's post_status / + * 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, orders by the copied + * post_date and skips GROUP BY (one row per post), so MySQL can walk + * `idx_post_status_date` and stop at the LIMIT. + * * 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes * only entity_type='post'. * @@ -43,6 +50,16 @@ class TMDO_Post_Query_Router { /** Query var key for capturing routed clauses (consumed by posts_join/where). */ private const QUERY_VAR = 'wpdo_post_clauses'; + /** Query var holding the status / date copy route, when one applies. */ + private const COPY_VAR = 'wpdo_post_copy_route'; + + /** + * Per-request answer to "are this group's copies usable?". + * + * @var array + */ + private static array $copies_usable = array(); + /** * Register all WP_Query hooks. * @@ -53,6 +70,7 @@ class TMDO_Post_Query_Router { add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 ); add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 ); add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 ); + add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 ); } /** @@ -134,6 +152,11 @@ class TMDO_Post_Query_Router { $query->set( 'meta_query', $remaining ); $query->set( self::QUERY_VAR, $routed ); + + $copy = self::copy_route( $query, $routed ); + if ( $copy ) { + $query->set( self::COPY_VAR, $copy ); + } } /** @@ -167,7 +190,8 @@ class TMDO_Post_Query_Router { continue; } - $join .= " LEFT JOIN `{$table}` AS `{$alias}` " + $type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT'; + $join .= " {$type} JOIN `{$table}` AS `{$alias}` " . "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)"; } @@ -201,9 +225,31 @@ class TMDO_Post_Query_Router { } } + $copy = $query->get( self::COPY_VAR ); + if ( ! empty( $copy ) ) { + $alias = self::table_alias( $copy['post_type'], $copy['group'] ); + $where .= self::build_condition( "`{$alias}`.`post_status`", 'IN', $copy['statuses'] ); + } + return $where; } + /** + * Order by the flat table's post_date copy when the copy route applies. + * + * @param string|null $orderby Current ORDER BY SQL. + * @param \WP_Query $query Current WP_Query. + * @return string Modified ORDER BY SQL. + */ + public function posts_orderby( ?string $orderby, \WP_Query $query ): string { + $orderby = (string) ( $orderby ?? '' ); + $copy = $query->get( self::COPY_VAR ); + if ( empty( $copy ) ) { + return $orderby; + } + return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order']; + } + /** * Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows. * @@ -214,7 +260,7 @@ class TMDO_Post_Query_Router { public function posts_groupby( ?string $groupby, \WP_Query $query ): string { $groupby = (string) ( $groupby ?? '' ); $routed = $query->get( self::QUERY_VAR ); - if ( empty( $routed ) || ! is_array( $routed ) ) { + if ( empty( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) { return $groupby; } @@ -242,6 +288,76 @@ class TMDO_Post_Query_Router { return TMDO_Mode_Manager::reads_from_flat( 'post' ); } + /** + * Forget per-request copy checks (tests, or after `post-status-sync`). + * + * @return void + */ + public static function flush_copy_cache(): void { + self::$copies_usable = array(); + } + + /** + * The status / date copy route for a routed query, or [] when it does not apply. + * + * @param \WP_Query $query Current WP_Query. + * @param array> $routed Routed clauses keyed by post type. + * @return array{post_type:string, group:string, statuses:string[], order:string}|array{} + */ + private static function copy_route( \WP_Query $query, array $routed ): array { + if ( 1 !== count( $routed ) ) { + return array(); + } + $post_type = (string) array_key_first( $routed ); + $groups = array_values( array_unique( array_column( $routed[ $post_type ], 'group' ) ) ); + if ( 1 !== count( $groups ) || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true ) ) { + return array(); + } + $statuses = $query->get( 'post_status' ); + $statuses = array_values( array_filter( array_map( 'sanitize_key', is_array( $statuses ) ? $statuses : explode( ',', (string) $statuses ) ) ) ); + if ( empty( $statuses ) || in_array( 'any', $statuses, true ) || ! self::copies_usable( $groups[0] ) ) { + return array(); + } + return array( + 'post_type' => $post_type, + 'group' => $groups[0], + 'statuses' => $statuses, + 'order' => 'ASC' === strtoupper( (string) $query->get( 'order' ) ) ? 'ASC' : 'DESC', + ); + } + + /** + * Whether the copy route applies to this post type's join. + * + * @param \WP_Query $query Current WP_Query. + * @param string $post_type Post type slug. + * @return bool + */ + private static function copy_route_for( \WP_Query $query, string $post_type ): bool { + $copy = $query->get( self::COPY_VAR ); + return ! empty( $copy ) && $copy['post_type'] === $post_type; + } + + /** + * Copies are marked ready and no row lacks them (bulk writes that bypass + * the Hook Bus leave NULLs until `wp tmdo post-status-sync` runs again). + * One index probe per group per request. + * + * @param string $group Entity group name. + * @return bool + */ + private static function copies_usable( string $group ): bool { + if ( ! isset( self::$copies_usable[ $group ] ) ) { + global $wpdb; + $table = TMDO_Schema_Manager::get_table_name( 'post', $group ); + self::$copies_usable[ $group ] = class_exists( 'TMDO_Post_Status_Sync' ) + && 'ready' === TMDO_Post_Status_Sync::state( $group ) + && TMDO_Schema_Manager::table_exists( $table ) + && null === $wpdb->get_var( "SELECT 1 FROM `{$table}` WHERE `post_status` IS NULL LIMIT 1" ); + } + return self::$copies_usable[ $group ]; + } + /** * Generate a deterministic JOIN alias for a (post_type, group) pair. * diff --git a/tests/bench/run.php b/tests/bench/run.php index 07d7c99..d725080 100644 --- a/tests/bench/run.php +++ b/tests/bench/run.php @@ -76,6 +76,12 @@ function flat_search( string $table, string $alias, string $conditions ): string . " WHERE 1=1 AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')){$conditions} GROUP BY p.ID ORDER BY p.post_date DESC LIMIT 0, 20"; } +/** TMDO router shape with post_status / post_date copies (PR-029): INNER JOIN, no GROUP BY. */ +function flat_copy_search( string $table, string $alias, string $conditions ): string { + return 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p INNER JOIN `" . P . "{$table}` AS `{$alias}` ON (p.ID = `{$alias}`.`post_id`)" + . " WHERE 1=1 AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')){$conditions} AND `{$alias}`.`post_status` IN ('publish') ORDER BY `{$alias}`.`post_date` DESC LIMIT 0, 20"; +} + function random_ids( int $count, int $offset, int $max ): string { $ids = array(); for ( $i = 0; $i < $count; $i++ ) { @@ -105,6 +111,10 @@ $scenarios = array( [ $lo, $hi ] = $price(); return array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}'" ), 'SELECT FOUND_ROWS()' ); }, + 'entity+copy' => function () use ( $price ) { + [ $lo, $hi ] = $price(); + return array( flat_copy_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}'" ), 'SELECT FOUND_ROWS()' ); + }, ), '2. 價格區間 + verified=1' => array( 'native' => function () use ( $price ) { @@ -127,6 +137,10 @@ $scenarios = array( [ $lo, $hi ] = $price(); return array( flat_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}' AND `e`.`hp_verified` = '1'" ), 'SELECT FOUND_ROWS()' ); }, + 'entity+copy' => function () use ( $price ) { + [ $lo, $hi ] = $price(); + return array( flat_copy_search( 'wpdo_post_hp_listing_core', 'e', " AND `e`.`hp_price` BETWEEN '{$lo}' AND '{$hi}' AND `e`.`hp_verified` = '1'" ), 'SELECT FOUND_ROWS()' ); + }, ), '3. 某 vendor 名下 listing' => array( 'post_parent' => fn() => array( 'SELECT SQL_CALC_FOUND_ROWS p.ID FROM `' . P . "posts` p WHERE p.post_parent = " . mt_rand( 1, $vendors ) . " AND p.post_type = 'hp_listing' AND ((p.post_status = 'publish')) ORDER BY p.post_date DESC LIMIT 0, 20", 'SELECT FOUND_ROWS()' ), diff --git a/tests/integration/PostQueryRouterCopyTest.php b/tests/integration/PostQueryRouterCopyTest.php new file mode 100644 index 0000000..e0695f6 --- /dev/null +++ b/tests/integration/PostQueryRouterCopyTest.php @@ -0,0 +1,126 @@ +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( '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' ) ); + $GLOBALS['_wp_is_admin'] = false; + $ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' ); + $ref->setAccessible( true ); + $ref->setValue( null, array() ); + $mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); + $mode->setAccessible( true ); + $mode->setValue( null, array( 'post' => 'aeav_only', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) ); + TMDO_Post_Query_Router::flush_copy_cache(); + + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); + WPDO_Entity_Registry::register_group( 'post', 'rc_core', array( array( 'key' => 'rc_price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'rc_item' ) ) ); + } + + protected function tearDown(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + WPDO_Entity_Registry::init(); + $mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); + $mode->setAccessible( true ); + $mode->setValue( null, null ); + } + + /** @return array{join:string, where:string, orderby:string, groupby:string} */ + private function sql( array $vars ): array { + $query = new WP_Query(); + foreach ( array_merge( + array( + 'post_type' => 'rc_item', + 'post_status' => 'publish', + 'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN' ) ), + ), + $vars + ) as $k => $v ) { + $query->set( $k, $v ); + } + $router = new TMDO_Post_Query_Router(); + $router->pre_get_posts( $query ); + return array( + 'join' => $router->posts_join( '', $query ), + 'where' => $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ), + 'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ), + 'groupby' => $router->posts_groupby( '', $query ), + ); + } + + private function assert_legacy_shape( array $sql ): void { + $this->assertStringContainsString( 'LEFT JOIN', $sql['join'] ); + $this->assertStringNotContainsString( self::ALIAS . '.`post_status`', $sql['where'] ); + $this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] ); + $this->assertSame( '`wp_itest_posts`.`ID`', $sql['groupby'] ); + } + + public function test_ready_copies_filter_status_and_order_by_date_on_the_flat_table(): void { + $sql = $this->sql( array( 'order' => 'ASC' ) ); + + $this->assertStringContainsString( 'INNER JOIN', $sql['join'] ); + $this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish')", $sql['where'] ); + $this->assertStringContainsString( "wp_posts.post_status = 'publish'", $sql['where'], 'WordPress status clause stays' ); + $this->assertSame( self::ALIAS . '.`post_date` ASC', $sql['orderby'] ); + $this->assertSame( '', $sql['groupby'], 'one row per post: no GROUP BY, so LIMIT can stop early' ); + } + + public function test_several_explicit_statuses_are_kept(): void { + $sql = $this->sql( array( 'post_status' => array( 'publish', 'private' ), 'orderby' => 'date' ) ); + + $this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish','private')", $sql['where'] ); + $this->assertSame( self::ALIAS . '.`post_date` DESC', $sql['orderby'] ); + } + + public function test_copies_not_marked_ready_keep_the_legacy_shape(): void { + $GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'columns' ) ); + + $this->assert_legacy_shape( $this->sql( array() ) ); + } + + public function test_rows_with_missing_copies_keep_the_legacy_shape(): void { + global $wpdb; + $wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, rc_price) VALUES (2, 12)' ); + + $this->assert_legacy_shape( $this->sql( array() ) ); + } + + public function test_other_orderings_or_implicit_statuses_keep_the_legacy_shape(): void { + $this->assert_legacy_shape( $this->sql( array( 'orderby' => 'title' ) ) ); + $this->assert_legacy_shape( $this->sql( array( 'post_status' => '' ) ) ); + $this->assert_legacy_shape( $this->sql( array( 'post_status' => 'any' ) ) ); + } +}