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
This commit is contained in:
2026-09-27 00:48:18 +08:00
parent a26500206a
commit 9d1a7c5591
5 changed files with 227 additions and 9 deletions
@@ -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
+11 -8
View File
@@ -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';
}
@@ -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
*