From ec8d69a6f427f30bf3537cad8b4b28fa08bab779 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 16:07:23 +0800 Subject: [PATCH 01/20] fix(2meet-data-optimizer): add declared flat-table indexes from WP-CLI or WP-Cron only, one process at a time - ensure_group_indexes() runs the ALTER only under WP-CLI or WP-Cron (filter tmdo_schema_ddl_allowed); web requests, including /wp-admin/ before auth_redirect() and admin-post.php, schedule tmdo_ensure_group_indexes instead - MySQL GET_LOCK keeps it to one process (add_option() is INSERT ... ON DUPLICATE KEY UPDATE and cannot serve as a lock); released by MySQL if the process dies - the applied signature is recorded only when every ALTER succeeds; failures log schema.index_failed and stay in pending_indexes(), which doctor reports - new tables get their declared cidx_ indexes in CREATE TABLE - test stub wpdb::query()/insert() return false on SQL errors like wpdb - Verify: GroupCompositeIndexTest 7/7, unit 698, integration 576, phpcs, stan - PR: PR-090a --- cli/class-tmdo-cli.php | 4 + includes/class-tmdo-core.php | 3 + includes/class-tmdo-installer.php | 2 + includes/engine/class-tmdo-schema-manager.php | 123 ++++++++++++++++-- tests/integration/GroupCompositeIndexTest.php | 79 +++++++++++ tests/integration/bootstrap.php | 16 ++- 6 files changed, 214 insertions(+), 13 deletions(-) diff --git a/cli/class-tmdo-cli.php b/cli/class-tmdo-cli.php index d87c79c..b44bd5b 100644 --- a/cli/class-tmdo-cli.php +++ b/cli/class-tmdo-cli.php @@ -142,6 +142,10 @@ class TMDO_CLI { foreach ( $violations as $entity_type => $integrations ) { WP_CLI::warning( " [GUARD] {$entity_type} is aeav_only while " . implode( ', ', $integrations ) . " reads native meta — write flat back with `wp tmdo bridge-restore-native {$entity_type} --demote`." ); } + $pending = TMDO_Schema_Manager::pending_indexes(); + if ( $pending ) { + WP_CLI::warning( ' [INDEX] declared indexes not added yet: ' . implode( ', ', $pending ) . ' — still missing after this run: another process is adding them, or the ALTER failed (see the `schema.index_failed` log).' ); + } $status = get_option( TMDO_Drift_Check::OPTION, array() ); $status = is_array( $status ) ? $status : array(); diff --git a/includes/class-tmdo-core.php b/includes/class-tmdo-core.php index fa8fb2e..3a116bb 100644 --- a/includes/class-tmdo-core.php +++ b/includes/class-tmdo-core.php @@ -306,6 +306,9 @@ class TMDO_Core { add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 ); add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) ); + // 1.3.0: declared indexes a visitor request left for WP-Cron. + add_action( TMDO_Schema_Manager::INDEXES_HOOK, array( 'TMDO_Schema_Manager', 'ensure_pending_indexes' ) ); + // v2.3.0 M6: daily snapshot prune cron (paired with health check). add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) ); diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php index 7dd5fe8..da1244a 100644 --- a/includes/class-tmdo-installer.php +++ b/includes/class-tmdo-installer.php @@ -789,6 +789,7 @@ class TMDO_Installer { wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' ); wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' ); wp_clear_scheduled_hook( 'tmdo_drift_resync' ); + wp_clear_scheduled_hook( 'tmdo_ensure_group_indexes' ); wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' ); wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' ); wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' ); @@ -993,6 +994,7 @@ class TMDO_Installer { 'wpdo_post_shadow_verify', 'wpdo_term_comment_shadow_verify', 'tmdo_drift_resync', + 'tmdo_ensure_group_indexes', 'wpdo_post_stress_test_batch', 'wpdo_user_stress_test_batch', 'wpdo_term_stress_test_batch', diff --git a/includes/engine/class-tmdo-schema-manager.php b/includes/engine/class-tmdo-schema-manager.php index 448c21e..88ee68b 100644 --- a/includes/engine/class-tmdo-schema-manager.php +++ b/includes/engine/class-tmdo-schema-manager.php @@ -23,6 +23,9 @@ final class TMDO_Schema_Manager { /** Option: "entity/group" → md5 of the composite index definition already applied. */ public const INDEXES_OPTION = 'tmdo_entity_indexes'; + /** Single cron event that adds declared indexes a visitor request had to leave out. */ + public const INDEXES_HOOK = 'tmdo_ensure_group_indexes'; + /** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */ private static array $table_exists_cache = array(); @@ -144,6 +147,11 @@ final class TMDO_Schema_Manager { } } + // A new table gets its declared composite indexes while it is empty. + foreach ( TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name ) as $name => $keys ) { + $sql_indexes[] = 'KEY `cidx_' . self::sanitize_column_name( (string) $name ) . '` (' . implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) ) . ')'; + } + $columns_sql = implode( ",\n ", $sql_columns ); $indexes_sql = implode( ",\n ", $sql_indexes ); @@ -176,6 +184,12 @@ final class TMDO_Schema_Manager { * applied definition is remembered per group (one autoloaded option), so * requests only run SHOW INDEX / ALTER when the declaration changes. * + * On a large table the ALTER takes seconds, so it runs only from WP-CLI or + * WP-Cron (ddl_allowed(); other requests schedule INDEXES_HOOK) and in one + * process at a time (MySQL GET_LOCK on index_lock_name()). Until every + * ALTER succeeds the group stays in pending_indexes() and the table works + * without the index. + * * @param string $entity_type Entity type. * @param string $group_name Group name. * @return void @@ -193,23 +207,112 @@ final class TMDO_Schema_Manager { if ( ( $applied[ $id ] ?? '' ) === $signature ) { return; } + if ( ! self::ddl_allowed() ) { + if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) { + wp_schedule_single_event( time(), self::INDEXES_HOOK ); + } + return; + } $table = self::get_table_name( $entity_type, $group_name ); if ( ! self::table_exists( $table ) ) { return; } - $existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) ); - foreach ( $declared as $name => $keys ) { - $index = 'cidx_' . self::sanitize_column_name( $name ); - if ( in_array( $index, $existing, true ) ) { - continue; - } - $columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) ); - $wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" ); + // add_option() is INSERT ... ON DUPLICATE KEY UPDATE and would let two + // processes both "acquire" an option lock; GET_LOCK is atomic and is + // released by MySQL if this process dies. + $lock = self::index_lock_name(); + if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(MD5(CONCAT(DATABASE(), %s)), 0)', $lock ) ) ) { + return; // Another process is adding indexes. } + try { + // SHOW INDEX (not the option, cached for this request) tells what another process already added. + $existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) ); + $failed = false; + foreach ( $declared as $name => $keys ) { + $index = 'cidx_' . self::sanitize_column_name( $name ); + if ( in_array( $index, $existing, true ) ) { + continue; + } + $columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) ); + if ( false === $wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" ) ) { + $failed = true; + TMDO_Logger::warning( + 'schema.index_failed', + array( + 'table' => $table, + 'index' => $index, + 'error' => $wpdb->last_error, + ) + ); + } + } - $applied[ $id ] = $signature; - update_option( self::INDEXES_OPTION, $applied, true ); + if ( ! $failed ) { + $applied[ $id ] = $signature; + update_option( self::INDEXES_OPTION, $applied, true ); + } + } finally { + $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), %s)))', $lock ) ); + } + } + + /** + * INDEXES_HOOK handler: add the indexes a visitor request could not. + * + * @return void + */ + public static function ensure_pending_indexes(): void { + foreach ( self::pending_indexes() as $id ) { + list( $entity_type, $group_name ) = explode( '/', $id, 2 ); + self::ensure_group_indexes( $entity_type, $group_name ); + } + } + + /** + * Key of the MySQL lock held while indexes are added. Lock names are + * server-wide, so SQL hashes it with DATABASE() to keep sites apart. + * + * @return string + */ + public static function index_lock_name(): string { + global $wpdb; + return $wpdb->prefix . self::INDEXES_OPTION; + } + + /** + * Whether this request may alter flat tables: WP-CLI or WP-Cron only. + * Front-end, REST, admin-ajax, admin-post and even /wp-admin/ requests + * (init runs before auth_redirect()) can be a visitor's, who would wait + * for the ALTER. Filter: `tmdo_schema_ddl_allowed`. + * + * @return bool + */ + public static function ddl_allowed(): bool { + $allowed = ( defined( 'WP_CLI' ) && WP_CLI ) + || ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ); + return (bool) apply_filters( 'tmdo_schema_ddl_allowed', $allowed ); + } + + /** + * Registered groups whose declared indexes are not on their table yet. + * + * @return string[] "entity/group" + */ + public static function pending_indexes(): array { + $applied = get_option( self::INDEXES_OPTION, array() ); + $applied = is_array( $applied ) ? $applied : array(); + $pending = array(); + foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) { + foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group_name ) { + $declared = TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name ); + if ( $declared && ( $applied[ "{$entity_type}/{$group_name}" ] ?? '' ) !== md5( (string) wp_json_encode( $declared ) ) + && self::table_exists( self::get_table_name( $entity_type, $group_name ) ) ) { + $pending[] = "{$entity_type}/{$group_name}"; + } + } + } + return $pending; } /** diff --git a/tests/integration/GroupCompositeIndexTest.php b/tests/integration/GroupCompositeIndexTest.php index 4e4d4f5..e8d6e3e 100644 --- a/tests/integration/GroupCompositeIndexTest.php +++ b/tests/integration/GroupCompositeIndexTest.php @@ -31,6 +31,10 @@ class GroupCompositeIndexTest extends TestCase { $wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, ci_flag BIGINT(20) DEFAULT NULL, ci_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' ); $wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, ci_flag, ci_price) VALUES (1, 1, 10), (2, 0, 20)' ); $GLOBALS['_wp_options'] = array(); + // Index DDL runs from WP-CLI / WP-Cron only (2.3 PR-090a); these tests act as one. + $GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array(); + add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); + $GLOBALS['_wp_scheduled_single'] = array(); $ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' ); $ref->setAccessible( true ); @@ -53,6 +57,81 @@ class GroupCompositeIndexTest extends TestCase { global $wpdb; $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); WPDO_Entity_Registry::init(); + $GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array(); + unset( $GLOBALS['_wp_is_admin'], $GLOBALS['_wp_scheduled_single'] ); + } + + public function test_a_web_request_adds_no_index_and_leaves_it_to_wp_cron(): void { + $GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array(); + foreach ( array( false, true ) as $is_admin ) { // /wp-admin/ runs init before auth_redirect(). + $GLOBALS['_wp_is_admin'] = $is_admin; + $this->assertFalse( TMDO_Schema_Manager::ddl_allowed() ); + + TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' ); + + $this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'no ALTER in a web request' ); + } + $this->assertSame( array( 'post/ci_core' ), TMDO_Schema_Manager::pending_indexes() ); + $this->assertContains( TMDO_Schema_Manager::INDEXES_HOOK, $GLOBALS['_wp_scheduled_single'] ); + + add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); // The cron run. + TMDO_Schema_Manager::ensure_pending_indexes(); + + $this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() ); + $this->assertSame( array(), TMDO_Schema_Manager::pending_indexes() ); + } + + public function test_the_ddl_decision_can_be_overridden(): void { + add_filter( 'tmdo_schema_ddl_allowed', static fn() => false ); + + TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' ); + + $this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes() ); + } + + public function test_a_failed_alter_is_not_recorded_as_applied(): void { + global $wpdb; + $wpdb->query( 'ALTER TABLE `' . self::TABLE . '` ADD COLUMN ci_note TEXT DEFAULT NULL' ); + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); + WPDO_Entity_Registry::register_group( + 'post', + 'ci_core', + array( + array( 'key' => 'ci_flag', 'type' => 'integer' ), + array( 'key' => 'ci_note', 'type' => 'textarea' ), + ), + array( 'indexes' => array( 'flag_note' => array( 'ci_flag', 'ci_note' ) ) ) // TEXT without a prefix length: MySQL 1170. + ); + + TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' ); + + $this->assertArrayNotHasKey( 'cidx_flag_note', $this->indexes() ); + $this->assertFalse( get_option( TMDO_Schema_Manager::INDEXES_OPTION ), 'not recorded, so it is retried' ); + $this->assertSame( array( 'post/ci_core' ), TMDO_Schema_Manager::pending_indexes() ); + } + + public function test_a_lock_held_by_another_process_skips_the_ddl_until_it_is_released(): void { + $other = new mysqli( + getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1', + getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo', + (string) getenv( 'TMDO_TEST_DB_PASS' ), + getenv( 'TMDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test' + ); + $name = $other->real_escape_string( TMDO_Schema_Manager::index_lock_name() ); + $this->assertSame( '1', (string) $other->query( "SELECT GET_LOCK(MD5(CONCAT(DATABASE(), '{$name}')), 0)" )->fetch_row()[0] ); + + TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' ); + $this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'another process holds the lock' ); + + $other->query( "SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), '{$name}')))" ); + $other->close(); + TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' ); + + $this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() ); + $this->assertSame( array(), TMDO_Schema_Manager::pending_indexes() ); + global $wpdb; + $this->assertSame( '1', (string) $wpdb->get_var( $wpdb->prepare( 'SELECT IS_FREE_LOCK(MD5(CONCAT(DATABASE(), %s)))', TMDO_Schema_Manager::index_lock_name() ) ), 'lock released' ); } /** @return array index name → ordered columns */ diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 6d3dad8..1c9cbcc 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -168,7 +168,12 @@ $wpdb = new class( $_mysqli ) { fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'", array_values( $data ) ) ); - $ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" ); + try { + $ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" ); + } catch ( mysqli_sql_exception $e ) { // Like wpdb::insert(): an SQL error returns false. + $this->last_error = $e->getMessage(); + return false; + } if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->insert_id; } $this->last_error = (string) $this->db->error; return false; @@ -200,7 +205,12 @@ $wpdb = new class( $_mysqli ) { public function flush(): void {} public function query( string $sql ): int|bool { - $result = $this->db->query( $sql ); + try { + $result = $this->db->query( $sql ); + } catch ( mysqli_sql_exception $e ) { // Like wpdb::query(): an SQL error returns false. + $this->last_error = $e->getMessage(); + return false; + } if ( $result instanceof mysqli_result ) { $result->free(); return true; } if ( false === $result ) { return false; } return $this->db->affected_rows; @@ -291,7 +301,7 @@ if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ): if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } } if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } } if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } } -if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { return true; } } +if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { $GLOBALS['_wp_scheduled_single'][] = $hook; return true; } } if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } } if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } } if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } } -- 2.43.0 From b187c1c4d3f025ff766abd56488714d0840d6921 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 16:19:11 +0800 Subject: [PATCH 02/20] perf(2meet-data-optimizer): count routed searches from a cached base count minus the excluded posts - a routed search with post__not_in (HivePress sends a random set per page) caches the COUNT without the exclusion and subtracts a live primary-key count of the excluded posts that match; the total stays exact - the excluded count drops STRAIGHT_JOIN so MySQL starts from posts.ID - Verify: PostQueryRouterFoundRowsTest, unit 698, integration 577, phpcs, stan; dev36 live router suite and keyword + price search - PR: PR-091 --- .../query/class-tmdo-post-query-router.php | 25 +++++++++++++++++-- .../PostQueryRouterFoundRowsTest.php | 25 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index db90998..161b6b4 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -372,7 +372,23 @@ class TMDO_Post_Query_Router { if ( ! $counted ) { return $request; } - $matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request ); + global $wpdb; + $matches = (string) preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request ); + $not_in = " AND {$wpdb->posts}.ID NOT IN ("; + // post__not_in (HivePress sends a random set per page) would give every + // search its own cache key: count without it (cached) minus the excluded + // posts that match (primary-key lookups, so no forced flat-first order). + if ( 1 === substr_count( $matches, $not_in ) && preg_match( '/' . preg_quote( $not_in, '/' ) . '([\d,]+)\)/', $matches, $m ) ) { + $excluded = str_replace( $m[0], " AND {$wpdb->posts}.ID IN ({$m[1]})", (string) preg_replace( '/^SELECT STRAIGHT_JOIN /', 'SELECT ', $matches ) ); + $query->set( + self::COUNT_VAR, + array( + 'SELECT COUNT(*) FROM ( ' . str_replace( $m[0], '', $matches ) . ' ) AS tmdo_found', + "SELECT COUNT(*) FROM ( {$excluded} ) AS tmdo_found", + ) + ); + return $request; + } $query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" ); return $request; } @@ -414,13 +430,18 @@ class TMDO_Post_Query_Router { if ( empty( $count_sql ) ) { return (string) ( $sql ?? '' ); } + global $wpdb; + list( $count_sql, $excluded_sql ) = array_pad( (array) $count_sql, 2, '' ); + $key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' ); $total = wp_cache_get( $key, self::COUNT_CACHE_GROUP ); if ( false === $total ) { - global $wpdb; $total = (int) $wpdb->get_var( $count_sql ); wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS ); } + if ( '' !== $excluded_sql ) { + $total = max( 0, (int) $total - (int) $wpdb->get_var( $excluded_sql ) ); + } return 'SELECT ' . (int) $total; } diff --git a/tests/integration/PostQueryRouterFoundRowsTest.php b/tests/integration/PostQueryRouterFoundRowsTest.php index 78e01eb..f66e4cc 100644 --- a/tests/integration/PostQueryRouterFoundRowsTest.php +++ b/tests/integration/PostQueryRouterFoundRowsTest.php @@ -121,6 +121,31 @@ class PostQueryRouterFoundRowsTest extends TestCase { $this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' ); } + /** + * HivePress excludes a different random set of posts on each search page; + * the total is the cached count without the exclusion minus a live count + * of the excluded posts that match (by primary key). + */ + public function test_excluded_posts_do_not_split_the_cached_total(): void { + global $wpdb; + $count = function ( string $not_in ) use ( $wpdb ): string { + list( $router, $query, $request ) = $this->routed(); + $request = str_replace( ' GROUP BY', " AND {$wpdb->posts}.ID NOT IN ({$not_in}) GROUP BY", $request ); + $router->posts_request( $request, $query ); + return (string) $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ); + }; + + $this->assertSame( '1', $count( '3' ), 'prices 20, 30 match; 3 excluded' ); + $this->assertSame( '0', $count( '2,3' ) ); + + $wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 3' ); // Not seen until posts change. + $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(); + $this->assertSame( '0', $count( '2' ), 'recounted' ); + } + 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' ); -- 2.43.0 From 22b46ad175a023e52b5ed3bee9e212bfe601d256 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 16:33:16 +0800 Subject: [PATCH 03/20] perf(2meet-data-optimizer): stop writing an option for every rate-limited view - blocked POST /listings/{id}/view requests update wpdo_rl_stats 1 in 10, each sample adding 10: anonymous traffic no longer turns 1:1 into DB writes, and the admin top list stays representative (unbiased estimate) - Verify: RestApiTest counts option writes (about 20 for 200 blocked requests), unit 698, integration 577, phpcs, stan - PR: PR-094 --- includes/class-tmdo-rest-api.php | 21 ++++++++++++++------- tests/unit/RestApiTest.php | 19 +++++++++++++++---- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/includes/class-tmdo-rest-api.php b/includes/class-tmdo-rest-api.php index e135fb5..e2b949b 100644 --- a/includes/class-tmdo-rest-api.php +++ b/includes/class-tmdo-rest-api.php @@ -23,6 +23,9 @@ if ( ! defined( 'ABSPATH' ) ) { */ class TMDO_REST_API { + /** Blocked views are counted 1 in this many (wpdo_rl_stats). */ + private const RL_STATS_SAMPLE = 10; + const NAMESPACE = 'wpdo/v1'; /** @@ -725,14 +728,18 @@ class TMDO_REST_API { if ( $cookie_blocked || $ip_blocked ) { // Track rate-limit hits per post for admin visibility, capped at 100 entries. - $rl_stats = get_option( 'wpdo_rl_stats', array() ); - $rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + 1; - if ( count( $rl_stats ) > 100 ) { - // Keep only the top-100 posts by hit count to bound option size. - arsort( $rl_stats ); - $rl_stats = array_slice( $rl_stats, 0, 100, true ); + // Sampled (1 in RL_STATS_SAMPLE, each sample adding RL_STATS_SAMPLE) so + // anonymous requests cannot turn every blocked view into an option write. + if ( 1 === wp_rand( 1, self::RL_STATS_SAMPLE ) ) { + $rl_stats = get_option( 'wpdo_rl_stats', array() ); + $rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + self::RL_STATS_SAMPLE; + if ( count( $rl_stats ) > 100 ) { + // Keep only the top-100 posts by hit count to bound option size. + arsort( $rl_stats ); + $rl_stats = array_slice( $rl_stats, 0, 100, true ); + } + update_option( 'wpdo_rl_stats', $rl_stats, false ); } - update_option( 'wpdo_rl_stats', $rl_stats, false ); return new WP_REST_Response( array( diff --git a/tests/unit/RestApiTest.php b/tests/unit/RestApiTest.php index 64d947f..b30cf7c 100644 --- a/tests/unit/RestApiTest.php +++ b/tests/unit/RestApiTest.php @@ -377,7 +377,11 @@ class RestApiTest extends TestCase { $this->assertSame( 'too_many_requests', $response->get_data()['code'] ); } - public function test_post_view_429_increments_rate_limit_stats(): void { + /** + * Blocked requests are counted 1 in 10 (each sample adds 10), so anonymous + * traffic cannot turn every blocked request into an option write. + */ + public function test_post_view_429_samples_rate_limit_stats(): void { $GLOBALS['_wp_post_types'][40] = 'hp_listing'; $_COOKIE['wpdo_view_40'] = '1'; // Trigger cookie block. $nonce = wp_create_nonce( 'wp_rest' ); @@ -385,10 +389,17 @@ class RestApiTest extends TestCase { $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/40/view' ); $req->set_param( 'id', 40 ); $req->set_header( 'X-WP-Nonce', $nonce ); - $this->api->post_view( $req ); + $writes = 0; + for ( $i = 0; $i < 200; $i++ ) { + $before = get_option( 'wpdo_rl_stats', [] ); + $this->assertSame( 429, $this->api->post_view( $req )->get_status() ); + $writes += get_option( 'wpdo_rl_stats', [] ) === $before ? 0 : 1; + } - $stats = get_option( 'wpdo_rl_stats', [] ); - $this->assertSame( 1, (int) ( $stats['40'] ?? 0 ) ); + $count = (int) ( get_option( 'wpdo_rl_stats', [] )['40'] ?? 0 ); + $this->assertSame( 10 * $writes, $count, 'each sample adds 10' ); + $this->assertGreaterThan( 0, $writes ); + $this->assertLessThan( 60, $writes, 'about 20 writes for 200 blocked requests, not 200' ); } // ── get_status: rate_limit_stats ───────────────────────────────────────── -- 2.43.0 From 1bdd51d9a3a4dfbbacbe949429c66b016f07f37f Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 16:35:13 +0800 Subject: [PATCH 04/20] fix(2meet-data-optimizer): log info and notice events only when debug logging is on - TMDO_Logger::info()/notice() write to the PHP error log only with WP_DEBUG_LOG (filter tmdo_log_info); every CLI run and production request printed [WPDO][INFO] lines - debug() keeps its WP_DEBUG gate; warning() and error() are unchanged - Verify: LoggerTest (2 new), unit 700, integration 577, phpcs, stan - PR: PR-095 --- includes/class-tmdo-logger.php | 20 ++++++++++++++++++-- tests/unit/LoggerTest.php | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/includes/class-tmdo-logger.php b/includes/class-tmdo-logger.php index feac105..1ceff62 100644 --- a/includes/class-tmdo-logger.php +++ b/includes/class-tmdo-logger.php @@ -48,7 +48,7 @@ class TMDO_Logger { /** * Log an INFO-level event (event-based signature, used by v2.0.0 engine code). * - * Writes only to PHP error_log; does NOT touch wpdo_errors (which is reserved + * Writes only to PHP error_log, and only when debug logging is on; does NOT touch wpdo_errors (which is reserved * for actual errors). Mode changes / cache flushes / audit prunes call here * many times per request — keeping them out of the DB error table. * @@ -57,6 +57,22 @@ class TMDO_Logger { * @return void */ public static function info( string $event, array $context = array() ): void { + // Routine events: only with WP_DEBUG_LOG (filter `tmdo_log_info`), or every + // CLI run and production request would flood the log. + if ( ! apply_filters( 'tmdo_log_info', defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) ) { + return; + } + self::write_info( $event, $context ); + } + + /** + * Write an INFO line to the PHP error log. + * + * @param string $event Event name. + * @param array $context Structured context. + * @return void + */ + private static function write_info( string $event, array $context ): void { error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) ); } @@ -97,7 +113,7 @@ class TMDO_Logger { */ public static function debug( string $event, array $context = array() ): void { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - self::info( $event, $context ); + self::write_info( $event, $context ); } } diff --git a/tests/unit/LoggerTest.php b/tests/unit/LoggerTest.php index 9b3cffa..b4f9578 100644 --- a/tests/unit/LoggerTest.php +++ b/tests/unit/LoggerTest.php @@ -130,4 +130,38 @@ class LoggerTest extends TestCase { $deleted = WPDO_Logger::purge( 30 ); $this->assertSame( 5, $deleted ); } + + /** error_log() output of $fn. */ + private function logged( callable $fn ): string { + $file = tempnam( sys_get_temp_dir(), 'tmdo-log' ); + $old = ini_set( 'error_log', $file ); + try { + $fn(); + } finally { + ini_set( 'error_log', (string) $old ); + } + $out = (string) file_get_contents( $file ); + unlink( $file ); + return $out; + } + + public function test_info_and_notice_stay_out_of_the_log_unless_debug_logging_is_on(): void { + $GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array(); + + $this->assertSame( '', $this->logged( static function () { + WPDO_Logger::info( 'cache_flushed' ); + WPDO_Logger::notice( 'mode_changed' ); + } ) ); + + add_filter( 'tmdo_log_info', static fn() => true ); + try { + $this->assertStringContainsString( '[WPDO][INFO][cache_flushed]', $this->logged( static fn() => WPDO_Logger::info( 'cache_flushed' ) ) ); + } finally { + $GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array(); + } + } + + public function test_warnings_are_always_logged(): void { + $this->assertStringContainsString( '[WPDO][WARN][odd_return]', $this->logged( static fn() => WPDO_Logger::warning( 'odd_return' ) ) ); + } } -- 2.43.0 From a268470b607aa6ba0e0a15d674604ea9cbfb2118 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 16:36:51 +0800 Subject: [PATCH 05/20] fix(2meet-data-optimizer): treat contact and address keys as private in the public REST filter - rest_exposable() also withholds keys named like an email, phone, address or IP, even when a field declares show_in_rest (defence in depth) - Verify: RestApiTest (private and look-alike public keys), unit 701, integration 577, phpcs, stan; dev36 live rest suite - PR: PR-096 --- includes/class-tmdo-schema-registry.php | 7 +++++-- tests/unit/RestApiTest.php | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/includes/class-tmdo-schema-registry.php b/includes/class-tmdo-schema-registry.php index 442af5d..95225ff 100644 --- a/includes/class-tmdo-schema-registry.php +++ b/includes/class-tmdo-schema-registry.php @@ -282,7 +282,8 @@ class TMDO_Schema_Registry { * Whether a field may appear in the public listings endpoints: declared * show_in_rest, not a protected meta key (leading underscore — WordPress * hides those from public output, e.g. _purchase_note), and not named like a - * credential (tokens, secrets, passwords, API keys), whatever it declares. + * credential (tokens, secrets, passwords, API keys) or a contact / location + * detail (email, phone, address, IP), whatever it declares. * * @param array $field Registered field. * @return bool @@ -291,7 +292,9 @@ class TMDO_Schema_Registry { $key = (string) ( $field['meta_key'] ?? '' ); return ! empty( $field['show_in_rest'] ) && 0 !== strpos( $key, '_' ) - && 1 !== preg_match( '/token|secret|passw|api_?key|private_?key|credential|salt|nonce/i', $key . ' ' . ( $field['column'] ?? '' ) ); + && 1 !== preg_match( '/token|secret|passw|api_?key|private_?key|credential|salt|nonce/i', $key . ' ' . ( $field['column'] ?? '' ) ) + // Contact and location details (2.3 PR-096): private even when declared public. + && 1 !== preg_match( '/e_?mail|phone|address|(^|_)ip(_|$|\s)/i', $key . ' ' . ( $field['column'] ?? '' ) ); } /** diff --git a/tests/unit/RestApiTest.php b/tests/unit/RestApiTest.php index b30cf7c..60c9aba 100644 --- a/tests/unit/RestApiTest.php +++ b/tests/unit/RestApiTest.php @@ -164,6 +164,21 @@ class RestApiTest extends TestCase { $this->assertSame( array( 'id', 'post_type', 'hp_price' ), array_keys( $data ) ); } + public function test_contact_and_address_keys_stay_private_even_when_declared_public(): void { + $private = array( 'hp_email', 'hp_contact_e_mail', 'hp_phone_number', 'hp_address', 'billing_address_1', 'hp_ip', 'hp_client_ip_addr' ); + $public = array( 'hp_price', 'hp_description', 'hp_tip_amount', 'hp_shipping_days' ); + $fields = array(); + $values = array(); + foreach ( array_merge( $private, $public ) as $key ) { + $fields[] = array( 'meta_key' => $key, 'zone' => 'cold', 'show_in_rest' => true ); + $values[ $key ] = 'v'; + } + + $data = $this->listing_with( $fields, $values ); + + $this->assertSame( array_merge( array( 'id', 'post_type' ), $public ), array_keys( $data ) ); + } + public function test_rest_visible_columns_exclude_protected_and_credential_keys(): void { $registry = WPDO_Schema_Registry::instance(); $registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) ); -- 2.43.0 From 1b50df7cd040f0f6e9bb8abb43e9229e6a1f2d08 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 16:39:56 +0800 Subject: [PATCH 06/20] refactor(2meet-data-optimizer): handle admin form posts before the page renders - the main page's form handlers move unchanged from render_page() to handle_post(), hooked on load-{page}: inside render_page() the admin header was already sent, so their wp_safe_redirect() calls could not work - the setup wizard's step 4 snapshot post moves to handle_snapshot_post() on its load hook; a failure redirects with snap_failed=1 for the notice - Verify: unit 701, integration 577, phpcs, stan; dev36 load hook handles a posted reset and redirects before output - PR: PR-097 --- admin/class-tmdo-admin.php | 21 ++++++++++++--- admin/class-tmdo-setup-wizard.php | 45 ++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/admin/class-tmdo-admin.php b/admin/class-tmdo-admin.php index 288aaff..27827ac 100644 --- a/admin/class-tmdo-admin.php +++ b/admin/class-tmdo-admin.php @@ -89,13 +89,16 @@ class TMDO_Admin { * Register the admin menu page under Tools. */ public static function register_menu(): void { - add_management_page( + $hook_suffix = add_management_page( __( 'WP Data Optimizer', '2meet-data-optimizer' ), __( 'WP Data Optimizer', '2meet-data-optimizer' ), 'manage_options', self::MENU_SLUG, array( __CLASS__, 'render_page' ) ); + if ( $hook_suffix ) { + add_action( 'load-' . $hook_suffix, array( __CLASS__, 'handle_post' ) ); + } } /** @@ -326,9 +329,10 @@ class TMDO_Admin { } /** - * Render the admin page. + * Handle the page's form posts on load-{page}, before any output, so their + * redirects work (inside render_page() the admin header was already sent). */ - public static function render_page(): void { + public static function handle_post(): void { if ( ! TMDO_Capability::current_user_can_admin() ) { return; } @@ -752,6 +756,15 @@ class TMDO_Admin { ); exit; } + } + + /** + * Render the admin page. + */ + public static function render_page(): void { + if ( ! TMDO_Capability::current_user_can_admin() ) { + return; + } $tabs = array( 'dashboard' => __( '儀表板', '2meet-data-optimizer' ), @@ -774,7 +787,7 @@ class TMDO_Admin { 'hivepress' => __( 'HivePress 整合', '2meet-data-optimizer' ), ); - $tab = sanitize_key( wp_unslash( $_GET['tab'] ?? 'dashboard' ) ); + $tab = sanitize_key( wp_unslash( $_GET['tab'] ?? 'dashboard' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- tab navigation, no state change. // v2.11.8: legacy tabs removed — redirect bookmarks to current main maintenance entry. if ( in_array( $tab, array( 'sop', 'migration' ), true ) ) { $tab = 'entity-bridge'; diff --git a/admin/class-tmdo-setup-wizard.php b/admin/class-tmdo-setup-wizard.php index 2554578..32c8ab9 100644 --- a/admin/class-tmdo-setup-wizard.php +++ b/admin/class-tmdo-setup-wizard.php @@ -106,7 +106,7 @@ class TMDO_Setup_Wizard { * @return void */ public static function register_menu_page(): void { - add_submenu_page( + $hook_suffix = add_submenu_page( 'tools.php', __( 'WPDO 設定嚮導', '2meet-data-optimizer' ), __( 'WPDO 設定嚮導', '2meet-data-optimizer' ), @@ -114,6 +114,33 @@ class TMDO_Setup_Wizard { 'wpdo-setup-wizard', array( __CLASS__, 'render_page' ) ); + if ( $hook_suffix ) { + add_action( 'load-' . $hook_suffix, array( __CLASS__, 'handle_snapshot_post' ) ); + } + } + + /** + * Step 4 baseline snapshot (POST + nonce, prevents Referer leak — v3.3.x + * P1-6b hardening), handled on load-{page} so the redirect runs before any + * output. + * + * @return void + */ + public static function handle_snapshot_post(): void { + if ( ! isset( $_POST['wpdo_take_snapshot'] ) || ! TMDO_Capability::current_user_can_admin() || ! class_exists( 'TMDO_Snapshot_Manager' ) ) { + return; + } + check_admin_referer( self::NONCE_NAME ); + $result = TMDO_Snapshot_Manager::create( + 'manual', + array(), + array( + 'notes' => 'wizard_baseline', + 'retention_days' => 365, + ) + ); + wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&' . ( empty( $result['ok'] ) ? 'snap_failed=1' : 'snap_done=1' ) ) ); + exit; } /** @@ -356,20 +383,8 @@ class TMDO_Setup_Wizard {

