76c01e44df
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入 並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
383 lines
12 KiB
PHP
383 lines
12 KiB
PHP
<?php
|
||
/**
|
||
* TMDO_Mode_Manager - Per-entity-type bridge 模式管理
|
||
*
|
||
* 4 種運作模式(每個 entity type 可獨立設定):
|
||
*
|
||
* disabled — 完全停用 UAE hook,回到原生 wp_*meta EAV。
|
||
* Kill-switch,緊急關閉用。
|
||
*
|
||
* dual_write — 寫入:同時寫 UAE flat table + wp_*meta
|
||
* 讀取:走 wp_*meta(原生)
|
||
* 用途:遷移前期安全模式,UAE 開始累積資料但不影響讀取
|
||
*
|
||
* shadow_read — 寫入:同時寫 UAE flat table + wp_*meta
|
||
* 讀取:走 UAE flat table,同時比對 wp_*meta 記錄差異
|
||
* 用途:驗證期,確認 UAE 資料正確後才進入 aeav_only
|
||
*
|
||
* aeav_only — 寫入:只寫 UAE flat table
|
||
* 讀取:只讀 UAE flat table
|
||
* 用途:完成遷移後的最終模式,效能最佳
|
||
*
|
||
* 合法轉換路徑(安全性):
|
||
*
|
||
* disabled ←→ dual_write ←→ shadow_read ←→ aeav_only
|
||
* ↑ ↑
|
||
* └──── aeav_only 可以直接降級到任一前向狀態
|
||
*
|
||
* 禁止:disabled → shadow_read 或 disabled → aeav_only
|
||
* (會讀不到資料,因為 UAE 表還沒有任何寫入)
|
||
*
|
||
* @package WP_Data_Optimizer
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||
|
||
|
||
defined( 'ABSPATH' ) || exit;
|
||
|
||
final class TMDO_Mode_Manager {
|
||
|
||
public const MODE_DISABLED = 'disabled';
|
||
public const MODE_DUAL_WRITE = 'dual_write';
|
||
public const MODE_SHADOW_READ = 'shadow_read';
|
||
public const MODE_AEAV_ONLY = 'aeav_only';
|
||
|
||
public const ALL_MODES = array(
|
||
self::MODE_DISABLED,
|
||
self::MODE_DUAL_WRITE,
|
||
self::MODE_SHADOW_READ,
|
||
self::MODE_AEAV_ONLY,
|
||
);
|
||
|
||
/** wp_options key holding array<entity_type, mode> */
|
||
private const OPT_KEY = 'wpdo_bridge_modes';
|
||
|
||
/**
|
||
* Per-entity 進入當前 mode 的 unix timestamp(v1.5.0+ 供 auto-promoter 使用)。
|
||
* Shape: array<string entity_type, int timestamp>
|
||
*/
|
||
public const OPT_ENTERED_AT = 'wpdo_bridge_mode_entered_at';
|
||
|
||
/** Per-request memoization(避免每次 filter 都查 DB option) */
|
||
private static ?array $cache = null;
|
||
|
||
/**
|
||
* 預設模式(v2.5.4 起):
|
||
* post → disabled(由 Legacy Feature_Flags FSM 管理,不由 Mode_Manager 控制)
|
||
* user / term / comment → dual_write(安全雙寫:寫入 flat table + 原生 EAV,讀取仍走 EAV)
|
||
*/
|
||
private static function defaults(): array {
|
||
return array(
|
||
'post' => self::MODE_DISABLED,
|
||
'user' => self::MODE_DUAL_WRITE,
|
||
'term' => self::MODE_DUAL_WRITE,
|
||
'comment' => self::MODE_DUAL_WRITE,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 取得所有 entity 的 mode
|
||
*
|
||
* @return array<string, string>
|
||
*/
|
||
public static function all(): array {
|
||
if ( self::$cache !== null ) {
|
||
return self::$cache;
|
||
}
|
||
|
||
$stored = get_option( self::OPT_KEY, array() );
|
||
$modes = self::defaults();
|
||
|
||
if ( is_array( $stored ) ) {
|
||
foreach ( $stored as $type => $mode ) {
|
||
if ( is_string( $type ) && self::is_valid_mode( $mode ) && in_array( $type, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
|
||
$modes[ $type ] = $mode;
|
||
}
|
||
}
|
||
}
|
||
|
||
self::$cache = $modes;
|
||
return $modes;
|
||
}
|
||
|
||
/**
|
||
* 取得單一 entity type 的 mode
|
||
*/
|
||
public static function get( string $entity_type ): string {
|
||
$all = self::all();
|
||
return $all[ $entity_type ] ?? self::MODE_DISABLED;
|
||
}
|
||
|
||
/**
|
||
* 設定單一 entity type 的 mode(包含轉換安全檢查)
|
||
*
|
||
* @return true|\WP_Error
|
||
*/
|
||
public static function set( string $entity_type, string $new_mode ) {
|
||
if ( ! in_array( $entity_type, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
|
||
return new \WP_Error( 'invalid_entity', "Invalid entity type: {$entity_type}" );
|
||
}
|
||
if ( ! self::is_valid_mode( $new_mode ) ) {
|
||
return new \WP_Error( 'invalid_mode', "Invalid mode: {$new_mode}" );
|
||
}
|
||
|
||
$current = self::get( $entity_type );
|
||
if ( $current === $new_mode ) {
|
||
return true; // no-op
|
||
}
|
||
|
||
// 驗證轉換安全性
|
||
if ( ! self::is_safe_transition( $current, $new_mode ) ) {
|
||
return new \WP_Error(
|
||
'unsafe_transition',
|
||
sprintf(
|
||
__( '不安全的模式轉換:%1$s → %2$s。建議路徑:disabled → dual_write → shadow_read → aeav_only', '2meet-data-optimizer' ),
|
||
$current,
|
||
$new_mode
|
||
)
|
||
);
|
||
}
|
||
|
||
$all = self::all();
|
||
$all[ $entity_type ] = $new_mode;
|
||
|
||
update_option( self::OPT_KEY, $all, false );
|
||
self::$cache = $all;
|
||
|
||
// 記錄進入此 mode 的 timestamp — auto-promoter 用來判斷停留天數。
|
||
$entered = get_option( self::OPT_ENTERED_AT, array() );
|
||
if ( ! is_array( $entered ) ) {
|
||
$entered = array();
|
||
}
|
||
$entered[ $entity_type ] = time();
|
||
update_option( self::OPT_ENTERED_AT, $entered, false );
|
||
|
||
TMDO_Logger::info(
|
||
'bridge_mode_changed',
|
||
array(
|
||
'entity_type' => $entity_type,
|
||
'from' => $current,
|
||
'to' => $new_mode,
|
||
'user_id' => get_current_user_id(),
|
||
)
|
||
);
|
||
|
||
// 模式變更時清除所有相關快取
|
||
TMDO_Cache_Orchestrator::flush_entity( $entity_type );
|
||
|
||
do_action( 'wpdo_bridge_mode_changed', $entity_type, $new_mode, $current );
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 緊急 kill-switch:全部 entity 瞬間切到 disabled
|
||
*
|
||
* 這是**安全轉換規則的例外** — 任何狀態都可以瞬間降到 disabled,
|
||
* 因為 EAV 資料一直存在(dual_write / shadow_read 都還寫 EAV),
|
||
* 只有 aeav_only 切到 disabled 才有資料遺失風險。
|
||
*
|
||
* 呼叫這個方法表示「出事了,先退回安全狀態」,EAV 可能不是最新的,
|
||
* 但至少系統不會壞。
|
||
*
|
||
* @return int 改變狀態的 entity 數量
|
||
*/
|
||
public static function emergency_disable_all(): int {
|
||
$all = self::all();
|
||
$changed = 0;
|
||
|
||
foreach ( $all as $type => $mode ) {
|
||
if ( $mode !== self::MODE_DISABLED ) {
|
||
$all[ $type ] = self::MODE_DISABLED;
|
||
++$changed;
|
||
|
||
TMDO_Logger::warning(
|
||
'bridge_emergency_disable',
|
||
array(
|
||
'entity_type' => $type,
|
||
'previous_mode' => $mode,
|
||
'user_id' => get_current_user_id(),
|
||
'is_aeav_only' => $mode === self::MODE_AEAV_ONLY,
|
||
'warning' => $mode === self::MODE_AEAV_ONLY
|
||
? 'CRITICAL: switching from aeav_only to disabled — EAV may be stale'
|
||
: 'Normal emergency fallback',
|
||
)
|
||
);
|
||
}
|
||
}
|
||
|
||
if ( $changed > 0 ) {
|
||
update_option( self::OPT_KEY, $all, false );
|
||
self::$cache = $all;
|
||
TMDO_Cache_Orchestrator::flush_all();
|
||
do_action( 'wpdo_bridge_emergency_disabled', $changed );
|
||
}
|
||
|
||
return $changed;
|
||
}
|
||
|
||
/**
|
||
* 設定全部 entity 到同一個 mode(批次操作,會做轉換檢查)
|
||
*
|
||
* @return array<string, true|\WP_Error> 逐 entity 的結果
|
||
*/
|
||
public static function set_all( string $mode ): array {
|
||
$results = array();
|
||
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $type ) {
|
||
$results[ $type ] = self::set( $type, $mode );
|
||
}
|
||
return $results;
|
||
}
|
||
|
||
/**
|
||
* Helper: 目前 mode 是否會寫入 flat table?
|
||
*/
|
||
public static function writes_to_flat( string $entity_type ): bool {
|
||
return in_array(
|
||
self::get( $entity_type ),
|
||
array(
|
||
self::MODE_DUAL_WRITE,
|
||
self::MODE_SHADOW_READ,
|
||
self::MODE_AEAV_ONLY,
|
||
),
|
||
true
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Helper: 目前 mode 是否會讓 WP 原生 EAV 也被寫入?
|
||
*/
|
||
public static function writes_to_eav( string $entity_type ): bool {
|
||
return in_array(
|
||
self::get( $entity_type ),
|
||
array(
|
||
self::MODE_DISABLED,
|
||
self::MODE_DUAL_WRITE,
|
||
self::MODE_SHADOW_READ,
|
||
),
|
||
true
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Helper: 目前 mode 是否從 flat table 讀取?
|
||
*/
|
||
public static function reads_from_flat( string $entity_type ): bool {
|
||
return in_array(
|
||
self::get( $entity_type ),
|
||
array(
|
||
self::MODE_SHADOW_READ,
|
||
self::MODE_AEAV_ONLY,
|
||
),
|
||
true
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Helper: 是否要做 shadow 比對?
|
||
*/
|
||
public static function does_shadow_compare( string $entity_type ): bool {
|
||
return self::get( $entity_type ) === self::MODE_SHADOW_READ;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 驗證
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
public static function is_valid_mode( $mode ): bool {
|
||
return is_string( $mode ) && in_array( $mode, self::ALL_MODES, true );
|
||
}
|
||
|
||
/**
|
||
* 轉換是否安全?
|
||
*
|
||
* 規則:
|
||
* - 相鄰的階梯移動:允許(正向或反向)
|
||
* - 跨階梯降級:允許(後面的 fallback,EAV 還在)
|
||
* - 跨階梯升級:禁止(下游資料可能還沒跟上)
|
||
*
|
||
* 階梯順序(index 越大 = 越「靠 UAE」):
|
||
* 0: disabled
|
||
* 1: dual_write
|
||
* 2: shadow_read
|
||
* 3: aeav_only
|
||
*
|
||
* 降級(index 變小)永遠安全(EAV 都還在,除了 aeav_only→disabled 一跳,
|
||
* 但那是刻意的 emergency 用法 — 見 emergency_disable_all)。
|
||
*
|
||
* 升級只允許 +1(disabled→dual_write、dual_write→shadow_read、shadow_read→aeav_only)。
|
||
*/
|
||
public static function is_safe_transition( string $from, string $to ): bool {
|
||
$order = array(
|
||
self::MODE_DISABLED => 0,
|
||
self::MODE_DUAL_WRITE => 1,
|
||
self::MODE_SHADOW_READ => 2,
|
||
self::MODE_AEAV_ONLY => 3,
|
||
);
|
||
|
||
if ( ! isset( $order[ $from ], $order[ $to ] ) ) {
|
||
return false;
|
||
}
|
||
|
||
$diff = $order[ $to ] - $order[ $from ];
|
||
|
||
// 升級:只允許 +1
|
||
if ( $diff > 0 ) {
|
||
return $diff === 1;
|
||
}
|
||
|
||
// 降級:一律允許(這是 fallback,資料都還在)
|
||
return true;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 內部
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 測試用:清除 memoization
|
||
*/
|
||
public static function reset_cache(): void {
|
||
self::$cache = null;
|
||
}
|
||
|
||
/**
|
||
* Human-readable label
|
||
*/
|
||
public static function label( string $mode ): string {
|
||
switch ( $mode ) {
|
||
case self::MODE_DISABLED:
|
||
return __( '停用(原生 EAV)', '2meet-data-optimizer' );
|
||
case self::MODE_DUAL_WRITE:
|
||
return __( '雙寫', '2meet-data-optimizer' );
|
||
case self::MODE_SHADOW_READ:
|
||
return __( '影子讀取(驗證中)', '2meet-data-optimizer' );
|
||
case self::MODE_AEAV_ONLY:
|
||
return __( '僅 UAE(生產模式)', '2meet-data-optimizer' );
|
||
default:
|
||
return $mode;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Mode 的說明文字(給 admin UI 用)
|
||
*/
|
||
public static function description( string $mode ): string {
|
||
switch ( $mode ) {
|
||
case self::MODE_DISABLED:
|
||
return __( '完全停用 UAE 攔截,使用 WordPress 原生 wp_*meta 表。安全 fallback。', '2meet-data-optimizer' );
|
||
case self::MODE_DUAL_WRITE:
|
||
return __( '寫入 UAE + 原生 meta 表,讀取仍走原生。遷移前期的安全起點。', '2meet-data-optimizer' );
|
||
case self::MODE_SHADOW_READ:
|
||
return __( '寫入雙寫,讀取走 UAE 並比對原生 meta。驗證資料一致性的階段。', '2meet-data-optimizer' );
|
||
case self::MODE_AEAV_ONLY:
|
||
return __( '僅讀寫 UAE,不再寫入原生 meta 表。最終生產模式,效能最佳。', '2meet-data-optimizer' );
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
}
|