From 9d1a7c5591f9a08dd7591536e9cbce5ec5a2452a Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 00:48:18 +0800 Subject: [PATCH] feat(2meet-data-optimizer): throttle shadow_read diffs per key and promote on clean drift checks - The shadow diff logger records at most 20 rows per entity type + key per hour across all objects, on top of the existing per-object 5-minute window: a key stale on every object (WooCommerce total_sales, written by direct SQL) no longer logs a row per product read. - The auto-promoter's third condition is now TMDO_Drift_Check::clean_since(): every backfillable group of the entity checked within 2 days (sampled or full) with no drift; no record, no promotion. The shadow diff count no longer gates it (the filter's 4th argument is passed as 0). The Promotion Guard still refuses HivePress / WooCommerce sites. - Reason classification was dropped (decision 2026-09-27): it needs a schema change and drift-check already reports miss / diff / extra per key. - Verify: ShadowDiffThrottleTest, AutoPromoterDriftTest (RED before); dev36 simulated user shadow_read: clean_since yes, promotion still refused by the guard, mode unchanged. - PR: PR-075 --- .../diagnostic/class-tmdo-drift-check.php | 25 ++++++ includes/engine/class-tmdo-auto-promoter.php | 19 ++-- .../engine/class-tmdo-shadow-diff-logger.php | 25 +++++- tests/integration/ShadowDiffThrottleTest.php | 89 +++++++++++++++++++ tests/unit/AutoPromoterDriftTest.php | 78 ++++++++++++++++ 5 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 tests/integration/ShadowDiffThrottleTest.php create mode 100644 tests/unit/AutoPromoterDriftTest.php diff --git a/includes/diagnostic/class-tmdo-drift-check.php b/includes/diagnostic/class-tmdo-drift-check.php index 7c21206..0c06045 100644 --- a/includes/diagnostic/class-tmdo-drift-check.php +++ b/includes/diagnostic/class-tmdo-drift-check.php @@ -114,6 +114,31 @@ final class TMDO_Drift_Check { return $report; } + /** + * Every backfillable group of the entity was checked within $max_age + * seconds (sampled or full) and none drifted. No record counts as unclean. + * + * @param string $entity_type Entity type. + * @param int $max_age Seconds. + */ + public static function clean_since( string $entity_type, int $max_age ): bool { + $status = get_option( self::OPTION, array() ); + $status = is_array( $status ) ? ( $status[ $entity_type ] ?? array() ) : array(); + $groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type ); + if ( empty( $groups ) ) { + return false; + } + foreach ( $groups as $group ) { + $last = $status[ $group ] ?? null; + if ( ! is_array( $last ) + || (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0 + || (int) strtotime( ( $last['checked_at'] ?? '' ) . ' UTC' ) < time() - $max_age ) { + return false; + } + } + return true; + } + /** * Daily check (wpdo_daily_health_check): sample every entity type written * to both native and flat (dual_write, shadow_read). In aeav_only flat is diff --git a/includes/engine/class-tmdo-auto-promoter.php b/includes/engine/class-tmdo-auto-promoter.php index 5f3983a..d7ffced 100644 --- a/includes/engine/class-tmdo-auto-promoter.php +++ b/includes/engine/class-tmdo-auto-promoter.php @@ -6,7 +6,8 @@ * 判斷條件(皆滿足才觸發): * 1. 當前 mode = shadow_read * 2. 進入 shadow_read 距今 ≥ `min_days` - * 3. 進入後 `shadow_diffs` 表中該 entity 的 diff 筆數 = 0 + * 3. 該 entity 每個群組最近一次 drift-check(2 天內,抽樣或全量)皆無漂移 + * (1.2.0 起取代 shadow_diffs 筆數;無紀錄不升級) * * 這符合「穩定期沒問題就進 production」的安全遷移原則。 * 若 diff 出現,規則永不觸發;人工介入即可。 @@ -35,6 +36,9 @@ final class TMDO_Auto_Promoter { public const CRON_HOOK = 'wpdo_auto_promote_check'; + /** A drift check older than this (seconds) does not count for promotion. */ + public const DRIFT_MAX_AGE = 2 * DAY_IN_SECONDS; + public static function init(): void { add_action( self::CRON_HOOK, array( self::class, 'check_all' ) ); add_action( 'init', array( self::class, 'maybe_schedule' ), 30 ); @@ -82,7 +86,7 @@ final class TMDO_Auto_Promoter { /** * 評估單一 entity 是否可升級。 * - * @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'has_diff' | 'vetoed' + * @return string 'promoted' | 'not_shadow_read' | 'not_ready' | 'drift_not_clean' | 'vetoed' | 'error' */ public static function check_entity( string $type, int $min_days, array $entered_all ): string { $current = TMDO_Mode_Manager::get( $type ); @@ -95,14 +99,13 @@ final class TMDO_Auto_Promoter { return 'not_ready'; } - $diff_counts = TMDO_Shadow_Diff_Logger::count_by_entity(); - $count = (int) ( $diff_counts[ $type ] ?? 0 ); - if ( $count > 0 ) { - return 'has_diff'; + if ( ! TMDO_Drift_Check::clean_since( $type, self::DRIFT_MAX_AGE ) ) { + return 'drift_not_clean'; } - // 讓外部 filter 最後一次 veto(例如:還在外部驗證期間) - $should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, $count ); + // 讓外部 filter 最後一次 veto(例如:還在外部驗證期間)。第 4 參數原為 + // shadow diff 筆數;1.2.0 起改看 drift,固定傳 0。 + $should = apply_filters( 'wpdo_auto_promote_should_run', true, $type, $entered_at, 0 ); if ( ! $should ) { return 'vetoed'; } diff --git a/includes/engine/class-tmdo-shadow-diff-logger.php b/includes/engine/class-tmdo-shadow-diff-logger.php index 766b8fa..0644b86 100644 --- a/includes/engine/class-tmdo-shadow-diff-logger.php +++ b/includes/engine/class-tmdo-shadow-diff-logger.php @@ -31,6 +31,14 @@ final class TMDO_Shadow_Diff_Logger { /** Rate limit 視窗(秒)— 同 entity+key 在此視窗內重複 diff 不重記 */ public const RATELIMIT_WINDOW = 300; + /** + * Rows per entity type + key per KEY_WINDOW, across all objects: a key + * stale on every object (WooCommerce total_sales, written by direct SQL) + * must not log a row for each object read. + */ + public const KEY_LIMIT = 20; + public const KEY_WINDOW = 3600; + public static function table_name(): string { global $wpdb; return $wpdb->prefix . 'wpdo_shadow_diffs'; @@ -69,7 +77,7 @@ final class TMDO_Shadow_Diff_Logger { } // 不一致 → 檢查 rate limit - if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) ) { + if ( self::is_rate_limited( $entity_type, $entity_id, $meta_key ) || self::key_over_limit( $entity_type, $meta_key ) ) { return; } @@ -183,6 +191,21 @@ final class TMDO_Shadow_Diff_Logger { return false; } + /** + * Count this diff against the key's KEY_LIMIT; true once the limit is + * reached within the current KEY_WINDOW (the window starts at its first row). + */ + private static function key_over_limit( string $entity_type, string $meta_key ): bool { + $key = 'wpdo_sd_kl_' . md5( "{$entity_type}:{$meta_key}" ); + $state = get_transient( $key ); + $state = is_array( $state ) ? $state : array( 0, time() ); + if ( $state[0] >= self::KEY_LIMIT ) { + return true; + } + set_transient( $key, array( $state[0] + 1, $state[1] ), max( 1, $state[1] + self::KEY_WINDOW - time() ) ); + return false; + } + /** * 寫入 diff row * diff --git a/tests/integration/ShadowDiffThrottleTest.php b/tests/integration/ShadowDiffThrottleTest.php new file mode 100644 index 0000000..85259be --- /dev/null +++ b/tests/integration/ShadowDiffThrottleTest.php @@ -0,0 +1,89 @@ +query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" ); + $wpdb->query( 'CREATE TABLE `' . self::DIFFS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, ts DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', entity_type VARCHAR(20) NOT NULL DEFAULT '', entity_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) NOT NULL DEFAULT '', postmeta_value LONGTEXT, zone_value LONGTEXT, diff_hash VARCHAR(40) NOT NULL DEFAULT '', PRIMARY KEY (id) ) ENGINE=InnoDB" ); + $wpdb->query( 'CREATE TABLE `' . self::ERRORS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, module VARCHAR(100) NOT NULL DEFAULT '', zone VARCHAR(20) NOT NULL DEFAULT '', hook VARCHAR(255) NOT NULL DEFAULT '', message TEXT NOT NULL, context LONGTEXT, created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id) ) ENGINE=InnoDB" ); + for ( $id = 1; $id <= 30; $id++ ) { + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => $id, 'meta_key' => 'total_sales', 'meta_value' => '5' ) ); + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => $id, 'meta_key' => 'other_key', 'meta_value' => '5' ) ); + } + + $GLOBALS['_wp_transients'] = array(); + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); + } + + protected function tearDown(): void { + self::drop(); + WPDO_Entity_Registry::init(); + } + + private static function drop(): void { + global $wpdb; + foreach ( array( $wpdb->usermeta, self::DIFFS, self::ERRORS ) as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + } + + private function logged( string $key ): int { + global $wpdb; + return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM `' . self::DIFFS . '` WHERE meta_key = %s', $key ) ); + } + + private function stale_read( int $user_id, string $key ): void { + TMDO_Shadow_Diff_Logger::compare_and_log( 'user', $user_id, $key, 4, array( 'type' => 'integer' ) ); + } + + public function test_a_key_stale_on_every_object_logs_at_most_the_key_limit(): void { + for ( $id = 1; $id <= 30; $id++ ) { + $this->stale_read( $id, 'total_sales' ); + } + + $this->assertSame( TMDO_Shadow_Diff_Logger::KEY_LIMIT, $this->logged( 'total_sales' ) ); + } + + public function test_the_limit_is_per_key(): void { + for ( $id = 1; $id <= 30; $id++ ) { + $this->stale_read( $id, 'total_sales' ); + } + $this->stale_read( 1, 'other_key' ); + + $this->assertSame( 1, $this->logged( 'other_key' ) ); + } + + public function test_the_same_object_and_key_still_log_once_per_window(): void { + $this->stale_read( 1, 'total_sales' ); + $this->stale_read( 1, 'total_sales' ); + + $this->assertSame( 1, $this->logged( 'total_sales' ) ); + } +} diff --git a/tests/unit/AutoPromoterDriftTest.php b/tests/unit/AutoPromoterDriftTest.php new file mode 100644 index 0000000..aa7e42e --- /dev/null +++ b/tests/unit/AutoPromoterDriftTest.php @@ -0,0 +1,78 @@ + array( 'user' => 'shadow_read' ) ); + $GLOBALS['_wp_filter_callbacks'] = array(); + $GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true ); + TMDO_Mode_Manager::reset_cache(); + TMDO_Entity_Registry::init(); + TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() ); + TMDO_Entity_Registry::register_group( 'user', 'ap_one', array( array( 'key' => 'ap_a', 'type' => 'text' ) ) ); + TMDO_Entity_Registry::register_group( 'user', 'ap_two', array( array( 'key' => 'ap_b', 'type' => 'text' ) ) ); + } + + protected function tearDown(): void { + TMDO_Entity_Registry::init(); + TMDO_Mode_Manager::reset_cache(); + } + + private function drift( string $group, int $diff, int $age = 0 ): void { + $GLOBALS['_wp_options'][ TMDO_Drift_Check::OPTION ]['user'][ $group ] = array( + 'miss' => 0, + 'diff' => $diff, + 'extra' => 0, + 'sample' => TMDO_Drift_Check::CRON_SAMPLE, + 'checked_at' => gmdate( 'Y-m-d H:i:s', time() - $age ), + ); + } + + private function check(): string { + return TMDO_Auto_Promoter::check_entity( 'user', 7, array( 'user' => time() - self::TEN_DAYS_AGO ) ); + } + + public function test_promotes_when_every_group_checked_clean_recently(): void { + $this->drift( 'ap_one', 0 ); + $this->drift( 'ap_two', 0 ); + + $this->assertSame( 'promoted', $this->check() ); + $this->assertSame( 'aeav_only', TMDO_Mode_Manager::get( 'user' ) ); + } + + public function test_drift_in_one_group_blocks_promotion(): void { + $this->drift( 'ap_one', 0 ); + $this->drift( 'ap_two', 1 ); + + $this->assertSame( 'drift_not_clean', $this->check() ); + $this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( 'user' ) ); + } + + public function test_a_group_never_checked_blocks_promotion(): void { + $this->drift( 'ap_one', 0 ); + + $this->assertSame( 'drift_not_clean', $this->check() ); + } + + public function test_a_check_older_than_two_days_blocks_promotion(): void { + $this->drift( 'ap_one', 0 ); + $this->drift( 'ap_two', 0, 3 * DAY_IN_SECONDS ); + + $this->assertSame( 'drift_not_clean', $this->check() ); + } +}