'wizard_baseline', - 'retention_days' => 365, - ) - ); - if ( ! empty( $result['ok'] ) ) { - wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&snap_done=1' ) ); - exit; - } + // The snapshot itself is taken in handle_snapshot_post(), before any output. + if ( isset( $_GET['snap_failed'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended echo '

' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '

'; } ?> -- 2.43.0 From d46913de41fc63eb197a8128cd627f3332a49c97 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 16:41:08 +0800 Subject: [PATCH 07/20] chore(2meet-data-optimizer): prefix the global variables in uninstall.php - $bootstrap_files, $bootstrap_ok, $f, $result, $snapshot_id and $dir become $wpdo_* like the file's other globals (WordPress.org Plugin Check) - Verify: PrefixAllGlobals sniff 0, phpcs, UninstallGuardTest and InstallerCleanupTest, unit 701 - PR: PR-098 --- uninstall.php | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/uninstall.php b/uninstall.php index b8747dc..c6e0a40 100644 --- a/uninstall.php +++ b/uninstall.php @@ -30,7 +30,7 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { global $wpdb; // ── Load shared cleanup logic + snapshot classes ───────────────────────── -$bootstrap_files = array( +$wpdo_bootstrap_files = array( __DIR__ . '/includes/class-tmdo-logger.php', __DIR__ . '/includes/class-tmdo-feature-flags.php', __DIR__ . '/includes/class-tmdo-installer.php', @@ -39,13 +39,13 @@ $bootstrap_files = array( __DIR__ . '/includes/snapshots/class-tmdo-snapshot-reader.php', __DIR__ . '/includes/snapshots/class-tmdo-snapshot-pruner.php', ); -$bootstrap_ok = true; -foreach ( $bootstrap_files as $f ) { - if ( ! is_file( $f ) ) { - $bootstrap_ok = false; +$wpdo_bootstrap_ok = true; +foreach ( $wpdo_bootstrap_files as $wpdo_bootstrap_file ) { + if ( ! is_file( $wpdo_bootstrap_file ) ) { + $wpdo_bootstrap_ok = false; continue; } - require_once $f; + require_once $wpdo_bootstrap_file; } // ── Sites to check and clean (0 = single site, no blog switching) ──────── @@ -77,7 +77,7 @@ $wpdo_for_each_site = static function ( callable $callback ) use ( $wpdo_site_id }; // ── Refuse while any site holds data that exists only in wpdo_* tables ─── -if ( ! $bootstrap_ok ) { +if ( ! $wpdo_bootstrap_ok ) { wp_die( esc_html( '2meet Data Optimizer:無法載入資料安全檢查,為避免資料遺失,已停止解除安裝。' ) ); } @@ -103,7 +103,7 @@ if ( $wpdo_blocked ) { // wpdo_snapshots rows (which is per-site so per-site snapshots survive the // site-level table DROP that happens later). Best-effort. if ( class_exists( 'TMDO_Snapshot_Manager' ) ) { - $result = TMDO_Snapshot_Manager::create( + $wpdo_snapshot = TMDO_Snapshot_Manager::create( 'pre_uninstall', array(), array( @@ -112,18 +112,18 @@ if ( class_exists( 'TMDO_Snapshot_Manager' ) ) { 'inline_threshold_bytes' => 0, ) ); - if ( ! empty( $result['ok'] ) ) { - $snapshot_id = $result['snapshot_id']; - $dir = TMDO_Snapshot_Manager::backup_dir(); - if ( is_dir( $dir ) ) { + if ( ! empty( $wpdo_snapshot['ok'] ) ) { + $wpdo_snapshot_id = $wpdo_snapshot['snapshot_id']; + $wpdo_backup_dir = TMDO_Snapshot_Manager::backup_dir(); + if ( is_dir( $wpdo_backup_dir ) ) { file_put_contents( - trailingslashit( $dir ) . 'README-pre-uninstall.txt', - "WPDO pre-uninstall snapshot: {$snapshot_id}\n" + trailingslashit( $wpdo_backup_dir ) . 'README-pre-uninstall.txt', + "WPDO pre-uninstall snapshot: {$wpdo_snapshot_id}\n" . 'Created: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n\n" . "To restore after re-installing wp-data-optimizer:\n" . " 1. Re-install the plugin (it will re-create wp_wpdo_snapshots).\n" . " 2. Manually re-insert this snapshot's catalog row, or use wp wpdo snapshot list to confirm.\n" - . " 3. Run: wp wpdo snapshot restore {$snapshot_id} --apply\n" + . " 3. Run: wp wpdo snapshot restore {$wpdo_snapshot_id} --apply\n" ); } } -- 2.43.0 From 303781e68b8783f5042493903626f7b226113c46 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 16:46:55 +0800 Subject: [PATCH 08/20] docs(2meet-data-optimizer): 1.3.0 release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - version 1.3.0; CHANGELOG and readme.txt for PR-090a, 091, 093 (verified, no change), 094–098, with the post-upgrade steps (first CLI / cron run adds the HivePress indexes; INFO lines need WP_DEBUG_LOG) - Verify: composer test (701) - PR: PR-101 --- 2meet-data-optimizer.php | 4 ++-- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++ readme.txt | 10 ++++++++- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index 1d11b45..88e3549 100644 --- a/2meet-data-optimizer.php +++ b/2meet-data-optimizer.php @@ -3,7 +3,7 @@ * Plugin Name: 2meet Data Optimizer * Plugin URI: https://2meet.io/2meet-data-optimizer * Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。 - * Version: 1.2.0 + * Version: 1.3.0 * Requires at least: 6.0 * Tested up to: 6.9.4 * Requires PHP: 8.1 @@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) { } // ── Constants ────────────────────────────────────────────────────────────── -define( 'TMDO_VERSION', '1.2.0' ); +define( 'TMDO_VERSION', '1.3.0' ); define( 'TMDO_DB_VERSION', '2.1.0' ); define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) ); define( 'TMDO_URL', plugin_dir_url( __FILE__ ) ); diff --git a/CHANGELOG.md b/CHANGELOG.md index dc94987..16d5fd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,50 @@ Versioning follows [Semantic Versioning](https://semver.org/). --- +## [1.3.0] — 2026-09-27 — 大表索引、路由計數與稽核修正(hardening 2.3) + +**性質**:效能 + 稽核修正。無 schema 變更(宣告的複合索引以 ALTER ADD INDEX 就地加上)。 +搭配 HivePress AddOn 0.4.0、WooCommerce AddOn 0.3.0。來源:dev36 規模壓測(90 萬 listing) +與系列稽核(2026-09-27)。 + +### Changed + +- **group 宣告的複合索引只在 WP-CLI 或 WP-Cron 加上**:網頁請求(包括 auth_redirect 之前的 + `/wp-admin/` 與 `admin-post.php`)改排單次事件 `tmdo_ensure_group_indexes`;以 MySQL + `GET_LOCK` 保證同一時間只有一個程序執行 ALTER;每個 ALTER 都成功才記錄已套用,失敗記 + `schema.index_failed` 並保留在 `doctor` 的 `[INDEX]` 待辦;新表在 CREATE TABLE 時就帶上 + 索引。filter:`tmdo_schema_ddl_allowed`。dev36 90 萬列加兩個索引約 4.7 s。(PR-090a) +- **路由搜尋的 found rows**:帶 `post__not_in` 的查詢(HivePress 每頁送一組隨機排除清單) + 改為「不含排除條件的 COUNT(快取)」減去「排除清單中符合條件的篇數(主鍵查詢)」,結果 + 精確,快取不再被打散。dev36 關鍵字 + 價格:首次 5.25 s、之後 3.2 s(原生 3.35 s)。(PR-091) +- `POST /wpdo/v1/listings/{id}/view` 被限流擋下時,`wpdo_rl_stats` 改為 1/10 抽樣寫入 + (每次 +10),匿名請求不再 1:1 放大成 option 寫入。(PR-094) +- `TMDO_Logger::info()` / `notice()` 只在 `WP_DEBUG_LOG` 時寫 error_log + (filter `tmdo_log_info`);`debug()`、`warning()`、`error()` 不變。(PR-095) +- 公開 listings REST 一律不輸出名稱像 email、phone、address、IP 的欄位,即使宣告 + `show_in_rest`。(PR-096) + +### Fixed + +- 後台主頁與設定精靈第 4 步的表單改在 `load-{page}`(輸出前)處理,`wp_safe_redirect()` + 可以正常生效;精靈快照失敗改以 `snap_failed=1` 導回。(PR-097) +- `uninstall.php` 的全域變數加上 `$wpdo_` 前綴(WordPress.org Plugin Check)。(PR-098) + +### Security(已驗證,無程式變更) + +- 快照目錄在 nginx 下的外洩風險(稽核 Medium):快照檔名含 64-bit 隨機值、遷移備份檔名含 + 12 字元隨機 job id,目錄有 index.php 擋列表;`doctor` 的 HTTP 探測與 nginx 後台提示已存在。 + dev31 探測全為 403。不採用「目錄名加隨機後綴」:各站 nginx deny 規則比對 `wpdo-backups/`, + 改名反而讓新快照脫離保護。(PR-093) + +### 升級後 + +- 部署後第一個 WP-CLI 指令或 WP-Cron 會為 HivePress AddOn 0.4.0 宣告的索引執行 ALTER + (90 萬列約 5 s);`DISABLE_WP_CRON` 且沒有系統 cron 的站,執行一次 `wp tmdo doctor` 即可。 +- CLI 輸出不再夾帶 `[WPDO][INFO]`;要看請定義 `WP_DEBUG_LOG` 或加上 `tmdo_log_info` filter。 + +--- + ## [1.2.0] — 2026-09-27 — 漂移偵測、範圍收斂、CLI 與診斷(hardening 2.2) **性質**:功能 + 資料正確性修正。無 schema 變更。延續 ADR-004 的搜尋索引模式;搭配 diff --git a/readme.txt b/readme.txt index 7cb6d7b..ee06e6f 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress Requires at least: 6.0 Tested up to: 6.9 Requires PHP: 8.1 -Stable tag: 1.2.0 +Stable tag: 1.3.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -107,6 +107,14 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency, == Changelog == += 1.3.0 = +* Declared flat-table indexes are added only from WP-CLI or WP-Cron, one process at a time (MySQL GET_LOCK); web requests schedule `tmdo_ensure_group_indexes`, and doctor lists indexes not added yet +* Routed searches with post__not_in count from a cached base count minus the excluded posts (HivePress keyword + price search no longer slower than native) +* Rate-limited view requests sample their statistics (1 in 10) instead of writing an option each time +* Logger info / notice lines only with WP_DEBUG_LOG (filter `tmdo_log_info`) +* The public listings REST output withholds email, phone, address and IP fields even when declared public +* Admin form posts are handled before the page renders; uninstall.php globals are prefixed + = 1.2.0 = * `wp tmdo drift-check [--group] [--sample] [--fix]`: reports flat-vs-native drift per key (miss / diff / extra) and rebuilds drifted flat rows from native; native meta is never written, and aeav_only sites are refused * The post query router keeps a query native while one of its groups is recorded as drifting; a daily sample (500 entities per group) on the existing health-check event keeps the record current -- 2.43.0 From 5968bcd95df05c14b19923eb92e3f58049bb6dd6 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 16:59:08 +0800 Subject: [PATCH 09/20] fix(2meet-data-optimizer): forget post status columns of groups whose flat table is gone - on_transition() skips a recorded group whose table no longer exists and removes it from tmdo_post_status_columns; every post status change used to UPDATE the missing table and print a database error (seen on dev36 after a throwaway group's table was dropped) - Verify: PostStatusSyncTest (dropped table), integration 578, unit 701, phpcs, stan - PR: PR-106 --- .../engine/class-tmdo-post-status-sync.php | 18 ++++++++++++++++++ tests/integration/PostStatusSyncTest.php | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/includes/engine/class-tmdo-post-status-sync.php b/includes/engine/class-tmdo-post-status-sync.php index 03743a5..1e91217 100644 --- a/includes/engine/class-tmdo-post-status-sync.php +++ b/includes/engine/class-tmdo-post-status-sync.php @@ -65,6 +65,10 @@ final class TMDO_Post_Status_Sync { unset( $new_status, $old_status ); foreach ( self::groups_with_columns() as $group ) { $table = TMDO_Schema_Manager::get_table_name( 'post', $group ); + if ( ! TMDO_Schema_Manager::table_exists( $table ) ) { + self::forget( $group ); // The table was dropped (retired or uninstalled group). + continue; + } $wpdb->query( $wpdb->prepare( "UPDATE `{$table}` SET `post_status` = %s, `post_date` = %s WHERE `post_id` = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Registry table name. @@ -220,6 +224,20 @@ final class TMDO_Post_Status_Sync { return is_array( $states ) ? array_keys( $states ) : array(); } + /** + * Drop a group from the recorded states. + * + * @param string $group Group name. + * @return void + */ + private static function forget( string $group ): void { + $states = get_option( self::OPTION, array() ); + if ( is_array( $states ) && isset( $states[ $group ] ) ) { + unset( $states[ $group ] ); + update_option( self::OPTION, $states, true ); + } + } + /** * Record a group's state. * diff --git a/tests/integration/PostStatusSyncTest.php b/tests/integration/PostStatusSyncTest.php index b4b4310..21654bb 100644 --- a/tests/integration/PostStatusSyncTest.php +++ b/tests/integration/PostStatusSyncTest.php @@ -134,6 +134,23 @@ class PostStatusSyncTest extends TestCase { $this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2], 'other posts untouched' ); } + public function test_a_group_whose_table_was_dropped_is_forgotten(): void { + global $wpdb; + TMDO_Post_Status_Sync::run(); + $wpdb->query( 'DROP TABLE `' . self::B . '`' ); + $ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' ); + $ref->setAccessible( true ); + $ref->setValue( null, array() ); + $wpdb->last_error = ''; + + TMDO_Post_Status_Sync::on_transition( 'trash', 'publish', (object) array( 'ID' => 1, 'post_status' => 'trash', 'post_date' => '2026-01-05 00:00:00' ) ); + + $this->assertSame( '', (string) $wpdb->last_error, 'no UPDATE of a missing table' ); + $this->assertSame( '', TMDO_Post_Status_Sync::state( 'ps_b' ), 'forgotten' ); + $this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) ); + $this->assertSame( array( 'trash', '2026-01-05 00:00:00' ), $this->copies( self::A )[1] ); + } + public function test_transition_leaves_tables_without_the_columns_alone(): void { global $wpdb; $wpdb->last_error = ''; -- 2.43.0 From e18030b2064a90b80d84d07b4aa36fb6d04cf41c Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:07:48 +0800 Subject: [PATCH 10/20] fix(2meet-data-optimizer): upgrade existing flat tables from WP-CLI or WP-Cron only - a changed group definition no longer runs dbDelta on an existing table inside a web request (seconds to minutes on a large table, and every concurrent request tried it): the group is recorded in tmdo_schema_deferred and tmdo_ensure_group_indexes is scheduled, whose request upgrades it - meanwhile the group stays native: Hook Bus field lookups return no field and the router treats it as drifting; afterwards a drift fix (tmdo_drift_resync) rebuilds the writes that skipped the flat table - new tables are still created at once; aeav_only upgrades at once (the flat table is the only copy); doctor lists deferred groups - Verify: SchemaUpgradeDeferralTest, Hook Bus and router tests, integration 582, unit 701, phpcs, stan; dev36 live cycle in the 2.3 acceptance - PR: PR-107 --- cli/class-tmdo-cli.php | 4 + includes/engine/class-tmdo-hook-bus.php | 7 +- includes/engine/class-tmdo-schema-manager.php | 78 +++++++++++++++- .../query/class-tmdo-post-query-router.php | 3 + tests/integration/HookBusIntegrationTest.php | 23 +++++ .../PostQueryRouterFoundRowsTest.php | 11 +++ .../integration/SchemaUpgradeDeferralTest.php | 91 +++++++++++++++++++ 7 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 tests/integration/SchemaUpgradeDeferralTest.php diff --git a/cli/class-tmdo-cli.php b/cli/class-tmdo-cli.php index b44bd5b..304f173 100644 --- a/cli/class-tmdo-cli.php +++ b/cli/class-tmdo-cli.php @@ -142,6 +142,10 @@ class TMDO_CLI { foreach ( $violations as $entity_type => $integrations ) { WP_CLI::warning( " [GUARD] {$entity_type} is aeav_only while " . implode( ', ', $integrations ) . " reads native meta — write flat back with `wp tmdo bridge-restore-native {$entity_type} --demote`." ); } + $deferred = array_keys( (array) get_option( TMDO_Schema_Manager::DEFERRED_OPTION, array() ) ); + if ( $deferred ) { + WP_CLI::warning( ' [SCHEMA] table upgrades not done yet (these groups use native meta meanwhile): ' . implode( ', ', $deferred ) . ' — see the error log if this run did not finish them.' ); + } $pending = TMDO_Schema_Manager::pending_indexes(); if ( $pending ) { WP_CLI::warning( ' [INDEX] declared indexes not added yet: ' . implode( ', ', $pending ) . ' — still missing after this run: another process is adding them, or the ALTER failed (see the `schema.index_failed` log).' ); diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php index 7b0d4ea..ce6be65 100644 --- a/includes/engine/class-tmdo-hook-bus.php +++ b/includes/engine/class-tmdo-hook-bus.php @@ -948,10 +948,11 @@ final class TMDO_Hook_Bus { */ private static function field_for( string $type, int $object_id, string $meta_key ): ?array { $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key ); - if ( null === $field_def || 'post' !== $type ) { - return $field_def; + if ( null !== $field_def && 'post' === $type ) { + $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) ); } - return TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) ); + // A group whose table upgrade waits for WP-CLI / WP-Cron stays native. + return null !== $field_def && TMDO_Schema_Manager::is_deferred( $type, (string) $field_def['group'] ) ? null : $field_def; } /** diff --git a/includes/engine/class-tmdo-schema-manager.php b/includes/engine/class-tmdo-schema-manager.php index 88ee68b..527c9da 100644 --- a/includes/engine/class-tmdo-schema-manager.php +++ b/includes/engine/class-tmdo-schema-manager.php @@ -26,6 +26,9 @@ final class TMDO_Schema_Manager { /** Single cron event that adds declared indexes a visitor request had to leave out. */ public const INDEXES_HOOK = 'tmdo_ensure_group_indexes'; + /** Option: "entity/group" → time an existing table's upgrade was left to WP-CLI / WP-Cron. */ + public const DEFERRED_OPTION = 'tmdo_schema_deferred'; + /** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */ private static array $table_exists_cache = array(); @@ -95,7 +98,12 @@ final class TMDO_Schema_Manager { return false; } - $table = self::get_table_name( $entity_type, $group_name ); + $table = self::get_table_name( $entity_type, $group_name ); + if ( self::upgrade_waits( $entity_type, $table ) ) { + self::defer_upgrade( "{$entity_type}/{$group_name}" ); + return false; + } + $charset = $wpdb->get_charset_collate(); $id_col = $adapter->get_entity_id_column(); @@ -167,6 +175,7 @@ final class TMDO_Schema_Manager { // 記錄 Schema 版本與定義 self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions ); + self::end_deferral( $entity_type, $group_name ); /** * Action: 表建立/升級完成 @@ -269,6 +278,73 @@ final class TMDO_Schema_Manager { } } + /** + * Whether the group's table upgrade is waiting for WP-CLI / WP-Cron; its + * keys then stay native (Hook Bus, query router). + * + * @param string $entity_type Entity type. + * @param string $group_name Group name. + * @return bool + */ + public static function is_deferred( string $entity_type, string $group_name ): bool { + $deferred = get_option( self::DEFERRED_OPTION, array() ); + return is_array( $deferred ) && isset( $deferred[ "{$entity_type}/{$group_name}" ] ); + } + + /** + * Whether an existing table's upgrade waits for WP-CLI / WP-Cron: altering + * a large table takes seconds to minutes, as for indexes. Until then the + * group reads and writes native meta (is_deferred()). Not in aeav_only, + * where the flat table is the only copy: that upgrade runs at once. + * + * @param string $entity_type Entity type. + * @param string $table Flat table name. + * @return bool + */ + public static function upgrade_waits( string $entity_type, string $table ): bool { + return self::table_exists( $table ) + && ! self::ddl_allowed() + && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type ); + } + + /** + * Record a deferred table upgrade and have WP-Cron run it. + * + * @param string $id "entity/group". + * @return void + */ + private static function defer_upgrade( string $id ): void { + $deferred = get_option( self::DEFERRED_OPTION, array() ); + $deferred = is_array( $deferred ) ? $deferred : array(); + if ( ! isset( $deferred[ $id ] ) ) { + $deferred[ $id ] = time(); + update_option( self::DEFERRED_OPTION, $deferred, true ); + } + if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) { + wp_schedule_single_event( time(), self::INDEXES_HOOK ); // Its request's init runs the upgrade. + } + } + + /** + * A deferred upgrade is done: writes made meanwhile skipped the flat table, + * so rebuild it from native (drift fix) a minute later. + * + * @param string $entity_type Entity type. + * @param string $group_name Group name. + * @return void + */ + private static function end_deferral( string $entity_type, string $group_name ): void { + if ( ! self::is_deferred( $entity_type, $group_name ) ) { + return; + } + $deferred = (array) get_option( self::DEFERRED_OPTION, array() ); + unset( $deferred[ "{$entity_type}/{$group_name}" ] ); + update_option( self::DEFERRED_OPTION, $deferred, true ); + if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && ! wp_next_scheduled( TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) ) ) { + wp_schedule_single_event( time() + MINUTE_IN_SECONDS, TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) ); + } + } + /** * Key of the MySQL lock held while indexes are added. Lock names are * server-wide, so SQL hashes it with DATABASE() to keep sites apart. diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index 161b6b4..acdb148 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -634,6 +634,9 @@ class TMDO_Post_Query_Router { if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( 'post' ) ) { return false; } + if ( TMDO_Schema_Manager::is_deferred( 'post', $group ) ) { + return true; // Table upgrade waiting for WP-CLI / WP-Cron. + } $status = get_option( TMDO_Drift_Check::OPTION, array() ); $last = is_array( $status ) ? ( $status['post'][ $group ] ?? null ) : null; return is_array( $last ) && (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0; diff --git a/tests/integration/HookBusIntegrationTest.php b/tests/integration/HookBusIntegrationTest.php index b4e4fec..5d60c23 100644 --- a/tests/integration/HookBusIntegrationTest.php +++ b/tests/integration/HookBusIntegrationTest.php @@ -227,6 +227,29 @@ class HookBusIntegrationTest extends TestCase { ); } + /** + * A group whose table upgrade waits for WP-CLI / WP-Cron (2.3 PR-107) + * stays native: its table may lack the new columns. + */ + public function test_a_group_waiting_for_its_table_upgrade_stays_native(): void { + global $wpdb; + self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE ); + update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/core_profile' => time() ) ); + try { + $this->with_usermeta( + function () use ( $wpdb ) { + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => 4, 'meta_key' => 'first_name', 'meta_value' => 'Dora' ) ); + + WPDO_Hook_Bus::sync_from_native( 'user', 4, 'first_name', 'Dora', 'update' ); + + $this->assertNull( $this->read_flat_first_name( 4 ) ); + } + ); + } finally { + delete_option( TMDO_Schema_Manager::DEFERRED_OPTION ); + } + } + /** * delete_all passes object ID 0: the delete_ action records the touched * users from the meta IDs, the deleted_ action re-syncs each to NULL. diff --git a/tests/integration/PostQueryRouterFoundRowsTest.php b/tests/integration/PostQueryRouterFoundRowsTest.php index f66e4cc..a374e91 100644 --- a/tests/integration/PostQueryRouterFoundRowsTest.php +++ b/tests/integration/PostQueryRouterFoundRowsTest.php @@ -90,6 +90,17 @@ class PostQueryRouterFoundRowsTest extends TestCase { return array( $router, $query, $request ); } + public function test_a_group_waiting_for_its_table_upgrade_is_not_routed(): void { + $mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); + $mode->setAccessible( true ); + $mode->setValue( null, array( 'post' => 'shadow_read', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) ); + update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'post/fr_core' => time() ) ); + + list( , $query ) = $this->routed(); + + $this->assertEmpty( $query->get( 'wpdo_post_clauses' ), 'kept native' ); + } + public function test_routed_request_drops_sql_calc_found_rows(): void { list( $router, $query, $request ) = $this->routed(); diff --git a/tests/integration/SchemaUpgradeDeferralTest.php b/tests/integration/SchemaUpgradeDeferralTest.php new file mode 100644 index 0000000..979bc02 --- /dev/null +++ b/tests/integration/SchemaUpgradeDeferralTest.php @@ -0,0 +1,91 @@ +query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + $wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, sd_a VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' ); + $wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) ); + $wpdb->insert( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core', 'schema_hash' => 'old', 'field_definitions' => '[]' ) ); + + $GLOBALS['_wp_options'] = array(); + $GLOBALS['_wp_scheduled_single'] = array(); + $GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array(); // A web request. + $ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' ); + $ref->setAccessible( true ); + $ref->setValue( null, array() ); + self::set_user_mode( 'dual_write' ); + + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); + WPDO_Entity_Registry::register_group( 'user', 'sd_core', array( array( 'key' => 'sd_a', 'type' => 'text' ), array( 'key' => 'sd_b', 'type' => 'integer' ) ) ); + } + + protected function tearDown(): void { + global $wpdb; + $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); + $wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) ); + WPDO_Entity_Registry::init(); + self::set_user_mode( null ); + unset( $GLOBALS['_wp_scheduled_single'] ); + } + + private static function set_user_mode( ?string $mode ): void { + $ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); + $ref->setAccessible( true ); + $ref->setValue( null, null === $mode ? null : array( 'post' => 'disabled', 'user' => $mode, 'term' => 'disabled', 'comment' => 'disabled' ) ); + } + + private function upgrade(): bool { + return TMDO_Schema_Manager::create_or_upgrade_table( 'user', 'sd_core', WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) ); + } + + public function test_a_web_request_leaves_the_upgrade_to_wp_cron_and_the_group_native(): void { + global $wpdb; + + $this->assertFalse( $this->upgrade() ); + + $this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) ); + $this->assertContains( TMDO_Schema_Manager::INDEXES_HOOK, $GLOBALS['_wp_scheduled_single'] ); + $this->assertSame( array( 'id', 'user_id', 'sd_a' ), $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::TABLE . '`' ), 'no ALTER' ); + $this->assertSame( 'old', TMDO_Schema_Manager::get_stored_schema_hash( 'user', 'sd_core' ) ); + } + + public function test_only_existing_tables_outside_aeav_only_and_outside_cli_or_cron_wait(): void { + $this->assertTrue( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) ); + $this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', 'wp_itest_wpdo_user_sd_missing' ), 'a new table is created at once' ); + + self::set_user_mode( 'aeav_only' ); + $this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ), 'aeav_only: the flat table is the only copy' ); + + self::set_user_mode( 'dual_write' ); + add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); // WP-CLI / WP-Cron. + $this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) ); + $GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array(); + } +} -- 2.43.0 From 06a35944175da772ccae0aba430e15576f84fb9b Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:09:10 +0800 Subject: [PATCH 11/20] fix(2meet-data-optimizer): take the index lock on the primary under read/write splitting - when $wpdb has send_reads_to_masters() (HyperDB, LudicrousDB), it is called before SELECT GET_LOCK, which a replica would take without locking out the ALTER on the primary - Verify: GroupCompositeIndexTest (call order through a wpdb proxy), integration 583, unit 701, phpcs, stan - PR: PR-108 --- includes/engine/class-tmdo-schema-manager.php | 5 +++ tests/integration/GroupCompositeIndexTest.php | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/includes/engine/class-tmdo-schema-manager.php b/includes/engine/class-tmdo-schema-manager.php index 527c9da..710f06b 100644 --- a/includes/engine/class-tmdo-schema-manager.php +++ b/includes/engine/class-tmdo-schema-manager.php @@ -231,6 +231,11 @@ final class TMDO_Schema_Manager { // processes both "acquire" an option lock; GET_LOCK is atomic and is // released by MySQL if this process dies. $lock = self::index_lock_name(); + if ( method_exists( $wpdb, 'send_reads_to_masters' ) ) { + // HyperDB / LudicrousDB would send the SELECT GET_LOCK to a replica, + // where it locks nothing against the ALTER on the primary. + $wpdb->send_reads_to_masters(); + } if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(MD5(CONCAT(DATABASE(), %s)), 0)', $lock ) ) ) { return; // Another process is adding indexes. } diff --git a/tests/integration/GroupCompositeIndexTest.php b/tests/integration/GroupCompositeIndexTest.php index e8d6e3e..4f335da 100644 --- a/tests/integration/GroupCompositeIndexTest.php +++ b/tests/integration/GroupCompositeIndexTest.php @@ -134,6 +134,45 @@ class GroupCompositeIndexTest extends TestCase { $this->assertSame( '1', (string) $wpdb->get_var( $wpdb->prepare( 'SELECT IS_FREE_LOCK(MD5(CONCAT(DATABASE(), %s)))', TMDO_Schema_Manager::index_lock_name() ) ), 'lock released' ); } + /** + * Under read/write splitting (HyperDB, LudicrousDB) a SELECT may go to a + * replica, where GET_LOCK would lock nothing: reads go to the primary first. + */ + public function test_the_lock_is_taken_on_the_primary_under_read_write_splitting(): void { + global $wpdb; + $real = $wpdb; + $wpdb = new class( $real ) { + /** @var string[] */ + public array $calls = array(); + + public function __construct( private object $db ) {} + + public function send_reads_to_masters(): void { + $this->calls[] = 'send_reads_to_masters'; + } + + public function __call( string $name, array $args ) { + if ( 'get_var' === $name && str_contains( (string) $args[0], 'GET_LOCK' ) ) { + $this->calls[] = 'GET_LOCK'; + } + return $this->db->$name( ...$args ); + } + + public function __get( string $name ) { + return $this->db->$name; + } + }; + try { + TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' ); + $calls = $wpdb->calls; + } finally { + $wpdb = $real; + } + + $this->assertSame( array( 'send_reads_to_masters', 'GET_LOCK' ), $calls ); + $this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() ); + } + /** @return array index name → ordered columns */ private function indexes(): array { global $wpdb; -- 2.43.0 From eb1c8b78f4d2e7c7c8d3496c15af0dc8a1659056 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:12:14 +0800 Subject: [PATCH 12/20] perf(2meet-data-optimizer): flush the cold cache group without a server-side scan - TMDO_Cache_Layer::flush_group() bumps the post type's cold cache version (option wpdo_cold_cache_version) instead of wp_cache_flush_group(), which Redis Object Cache runs as one server-side scan that blocks Redis on a large group; entries under older keys expire by their TTL - TMDO_Zone_Cold::cache_key() builds every cold blob key; version 0 keeps the current keys, so upgrading invalidates nothing - Verify: CacheLayerTest (new keys, no scan, other post types untouched), unit 702, integration 583, phpcs, stan - PR: PR-110 --- includes/class-tmdo-cache-layer.php | 24 ++++++++++----------- includes/zones/class-tmdo-zone-cold.php | 28 ++++++++++++++++++++----- tests/unit/CacheLayerTest.php | 19 +++++++++++++++++ 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/includes/class-tmdo-cache-layer.php b/includes/class-tmdo-cache-layer.php index 0db82cc..2eabb4d 100644 --- a/includes/class-tmdo-cache-layer.php +++ b/includes/class-tmdo-cache-layer.php @@ -49,7 +49,7 @@ class TMDO_Cache_Layer { // Check which IDs are already cached. $uncached = array(); foreach ( $post_ids as $pid ) { - $cached = wp_cache_get( "cold_{$pid}", $group ); + $cached = wp_cache_get( TMDO_Zone_Cold::cache_key( (int) $pid, $post_type ), $group ); if ( false === $cached ) { $uncached[] = (int) $pid; } @@ -94,7 +94,7 @@ class TMDO_Cache_Layer { // Cache all results (including empty arrays for posts with no cold data). foreach ( $uncached as $pid ) { $data = $fetched[ $pid ] ?? array(); - wp_cache_set( "cold_{$pid}", $data, $group, $ttl ); + wp_cache_set( TMDO_Zone_Cold::cache_key( $pid, $post_type ), $data, $group, $ttl ); } } @@ -114,7 +114,7 @@ class TMDO_Cache_Layer { // Force a fresh read from DB (bypasses cache). $group = 'wpdo_cold_' . sanitize_key( $post_type ); - wp_cache_delete( "cold_{$post_id}", $group ); + wp_cache_delete( TMDO_Zone_Cold::cache_key( $post_id, $post_type ), $group ); // Re-read will populate cache. TMDO_Zone_Cold::get_blob( $post_id, $post_type ); @@ -136,18 +136,18 @@ class TMDO_Cache_Layer { /** * Flush all cold cache for a post type. * - * Useful after bulk imports or schema changes. + * Useful after bulk imports or schema changes. Bumps the post type's cache + * version (new keys from now on) instead of wp_cache_flush_group(), which + * Redis Object Cache runs as one server-side scan that blocks Redis on a + * large group; older entries expire by their TTL. * * @param string $post_type Post type to flush. */ public static function flush_group( string $post_type ): void { - $group = 'wpdo_cold_' . sanitize_key( $post_type ); - - if ( function_exists( 'wp_cache_flush_group' ) ) { - wp_cache_flush_group( $group ); - } - // If wp_cache_flush_group is not available (older WP or no object cache), - // individual entries will expire via TTL. + $versions = get_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, array() ); + $versions = is_array( $versions ) ? $versions : array(); + $versions[ $post_type ] = (int) ( $versions[ $post_type ] ?? 0 ) + 1; + update_option( TMDO_Zone_Cold::CACHE_VERSION_OPTION, $versions, true ); } /** @@ -183,7 +183,7 @@ class TMDO_Cache_Layer { return array( 'groups' => $groups, 'prefetch_support' => true, - 'flush_support' => function_exists( 'wp_cache_flush_group' ), + 'flush_support' => true, ); } diff --git a/includes/zones/class-tmdo-zone-cold.php b/includes/zones/class-tmdo-zone-cold.php index 0fcdfa0..2cfc1c0 100644 --- a/includes/zones/class-tmdo-zone-cold.php +++ b/includes/zones/class-tmdo-zone-cold.php @@ -25,6 +25,9 @@ if ( ! defined( 'ABSPATH' ) ) { */ class TMDO_Zone_Cold { + /** Option: post type → cold cache version (TMDO_Cache_Layer::flush_group()). */ + public const CACHE_VERSION_OPTION = 'wpdo_cold_cache_version'; + /** * Get the cold table name for a post type. * @@ -45,6 +48,21 @@ class TMDO_Zone_Cold { return 'wpdo_cold_' . sanitize_key( $post_type ); } + /** + * Object-cache key of a post's cold blob. It carries the post type's cache + * version once TMDO_Cache_Layer::flush_group() has bumped it (none before, + * so existing entries keep their keys). + * + * @param int $post_id Post ID. + * @param string $post_type Post type slug. + * @return string + */ + public static function cache_key( int $post_id, string $post_type ): string { + $versions = get_option( self::CACHE_VERSION_OPTION, array() ); + $version = is_array( $versions ) ? (int) ( $versions[ $post_type ] ?? 0 ) : 0; + return $version ? "cold_{$post_id}_v{$version}" : "cold_{$post_id}"; + } + /** * Ensure the cold table exists for a post type. * @@ -92,7 +110,7 @@ class TMDO_Zone_Cold { */ public static function get_blob( int $post_id, string $post_type ): array { $group = self::cache_group( $post_type ); - $cache_key = "cold_{$post_id}"; + $cache_key = self::cache_key( $post_id, $post_type ); $cached = wp_cache_get( $cache_key, $group ); if ( false !== $cached ) { @@ -202,7 +220,7 @@ class TMDO_Zone_Cold { ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) ); + wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) ); } /** @@ -215,7 +233,7 @@ class TMDO_Zone_Cold { public static function delete( int $post_id, string $post_type ): void { global $wpdb; $wpdb->delete( self::table( $post_type ), array( 'post_id' => $post_id ), array( '%d' ) ); - wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) ); + wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) ); } // ── Private helpers ─────────────────────────────────────────────────── @@ -264,7 +282,7 @@ class TMDO_Zone_Cold { ); // Invalidate object cache. - wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) ); + wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) ); } /** @@ -305,6 +323,6 @@ class TMDO_Zone_Cold { ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) ); + wp_cache_delete( self::cache_key( $post_id, $post_type ), self::cache_group( $post_type ) ); } } diff --git a/tests/unit/CacheLayerTest.php b/tests/unit/CacheLayerTest.php index e15a618..d22cbd7 100644 --- a/tests/unit/CacheLayerTest.php +++ b/tests/unit/CacheLayerTest.php @@ -197,6 +197,25 @@ class CacheLayerTest extends TestCase { $this->assertNotSame( [ 'stale' => true ], $GLOBALS['_wp_cache'][ $group ][ $cache_key ] ?? null ); } + /** + * Flushing bumps the post type's cache version instead of asking the + * object cache to scan the group (a server-side script on Redis that + * blocks it on a large group); older entries expire by their TTL. + */ + public function test_flush_group_moves_to_new_keys_without_a_group_flush(): void { + $group = 'wpdo_cold_hp_listing'; + $old_key = WPDO_Zone_Cold::cache_key( 1, 'hp_listing' ); + $GLOBALS['_wp_cache'][ $group ][ $old_key ] = array( 'stale' => true ); + + WPDO_Cache_Layer::flush_group( 'hp_listing' ); + + $new_key = WPDO_Zone_Cold::cache_key( 1, 'hp_listing' ); + $this->assertNotSame( $old_key, $new_key ); + $this->assertFalse( wp_cache_get( $new_key, $group ) ); + $this->assertSame( 'cold_2', WPDO_Zone_Cold::cache_key( 2, 'other_type' ), 'other post types keep their keys' ); + $this->assertArrayHasKey( $old_key, $GLOBALS['_wp_cache'][ $group ], 'nothing scanned or deleted' ); + } + // ── get_stats() ────────────────────────────────────────────────────────── public function test_get_stats_includes_required_keys(): void { -- 2.43.0 From 0aec4b9bb60bfc2d6ec16e61b0f0c4bbe0a5cac9 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:12:54 +0800 Subject: [PATCH 13/20] docs(2meet-data-optimizer): add PR-106 to PR-110 to the 1.3.0 release notes - Changed: deferred table upgrades (PR-107), index lock on the primary (PR-108), cold cache flush by version (PR-110); Fixed: dropped tables in post status sync (PR-106) - Verify: composer test - PR: PR-101 (follow-up) --- CHANGELOG.md | 11 +++++++++++ readme.txt | 3 +++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16d5fd6..50cf6fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,12 +29,23 @@ Versioning follows [Semantic Versioning](https://semver.org/). (filter `tmdo_log_info`);`debug()`、`warning()`、`error()` 不變。(PR-095) - 公開 listings REST 一律不輸出名稱像 email、phone、address、IP 的欄位,即使宣告 `show_in_rest`。(PR-096) +- **既有扁平表的 schema 升級只在 WP-CLI 或 WP-Cron 執行**:網頁請求遇到定義變更時記入 + `tmdo_schema_deferred` 並排 `tmdo_ensure_group_indexes`;升級前該 group 的 key 走原生 + (Hook Bus 不攔、router 不路由),升級後排 `tmdo_drift_resync` 補回期間跳過的扁平寫入。 + 新表照常立即建立;aeav_only 仍立即升級(扁平表是唯一副本);`doctor` 顯示 `[SCHEMA]`。(PR-107) +- 讀寫分離(HyperDB、LudicrousDB)時,取索引鎖前先 `send_reads_to_masters()`, + `GET_LOCK` 才會在 primary 上。(PR-108) +- Cold 快取的「清除」改為遞增版本號(`wpdo_cold_cache_version`),不再呼叫 + `wp_cache_flush_group()`(Redis Object Cache 以伺服器端腳本掃描整個 group,大 group 會卡住 + Redis);舊 key 依 TTL 過期。(PR-110) ### Fixed - 後台主頁與設定精靈第 4 步的表單改在 `load-{page}`(輸出前)處理,`wp_safe_redirect()` 可以正常生效;精靈快照失敗改以 `snap_failed=1` 導回。(PR-097) - `uninstall.php` 的全域變數加上 `$wpdo_` 前綴(WordPress.org Plugin Check)。(PR-098) +- 扁平表已被刪除的 group 不再留在 `tmdo_post_status_columns`:每次文章狀態變更都 UPDATE + 不存在的表並印出 DB error。(PR-106) ### Security(已驗證,無程式變更) diff --git a/readme.txt b/readme.txt index ee06e6f..1875c41 100644 --- a/readme.txt +++ b/readme.txt @@ -114,6 +114,9 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency, * Logger info / notice lines only with WP_DEBUG_LOG (filter `tmdo_log_info`) * The public listings REST output withholds email, phone, address and IP fields even when declared public * Admin form posts are handled before the page renders; uninstall.php globals are prefixed +* Existing flat tables are upgraded from WP-CLI or WP-Cron only; a group waiting for its upgrade uses native meta, then a drift fix rebuilds it +* The index lock goes to the primary under read/write splitting; clearing the cold cache bumps a version instead of scanning the object cache group +* Groups whose flat table was dropped no longer cause database errors on post status changes = 1.2.0 = * `wp tmdo drift-check [--group] [--sample] [--fix]`: reports flat-vs-native drift per key (miss / diff / extra) and rebuilds drifted flat rows from native; native meta is never written, and aeav_only sites are refused -- 2.43.0 From 29adb05567b2336fac37bf39ed52a2f03df13037 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:22:57 +0800 Subject: [PATCH 14/20] fix(2meet-data-optimizer): alter an existing flat table in one process at a time; keep a deferred group native until its drift fix - security review H1: anonymous /wp-cron.php requests pass ddl_allowed(), and dbDelta on an existing table had no lock, so concurrent requests each ran the ALTER on a large table. The upgrade now runs under the same MySQL GET_LOCK as the index DDL (skipped when another process holds it) and re-checks the stored hash once locked; this covers aeav_only too. The table build moves unchanged to write_table(); lock()/unlock() are shared - security review M1: after a deferred upgrade the group stays native until tmdo_drift_resync has rebuilt the rows written meanwhile (TMDO_Schema_Manager::resynced() from run_scheduled_fix()); with no flat writes (disabled) it is released at once - Verify: SchemaUpgradeDeferralTest (lock held elsewhere, resync release), integration 585, unit 702, phpcs, stan - PR: PR-111 (2.3 acceptance security review) --- cli/class-tmdo-cli.php | 2 +- .../diagnostic/class-tmdo-drift-check.php | 1 + includes/engine/class-tmdo-schema-manager.php | 115 +++++++++++++++--- .../integration/SchemaUpgradeDeferralTest.php | 51 ++++++++ 4 files changed, 149 insertions(+), 20 deletions(-) diff --git a/cli/class-tmdo-cli.php b/cli/class-tmdo-cli.php index 304f173..629f698 100644 --- a/cli/class-tmdo-cli.php +++ b/cli/class-tmdo-cli.php @@ -144,7 +144,7 @@ class TMDO_CLI { } $deferred = array_keys( (array) get_option( TMDO_Schema_Manager::DEFERRED_OPTION, array() ) ); if ( $deferred ) { - WP_CLI::warning( ' [SCHEMA] table upgrades not done yet (these groups use native meta meanwhile): ' . implode( ', ', $deferred ) . ' — see the error log if this run did not finish them.' ); + WP_CLI::warning( ' [SCHEMA] groups using native meta until their table upgrade and the drift resync after it (tmdo_drift_resync) are done: ' . implode( ', ', $deferred ) . '.' ); } $pending = TMDO_Schema_Manager::pending_indexes(); if ( $pending ) { diff --git a/includes/diagnostic/class-tmdo-drift-check.php b/includes/diagnostic/class-tmdo-drift-check.php index 0c06045..a8704a9 100644 --- a/includes/diagnostic/class-tmdo-drift-check.php +++ b/includes/diagnostic/class-tmdo-drift-check.php @@ -237,6 +237,7 @@ final class TMDO_Drift_Check { public static function run_scheduled_fix( string $entity_type ): void { try { $result = self::fix( $entity_type ); + TMDO_Schema_Manager::resynced( $entity_type ); // Groups upgraded while deferred use the flat table again. TMDO_Logger::info( 'drift_resync', array( 'entity' => $entity_type ) + $result['fixed'] ); } catch ( InvalidArgumentException | RuntimeException $e ) { TMDO_Logger::info( diff --git a/includes/engine/class-tmdo-schema-manager.php b/includes/engine/class-tmdo-schema-manager.php index 710f06b..c6d5ca8 100644 --- a/includes/engine/class-tmdo-schema-manager.php +++ b/includes/engine/class-tmdo-schema-manager.php @@ -83,8 +83,6 @@ final class TMDO_Schema_Manager { string $group_name, array $field_definitions ): bool { - global $wpdb; - // Schema 版本比對:若未變動則跳過 $schema_hash = self::calculate_schema_hash( $field_definitions ); $stored_hash = self::get_stored_schema_hash( $entity_type, $group_name ); @@ -103,6 +101,39 @@ final class TMDO_Schema_Manager { self::defer_upgrade( "{$entity_type}/{$group_name}" ); return false; } + if ( ! self::table_exists( $table ) ) { + return self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash ); + } + + // An existing table is altered by one process at a time (anonymous + // /wp-cron.php requests also pass ddl_allowed()); a process that waited + // finds the upgrade done. + if ( ! self::lock() ) { + return false; + } + try { + if ( self::get_stored_schema_hash( $entity_type, $group_name ) === $schema_hash ) { + return true; + } + return self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash ); + } finally { + self::unlock(); + } + } + + /** + * Create or alter the table (dbDelta) and record its definition. + * + * @param string $entity_type Entity type. + * @param string $group_name Group name. + * @param array> $field_definitions Normalized fields. + * @param TMDO_Entity_Adapter_Interface $adapter Entity adapter. + * @param string $table Table name. + * @param string $schema_hash Definition hash. + * @return bool + */ + private static function write_table( string $entity_type, string $group_name, array $field_definitions, TMDO_Entity_Adapter_Interface $adapter, string $table, string $schema_hash ): bool { + global $wpdb; $charset = $wpdb->get_charset_collate(); $id_col = $adapter->get_entity_id_column(); @@ -227,16 +258,7 @@ final class TMDO_Schema_Manager { return; } - // add_option() is INSERT ... ON DUPLICATE KEY UPDATE and would let two - // processes both "acquire" an option lock; GET_LOCK is atomic and is - // released by MySQL if this process dies. - $lock = self::index_lock_name(); - if ( method_exists( $wpdb, 'send_reads_to_masters' ) ) { - // HyperDB / LudicrousDB would send the SELECT GET_LOCK to a replica, - // where it locks nothing against the ALTER on the primary. - $wpdb->send_reads_to_masters(); - } - if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(MD5(CONCAT(DATABASE(), %s)), 0)', $lock ) ) ) { + if ( ! self::lock() ) { return; // Another process is adding indexes. } try { @@ -267,7 +289,7 @@ final class TMDO_Schema_Manager { update_option( self::INDEXES_OPTION, $applied, true ); } } finally { - $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), %s)))', $lock ) ); + self::unlock(); } } @@ -331,8 +353,10 @@ final class TMDO_Schema_Manager { } /** - * A deferred upgrade is done: writes made meanwhile skipped the flat table, - * so rebuild it from native (drift fix) a minute later. + * A deferred upgrade is done. Writes made meanwhile skipped the flat table, + * so the group stays native until a drift fix (tmdo_drift_resync, a minute + * later) has rebuilt it from native and calls resynced(). With no flat + * writes (disabled), nothing is behind. * * @param string $entity_type Entity type. * @param string $group_name Group name. @@ -342,14 +366,67 @@ final class TMDO_Schema_Manager { if ( ! self::is_deferred( $entity_type, $group_name ) ) { return; } - $deferred = (array) get_option( self::DEFERRED_OPTION, array() ); - unset( $deferred[ "{$entity_type}/{$group_name}" ] ); - update_option( self::DEFERRED_OPTION, $deferred, true ); - if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && ! wp_next_scheduled( TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) ) ) { + if ( ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) ) { + $deferred = (array) get_option( self::DEFERRED_OPTION, array() ); + unset( $deferred[ "{$entity_type}/{$group_name}" ] ); + update_option( self::DEFERRED_OPTION, $deferred, true ); + return; + } + if ( ! wp_next_scheduled( TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) ) ) { wp_schedule_single_event( time() + MINUTE_IN_SECONDS, TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) ); } } + /** + * A drift fix of the entity succeeded: its deferred groups whose table is + * upgraded read and write the flat table again. + * + * @param string $entity_type Entity type. + * @return void + */ + public static function resynced( string $entity_type ): void { + $deferred = get_option( self::DEFERRED_OPTION, array() ); + if ( ! is_array( $deferred ) || ! $deferred ) { + return; + } + foreach ( array_keys( $deferred ) as $id ) { + list( $type, $group ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' ); + if ( $type === $entity_type && self::get_stored_schema_hash( $type, $group ) === self::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( $type, $group ) ) ) { + unset( $deferred[ $id ] ); + } + } + update_option( self::DEFERRED_OPTION, $deferred, true ); + } + + /** + * Take the schema lock without waiting. + * + * add_option() is INSERT ... ON DUPLICATE KEY UPDATE and would let two + * processes both "acquire" an option lock; GET_LOCK is atomic and is + * released by MySQL if this process dies. Re-entrant per connection. + * + * @return bool False when another process holds it. + */ + private static function lock(): bool { + global $wpdb; + if ( method_exists( $wpdb, 'send_reads_to_masters' ) ) { + // HyperDB / LudicrousDB would send the SELECT GET_LOCK to a replica, + // where it locks nothing against the ALTER on the primary. + $wpdb->send_reads_to_masters(); + } + return '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(MD5(CONCAT(DATABASE(), %s)), 0)', self::index_lock_name() ) ); + } + + /** + * Release the schema lock. + * + * @return void + */ + private static function unlock(): void { + global $wpdb; + $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), %s)))', self::index_lock_name() ) ); + } + /** * Key of the MySQL lock held while indexes are added. Lock names are * server-wide, so SQL hashes it with DATABASE() to keep sites apart. diff --git a/tests/integration/SchemaUpgradeDeferralTest.php b/tests/integration/SchemaUpgradeDeferralTest.php index 979bc02..4891d91 100644 --- a/tests/integration/SchemaUpgradeDeferralTest.php +++ b/tests/integration/SchemaUpgradeDeferralTest.php @@ -88,4 +88,55 @@ class SchemaUpgradeDeferralTest extends TestCase { $this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) ); $GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array(); } + + /** + * An existing table is altered by one process at a time: anonymous + * /wp-cron.php requests pass ddl_allowed() too (security review H1). + */ + public function test_an_upgrade_is_skipped_while_another_process_holds_the_schema_lock(): void { + add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); + $other = new mysqli( + getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1', + getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo', + (string) getenv( 'TMDO_TEST_DB_PASS' ), + getenv( 'TMDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test' + ); + $name = $other->real_escape_string( TMDO_Schema_Manager::index_lock_name() ); + $other->query( "SELECT GET_LOCK(MD5(CONCAT(DATABASE(), '{$name}')), 0)" ); + try { + $this->assertFalse( $this->upgrade(), 'returns before dbDelta (unavailable here)' ); + } finally { + $other->query( "SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), '{$name}')))" ); + $other->close(); + $GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array(); + } + $this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) ); + } + + /** + * After the upgrade the group stays native until the drift fix has + * rebuilt the rows written meanwhile (security review M1). + */ + public function test_a_drift_fix_releases_only_upgraded_groups_of_its_entity(): void { + global $wpdb; + $current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) ); + update_option( + TMDO_Schema_Manager::DEFERRED_OPTION, + array( + 'user/sd_core' => 1, + 'user/sd_later' => 1, // Not upgraded yet (no stored definition). + 'post/sd_core' => 1, // Another entity. + ) + ); + + TMDO_Schema_Manager::resynced( 'user' ); + $this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'stored hash still old: not upgraded' ); + + $wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) ); + TMDO_Schema_Manager::resynced( 'user' ); + + $this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) ); + $this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_later' ) ); + $this->assertTrue( TMDO_Schema_Manager::is_deferred( 'post', 'sd_core' ) ); + } } -- 2.43.0 From e26aadc4ec41df4a3d7a77eaada210da0b9fe21e Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:25:56 +0800 Subject: [PATCH 15/20] fix(2meet-data-optimizer): keep the audit trail of mode changes and snapshot deletions - TMDO_Logger::notice() is always written to the error log (it had no callers); bridge_mode_changed and snapshot_deleted use it. Since PR-095 info() is silent without WP_DEBUG_LOG, which left no record of who switched a mode or deleted a snapshot (security review L1) - Verify: LoggerTest (notice always logged, info gated), unit 703, integration 585, phpcs, stan - PR: PR-113 (2.3 acceptance security review) --- includes/class-tmdo-logger.php | 11 ++++++----- includes/engine/class-tmdo-mode-manager.php | 2 +- includes/snapshots/class-tmdo-snapshot-manager.php | 2 +- tests/unit/LoggerTest.php | 13 ++++++++----- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/includes/class-tmdo-logger.php b/includes/class-tmdo-logger.php index 1ceff62..4d7785f 100644 --- a/includes/class-tmdo-logger.php +++ b/includes/class-tmdo-logger.php @@ -77,15 +77,16 @@ class TMDO_Logger { } /** - * Log a NOTICE-level event — alias of info() for callers that want a stronger - * level intent. Same routing (error_log only). + * Log a NOTICE-level event: always written to the PHP error log (unlike + * info()), for the audit trail of operator actions such as mode changes + * and snapshot deletions. * - * @param string $event Event name. - * @param array $context Structured context. + * @param string $event Event name. + * @param array $context Structured context. * @return void */ public static function notice( string $event, array $context = array() ): void { - self::info( $event, $context ); + error_log( sprintf( '[WPDO][NOTICE][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) ); } /** diff --git a/includes/engine/class-tmdo-mode-manager.php b/includes/engine/class-tmdo-mode-manager.php index fa79a90..78494fa 100644 --- a/includes/engine/class-tmdo-mode-manager.php +++ b/includes/engine/class-tmdo-mode-manager.php @@ -159,7 +159,7 @@ final class TMDO_Mode_Manager { $entered[ $entity_type ] = time(); update_option( self::OPT_ENTERED_AT, $entered, false ); - TMDO_Logger::info( + TMDO_Logger::notice( 'bridge_mode_changed', array( 'entity_type' => $entity_type, diff --git a/includes/snapshots/class-tmdo-snapshot-manager.php b/includes/snapshots/class-tmdo-snapshot-manager.php index a4ec226..b654de4 100644 --- a/includes/snapshots/class-tmdo-snapshot-manager.php +++ b/includes/snapshots/class-tmdo-snapshot-manager.php @@ -310,7 +310,7 @@ class TMDO_Snapshot_Manager { } $table = $wpdb->prefix . self::TABLE_SLUG; $deleted = $wpdb->delete( $table, array( 'snapshot_id' => $snapshot_id ), array( '%s' ) ); // phpcs:ignore WordPress.DB - TMDO_Logger::info( 'snapshot_deleted', array( 'snapshot_id' => $snapshot_id ) ); + TMDO_Logger::notice( 'snapshot_deleted', array( 'snapshot_id' => $snapshot_id ) ); return false !== $deleted; } diff --git a/tests/unit/LoggerTest.php b/tests/unit/LoggerTest.php index b4f9578..6b76817 100644 --- a/tests/unit/LoggerTest.php +++ b/tests/unit/LoggerTest.php @@ -145,13 +145,10 @@ class LoggerTest extends TestCase { return $out; } - public function test_info_and_notice_stay_out_of_the_log_unless_debug_logging_is_on(): void { + public function test_info_stays_out_of_the_log_unless_debug_logging_is_on(): void { $GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array(); - $this->assertSame( '', $this->logged( static function () { - WPDO_Logger::info( 'cache_flushed' ); - WPDO_Logger::notice( 'mode_changed' ); - } ) ); + $this->assertSame( '', $this->logged( static fn() => WPDO_Logger::info( 'cache_flushed' ) ) ); add_filter( 'tmdo_log_info', static fn() => true ); try { @@ -161,6 +158,12 @@ class LoggerTest extends TestCase { } } + public function test_notices_are_always_logged_for_the_audit_trail(): void { + $GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array(); + + $this->assertStringContainsString( '[WPDO][NOTICE][bridge_mode_changed]', $this->logged( static fn() => WPDO_Logger::notice( 'bridge_mode_changed', array( 'to' => 'dual_write' ) ) ) ); + } + public function test_warnings_are_always_logged(): void { $this->assertStringContainsString( '[WPDO][WARN][odd_return]', $this->logged( static fn() => WPDO_Logger::warning( 'odd_return' ) ) ); } -- 2.43.0 From 1d70d13f45e68929444ff128fc19e1dd7ebf915c Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:30:36 +0800 Subject: [PATCH 16/20] fix(2meet-data-optimizer): close the gaps in deferred table upgrades found in the 2.3 review - a deferred group whose stored definition matches again (reverted, or upgraded by another process) goes through end_deferral() instead of staying native for good (review F2) - the tmdo_ensure_group_indexes handler upgrades deferred tables itself: with ALTERNATE_WP_CRON the event runs after init in a visitor's request (F3) - a new group table created while the entity writes to flat stays native until the drift fix has filled it from native meta; an opted-in HivePress attribute no longer routes searches to an empty table (F6) - direct_read() returns native values for a deferred group (F10); the Hook Bus ignores deferral in aeav_only, as the router does - dbDelta failures: the definition is recorded only when every column exists (schema.upgrade_failed otherwise), so a failed ALTER is retried and the group is not released onto missing columns - test bootstrap: get_metadata() reads the real meta table - Verify: SchemaUpgradeDeferralTest (6), Hook Bus direct read, integration 587, unit 703, phpcs, stan - PR: PR-114 (2.3 acceptance correctness review) --- includes/engine/class-tmdo-hook-bus.php | 26 ++++++-- includes/engine/class-tmdo-schema-manager.php | 61 +++++++++++++++++-- tests/integration/HookBusIntegrationTest.php | 1 + .../integration/SchemaUpgradeDeferralTest.php | 35 +++++++++++ tests/integration/bootstrap.php | 13 ++++ 5 files changed, 125 insertions(+), 11 deletions(-) diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php index ce6be65..46f001a 100644 --- a/includes/engine/class-tmdo-hook-bus.php +++ b/includes/engine/class-tmdo-hook-bus.php @@ -951,8 +951,20 @@ final class TMDO_Hook_Bus { if ( null !== $field_def && 'post' === $type ) { $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) ); } - // A group whose table upgrade waits for WP-CLI / WP-Cron stays native. - return null !== $field_def && TMDO_Schema_Manager::is_deferred( $type, (string) $field_def['group'] ) ? null : $field_def; + // A group whose table upgrade waits for WP-CLI / WP-Cron stays native + // (not in aeav_only, where native is stale; the router agrees). + return null !== $field_def && self::deferred( $type, (string) $field_def['group'] ) ? null : $field_def; + } + + /** + * Whether a group is deferred and so read and written natively. + * + * @param string $type Entity type. + * @param string $group Group name. + * @return bool + */ + private static function deferred( string $type, string $group ): bool { + return TMDO_Schema_Manager::is_deferred( $type, $group ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $type ); } /** @@ -970,10 +982,13 @@ final class TMDO_Hook_Bus { // 回傳所有群組的所有欄位 $result = array(); foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) { - $row = self::get_or_load_row( $type, $entity_id, $group ); + $deferred = self::deferred( $type, $group ); + $row = $deferred ? array() : self::get_or_load_row( $type, $entity_id, $group ); foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) { $col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] ); - $result[ $field['key'] ] = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field ); + $result[ $field['key'] ] = $deferred + ? get_metadata( $type, $entity_id, $field['key'], true ) + : TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field ); } } return $result; @@ -981,7 +996,8 @@ final class TMDO_Hook_Bus { $field_def = self::field_for( $type, $entity_id, $key ); if ( ! $field_def ) { - return null; + // A deferred group's value is the native one. + return null !== TMDO_Entity_Registry::get_field( $type, $key ) ? get_metadata( $type, $entity_id, $key, true ) : null; } $row = self::get_or_load_row( $type, $entity_id, $field_def['group'] ); diff --git a/includes/engine/class-tmdo-schema-manager.php b/includes/engine/class-tmdo-schema-manager.php index c6d5ca8..004c267 100644 --- a/includes/engine/class-tmdo-schema-manager.php +++ b/includes/engine/class-tmdo-schema-manager.php @@ -88,6 +88,8 @@ final class TMDO_Schema_Manager { $stored_hash = self::get_stored_schema_hash( $entity_type, $group_name ); if ( $stored_hash === $schema_hash ) { + // Upgraded meanwhile, or the definition went back to the stored one. + self::end_deferral( $entity_type, $group_name ); return true; } @@ -102,7 +104,16 @@ final class TMDO_Schema_Manager { return false; } if ( ! self::table_exists( $table ) ) { - return self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash ); + if ( ! self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash ) ) { + return false; + } + // A new table starts empty while native meta may already hold the + // group's values: native until the drift fix has filled it in. + if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type ) ) { + self::record_deferred( "{$entity_type}/{$group_name}" ); + self::end_deferral( $entity_type, $group_name ); + } + return true; } // An existing table is altered by one process at a time (anonymous @@ -134,6 +145,7 @@ final class TMDO_Schema_Manager { */ private static function write_table( string $entity_type, string $group_name, array $field_definitions, TMDO_Entity_Adapter_Interface $adapter, string $table, string $schema_hash ): bool { global $wpdb; + $existed = self::table_exists( $table ); $charset = $wpdb->get_charset_collate(); $id_col = $adapter->get_entity_id_column(); @@ -204,9 +216,27 @@ final class TMDO_Schema_Manager { // Bust the request-scoped table_exists cache so subsequent calls see the new table. self::$table_exists_cache[ $table ] = true; + // dbDelta reports no failure: record the definition only when every column is there. + $columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" ); + $missing = array_diff( array_map( static fn( $field ) => self::sanitize_column_name( (string) $field['key'] ), $field_definitions ), $columns ); + if ( $missing ) { + unset( self::$table_exists_cache[ $table ] ); + TMDO_Logger::warning( + 'schema.upgrade_failed', + array( + 'table' => $table, + 'missing' => array_values( $missing ), + 'error' => $wpdb->last_error, + ) + ); + return false; + } + // 記錄 Schema 版本與定義 self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions ); - self::end_deferral( $entity_type, $group_name ); + if ( $existed ) { + self::end_deferral( $entity_type, $group_name ); + } /** * Action: 表建立/升級完成 @@ -294,11 +324,20 @@ final class TMDO_Schema_Manager { } /** - * INDEXES_HOOK handler: add the indexes a visitor request could not. + * INDEXES_HOOK handler: the table upgrades and indexes a web request left. + * The cron request's init usually did them already; not with + * ALTERNATE_WP_CRON, where the event runs inside a visitor's request. * * @return void */ public static function ensure_pending_indexes(): void { + foreach ( array_keys( (array) get_option( self::DEFERRED_OPTION, array() ) ) as $id ) { + list( $entity_type, $group_name ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' ); + $fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name ); + if ( $fields ) { + self::create_or_upgrade_table( $entity_type, $group_name, $fields ); + } + } foreach ( self::pending_indexes() as $id ) { list( $entity_type, $group_name ) = explode( '/', $id, 2 ); self::ensure_group_indexes( $entity_type, $group_name ); @@ -341,15 +380,25 @@ final class TMDO_Schema_Manager { * @return void */ private static function defer_upgrade( string $id ): void { + self::record_deferred( $id ); + if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) { + wp_schedule_single_event( time(), self::INDEXES_HOOK ); // Its request's init runs the upgrade. + } + } + + /** + * Record a group as deferred (native until resynced()). + * + * @param string $id "entity/group". + * @return void + */ + private static function record_deferred( string $id ): void { $deferred = get_option( self::DEFERRED_OPTION, array() ); $deferred = is_array( $deferred ) ? $deferred : array(); if ( ! isset( $deferred[ $id ] ) ) { $deferred[ $id ] = time(); update_option( self::DEFERRED_OPTION, $deferred, true ); } - if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) { - wp_schedule_single_event( time(), self::INDEXES_HOOK ); // Its request's init runs the upgrade. - } } /** diff --git a/tests/integration/HookBusIntegrationTest.php b/tests/integration/HookBusIntegrationTest.php index 5d60c23..e10c28f 100644 --- a/tests/integration/HookBusIntegrationTest.php +++ b/tests/integration/HookBusIntegrationTest.php @@ -243,6 +243,7 @@ class HookBusIntegrationTest extends TestCase { WPDO_Hook_Bus::sync_from_native( 'user', 4, 'first_name', 'Dora', 'update' ); $this->assertNull( $this->read_flat_first_name( 4 ) ); + $this->assertSame( 'Dora', WPDO_Hook_Bus::direct_read( 'user', 4, 'first_name' ), 'direct reads return the native value' ); } ); } finally { diff --git a/tests/integration/SchemaUpgradeDeferralTest.php b/tests/integration/SchemaUpgradeDeferralTest.php index 4891d91..be9ee4f 100644 --- a/tests/integration/SchemaUpgradeDeferralTest.php +++ b/tests/integration/SchemaUpgradeDeferralTest.php @@ -139,4 +139,39 @@ class SchemaUpgradeDeferralTest extends TestCase { $this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_later' ) ); $this->assertTrue( TMDO_Schema_Manager::is_deferred( 'post', 'sd_core' ) ); } + + /** + * The definition went back to the stored one (or another process upgraded + * the table) while the group was deferred (correctness review F2). + */ + public function test_a_deferred_group_whose_definition_matches_again_is_resynced_then_released(): void { + global $wpdb; + $current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) ); + $wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) ); + update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) ); + + $this->assertTrue( $this->upgrade() ); + + $this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'dual_write: native until the drift fix' ); + $this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] ); + + self::set_user_mode( 'disabled' ); + $this->upgrade(); + $this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'no flat writes: nothing to rebuild' ); + } + + /** + * The cron event upgrades deferred groups itself: with ALTERNATE_WP_CRON + * it runs inside a visitor's request, after init (correctness review F3). + */ + public function test_the_cron_event_handles_deferred_groups(): void { + global $wpdb; + $current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) ); + $wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) ); + update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) ); + + TMDO_Schema_Manager::ensure_pending_indexes(); + + $this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] ); + } } diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 1c9cbcc..99679d6 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -299,6 +299,19 @@ if ( ! function_exists( 'do_action_ref_array' ) ) { if ( ! function_exists( 'is_admin' ) ) { function is_admin(): bool { return ! empty( $GLOBALS['_wp_is_admin'] ); } } if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ): bool { return false; } } if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } } +if ( ! function_exists( 'get_metadata' ) ) { + // Like get_metadata(): the first stored value of a key (single), read from the real meta table. + function get_metadata( string $type, $object_id, string $key = '', bool $single = false ) { + global $wpdb; + $table = $wpdb->{$type . 'meta'}; + $id = 'user' === $type ? 'umeta_id' : 'meta_id'; + $value = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$type}_id` = %d AND meta_key = %s ORDER BY `{$id}` LIMIT 1", $object_id, $key ) ); + if ( $single ) { + return null === $value ? '' : maybe_unserialize( $value ); + } + return null === $value ? array() : array( maybe_unserialize( $value ) ); + } +} if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } } if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } } if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { $GLOBALS['_wp_scheduled_single'][] = $hook; return true; } } -- 2.43.0 From 1835c8776ad49237ecd1c5fd30235486769ab1fc Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:34:19 +0800 Subject: [PATCH 17/20] docs(2meet-data-optimizer): add PR-111, PR-113 and PR-114 to the 1.3.0 release notes - deferred-upgrade hardening from the acceptance reviews, notice() audit trail, known cold-cache version window - PR: PR-101 (follow-up) --- CHANGELOG.md | 13 +++++++++++-- readme.txt | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50cf6fd..16a19f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,14 +25,21 @@ Versioning follows [Semantic Versioning](https://semver.org/). 精確,快取不再被打散。dev36 關鍵字 + 價格:首次 5.25 s、之後 3.2 s(原生 3.35 s)。(PR-091) - `POST /wpdo/v1/listings/{id}/view` 被限流擋下時,`wpdo_rl_stats` 改為 1/10 抽樣寫入 (每次 +10),匿名請求不再 1:1 放大成 option 寫入。(PR-094) -- `TMDO_Logger::info()` / `notice()` 只在 `WP_DEBUG_LOG` 時寫 error_log - (filter `tmdo_log_info`);`debug()`、`warning()`、`error()` 不變。(PR-095) +- `TMDO_Logger::info()` 只在 `WP_DEBUG_LOG` 時寫 error_log(filter `tmdo_log_info`); + `notice()` 一律寫入,模式切換(`bridge_mode_changed`)與快照刪除改用它,保留操作紀錄; + `debug()`、`warning()`、`error()` 不變。(PR-095、PR-113) - 公開 listings REST 一律不輸出名稱像 email、phone、address、IP 的欄位,即使宣告 `show_in_rest`。(PR-096) - **既有扁平表的 schema 升級只在 WP-CLI 或 WP-Cron 執行**:網頁請求遇到定義變更時記入 `tmdo_schema_deferred` 並排 `tmdo_ensure_group_indexes`;升級前該 group 的 key 走原生 (Hook Bus 不攔、router 不路由),升級後排 `tmdo_drift_resync` 補回期間跳過的扁平寫入。 新表照常立即建立;aeav_only 仍立即升級(扁平表是唯一副本);`doctor` 顯示 `[SCHEMA]`。(PR-107) + 驗收審查後補強:既有表的升級與索引共用同一把 `GET_LOCK`(匿名 `/wp-cron.php` 也會通過 + `ddl_allowed()`),取得鎖後重新比對 hash;延後的 group 要等 drift fix 完成才改回扁平表; + 寫入扁平表的 entity 新建 group 表時同樣先走原生,由 drift fix 從原生填滿(新宣告的 + HivePress 屬性不會把搜尋導到空表);定義還原時解除延後;cron 事件本身也會升級延後的表 + (`ALTERNATE_WP_CRON`);dbDelta 之後每個欄位都存在才記錄定義(否則 `schema.upgrade_failed` + 並重試);延後期間 `direct_read()` 回傳原生值。(PR-111、PR-114) - 讀寫分離(HyperDB、LudicrousDB)時,取索引鎖前先 `send_reads_to_masters()`, `GET_LOCK` 才會在 primary 上。(PR-108) - Cold 快取的「清除」改為遞增版本號(`wpdo_cold_cache_version`),不再呼叫 @@ -59,6 +66,8 @@ Versioning follows [Semantic Versioning](https://semver.org/). - 部署後第一個 WP-CLI 指令或 WP-Cron 會為 HivePress AddOn 0.4.0 宣告的索引執行 ALTER (90 萬列約 5 s);`DISABLE_WP_CRON` 且沒有系統 cron 的站,執行一次 `wp tmdo doctor` 即可。 - CLI 輸出不再夾帶 `[WPDO][INFO]`;要看請定義 `WP_DEBUG_LOG` 或加上 `tmdo_log_info` filter。 +- 已知限制:cold 快取版本遞增的瞬間,在遞增前就開始的長請求寫入後只會清掉舊版 key,新版 key + 最多保留舊值到 TTL(預設 1 小時);cold module 預設 idle。 --- diff --git a/readme.txt b/readme.txt index 1875c41..d847b02 100644 --- a/readme.txt +++ b/readme.txt @@ -117,6 +117,7 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency, * Existing flat tables are upgraded from WP-CLI or WP-Cron only; a group waiting for its upgrade uses native meta, then a drift fix rebuilds it * The index lock goes to the primary under read/write splitting; clearing the cold cache bumps a version instead of scanning the object cache group * Groups whose flat table was dropped no longer cause database errors on post status changes +* Schema upgrades share one MySQL lock with index DDL; new or upgraded groups stay native until a drift fix has filled them; mode changes and snapshot deletions are always logged = 1.2.0 = * `wp tmdo drift-check [--group] [--sample] [--fix]`: reports flat-vs-native drift per key (miss / diff / extra) and rebuilds drifted flat rows from native; native meta is never written, and aeav_only sites are refused -- 2.43.0 From 7851f150faea12989020e3fa6aaa16629fbb016b Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:39:36 +0800 Subject: [PATCH 18/20] perf(2meet-data-optimizer): key the routed count cache on the SQL without wpdb's placeholder escape - keyword searches carry wpdb's placeholder escape for "%" ({}), which is new in every request, so md5(count SQL) never matched across requests and every keyword search recounted (the 2.2 "keyword + filter routed slower than native" finding). The key now uses remove_placeholder_escape() - found in the 2.3 acceptance: dev36 keyword + price routed 6.2 s vs native 4.9 s; PR-091's in-process check reused one process, one escape - Verify: PostQueryRouterFoundRowsTest (two escapes, one COUNT), integration 588, unit 703, phpcs, stan; dev36 front-end - PR: PR-116 (2.3 acceptance) --- .../query/class-tmdo-post-query-router.php | 7 ++- .../PostQueryRouterFoundRowsTest.php | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index acdb148..b05e5cb 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -433,8 +433,11 @@ class TMDO_Post_Query_Router { global $wpdb; list( $count_sql, $excluded_sql ) = array_pad( (array) $count_sql, 2, '' ); - $key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' ); - $total = wp_cache_get( $key, self::COUNT_CACHE_GROUP ); + // 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' ); + $total = wp_cache_get( $key, self::COUNT_CACHE_GROUP ); if ( false === $total ) { $total = (int) $wpdb->get_var( $count_sql ); wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS ); diff --git a/tests/integration/PostQueryRouterFoundRowsTest.php b/tests/integration/PostQueryRouterFoundRowsTest.php index a374e91..c593b65 100644 --- a/tests/integration/PostQueryRouterFoundRowsTest.php +++ b/tests/integration/PostQueryRouterFoundRowsTest.php @@ -157,6 +157,49 @@ class PostQueryRouterFoundRowsTest extends TestCase { $this->assertSame( '0', $count( '2' ), 'recounted' ); } + /** + * Keyword searches carry wpdb's placeholder escape for "%", which changes + * with every request: the cache key uses the SQL without it, or no search + * with a keyword ever hits the cached count. + */ + public function test_the_cached_count_survives_a_new_placeholder_escape(): void { + global $wpdb; + $real = $wpdb; + $wpdb = new class( $real ) { + public int $counts = 0; + + public function __construct( private object $db ) {} + + public function remove_placeholder_escape( string $sql ): string { + return (string) preg_replace( '/\{[0-9a-f]{8}\}/', '%', $sql ); + } + + public function __call( string $name, array $args ) { + if ( 'get_var' === $name && str_contains( (string) $args[0], 'COUNT(*)' ) ) { + ++$this->counts; + } + return $this->db->$name( ...$args ); + } + + public function __get( string $name ) { + return $this->db->$name; + } + }; + try { + foreach ( array( '{1a2b3c4d}', '{9f8e7d6c}' ) as $escape ) { // Two requests. + list( $router, $query, $request ) = $this->routed(); + $request = str_replace( ' GROUP BY', " AND '{$escape}' = '{$escape}' GROUP BY", $request ); + $router->posts_request( $request, $query ); + $this->assertSame( '2', $real->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ) ); + } + $counts = $wpdb->counts; + } finally { + $wpdb = $real; + } + + $this->assertSame( 1, $counts, 'counted once' ); + } + 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' ); -- 2.43.0 From ca2cbdab659ac93603065b1b080fcc6bcee68f91 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:46:54 +0800 Subject: [PATCH 19/20] 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; } } -- 2.43.0 From 947e130386f30c38d9d0ea9b90e71e0ea379615f Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 17:58:07 +0800 Subject: [PATCH 20/20] docs(2meet-data-optimizer): add PR-116 and PR-117 to the 1.3.0 release notes - routed count cache: placeholder escape and count version group - PR: PR-101 (follow-up) --- CHANGELOG.md | 8 +++++++- readme.txt | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16a19f0..ad89ed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,13 @@ Versioning follows [Semantic Versioning](https://semver.org/). 索引。filter:`tmdo_schema_ddl_allowed`。dev36 90 萬列加兩個索引約 4.7 s。(PR-090a) - **路由搜尋的 found rows**:帶 `post__not_in` 的查詢(HivePress 每頁送一組隨機排除清單) 改為「不含排除條件的 COUNT(快取)」減去「排除清單中符合條件的篇數(主鍵查詢)」,結果 - 精確,快取不再被打散。dev36 關鍵字 + 價格:首次 5.25 s、之後 3.2 s(原生 3.35 s)。(PR-091) + 精確,快取不再被打散。(PR-091)快取 key 另外去掉 wpdb 替 `%` 產生的 placeholder escape + (每個請求不同,關鍵字搜尋原本永遠不命中,也就是 2.2 驗收「關鍵字 + 篩選 routed 較慢」的 + 原因)(PR-116);計數改跟隨自有的 `tmdo_posts` last_changed(文章儲存/刪除、經 Hook Bus + 的扁平寫入)與 terms 的 last_changed,不再跟隨 posts 的 last_changed——任何 post meta 寫入 + 都會推動它,dev36 的 Blocksy 佈景在爬蟲瀏覽每篇 listing 時都寫一筆 meta,快取幾秒就失效 + (PR-117)。dev36 關鍵字 + 價格 routed 9.8 s → 首次後約 5.5 s(原生 5.0–6.2 s,剩餘為 + WordPress 核心的 LIKE 全表掃描)。 - `POST /wpdo/v1/listings/{id}/view` 被限流擋下時,`wpdo_rl_stats` 改為 1/10 抽樣寫入 (每次 +10),匿名請求不再 1:1 放大成 option 寫入。(PR-094) - `TMDO_Logger::info()` 只在 `WP_DEBUG_LOG` 時寫 error_log(filter `tmdo_log_info`); diff --git a/readme.txt b/readme.txt index d847b02..5fd9835 100644 --- a/readme.txt +++ b/readme.txt @@ -109,7 +109,7 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency, = 1.3.0 = * Declared flat-table indexes are added only from WP-CLI or WP-Cron, one process at a time (MySQL GET_LOCK); web requests schedule `tmdo_ensure_group_indexes`, and doctor lists indexes not added yet -* Routed searches with post__not_in count from a cached base count minus the excluded posts (HivePress keyword + price search no longer slower than native) +* Routed searches with post__not_in count from a cached base count minus the excluded posts; the cache key ignores wpdb's per-request placeholder escape and follows post saves and managed-key writes only, not every post meta write (HivePress keyword + price search no longer slower than native) * Rate-limited view requests sample their statistics (1 in 10) instead of writing an option each time * Logger info / notice lines only with WP_DEBUG_LOG (filter `tmdo_log_info`) * The public listings REST output withholds email, phone, address and IP fields even when declared public -- 2.43.0