d36bb954d1
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
267 lines
8.5 KiB
PHP
267 lines
8.5 KiB
PHP
<?php
|
||
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for FSM state advisor
|
||
/**
|
||
* TMDO_FSM_Advisor — Recommends the next FSM state per module (v2.4.0 M12).
|
||
*
|
||
* Reads:
|
||
* - Current state via TMDO_Feature_Flags::get( $module )
|
||
* - Time-in-current-state via wp_options.wpdo_fsm_state_entered (set by
|
||
* TMDO_FSM_Guard::record_entry() since v2.2.0 M2)
|
||
* - shadow_diffs ratio per module from wp_wpdo_shadow_diffs (last 24h)
|
||
*
|
||
* Emits one of:
|
||
* - PROMOTE — current state has cooked long enough + low divergence; safe to advance
|
||
* - WAIT — needs more soak time, returns days remaining
|
||
* - REVIEW — divergence too high; manual investigation needed before promoting
|
||
* - ROLLBACK — error budget exceeded; suggest rewind to idle
|
||
* - HOLD — module already idle or complete; nothing to do
|
||
*
|
||
* Designed to be advice — never auto-acts. Surfaces in Migration tab as a
|
||
* panel beside each module row.
|
||
*
|
||
* @package WP_Data_Optimizer
|
||
*/
|
||
|
||
if ( ! defined( 'ABSPATH' ) ) {
|
||
exit;
|
||
}
|
||
|
||
/**
|
||
* FSM advisor — stateless static API with structured `advise()` return.
|
||
*/
|
||
class TMDO_FSM_Advisor {
|
||
|
||
/** Minimum soak days per state before advisor allows promotion. */
|
||
public const MIN_SOAK_DAYS = array(
|
||
'idle' => 0,
|
||
'dual_write' => 1,
|
||
'backfill' => 1,
|
||
'verify' => 7,
|
||
'cutover' => 1,
|
||
'cleanup' => 3,
|
||
'complete' => 0, // terminal — never promote.
|
||
);
|
||
|
||
/** Max divergence ratio in `verify` state before advisor refuses to promote. */
|
||
public const VERIFY_MAX_DIVERGENCE_RATIO = 0.001; // 0.1%
|
||
|
||
/**
|
||
* Build advice for a single module.
|
||
*
|
||
* @param string $module Module slug.
|
||
* @return array {action:string, next_state?:string, days_remaining?:int,
|
||
* reason:string, level:'info'|'warn'|'critical', metrics:array}
|
||
*/
|
||
public static function advise( string $module ): array {
|
||
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
||
return self::stub( 'unavailable', 'Feature_Flags 未載入' );
|
||
}
|
||
$state = TMDO_Feature_Flags::get( $module );
|
||
|
||
// Terminal cases.
|
||
if ( 'idle' === $state ) {
|
||
return array(
|
||
'action' => 'HOLD',
|
||
'level' => 'info',
|
||
'reason' => __( 'Module 處於 idle,無需建議。如要啟用請先讀 SOP。', '2meet-data-optimizer' ),
|
||
'metrics' => array( 'state' => $state ),
|
||
);
|
||
}
|
||
if ( 'complete' === $state ) {
|
||
return array(
|
||
'action' => 'HOLD',
|
||
'level' => 'info',
|
||
'reason' => __( 'Module 已 complete,反 EAV 完成。', '2meet-data-optimizer' ),
|
||
'metrics' => array( 'state' => $state ),
|
||
);
|
||
}
|
||
|
||
$days_in_state = self::days_in_state( $module );
|
||
$min_soak = self::MIN_SOAK_DAYS[ $state ] ?? 1;
|
||
|
||
$metrics = array(
|
||
'state' => $state,
|
||
'days_in_state' => $days_in_state,
|
||
'min_soak_days' => $min_soak,
|
||
);
|
||
|
||
// Verify state requires divergence ratio check.
|
||
if ( 'verify' === $state ) {
|
||
$div = self::shadow_diff_ratio( $module );
|
||
$metrics['shadow_diff_ratio_24h'] = $div['ratio'];
|
||
$metrics['shadow_diff_count_24h'] = $div['count'];
|
||
$metrics['shadow_diff_total_24h'] = $div['total'];
|
||
|
||
if ( $div['count'] > 0 && $div['ratio'] > self::VERIFY_MAX_DIVERGENCE_RATIO ) {
|
||
return array(
|
||
'action' => 'REVIEW',
|
||
'next_state' => null,
|
||
'level' => 'warn',
|
||
'reason' => sprintf(
|
||
/* translators: 1: ratio, 2: max allowed */
|
||
__( 'Verify 期間 shadow_diffs 比率 %1$.2f%% 超過上限 %2$.2f%%;建議手動 review wp_wpdo_shadow_diffs 確認分歧來源後再決定。', '2meet-data-optimizer' ),
|
||
$div['ratio'] * 100,
|
||
self::VERIFY_MAX_DIVERGENCE_RATIO * 100
|
||
),
|
||
'metrics' => $metrics,
|
||
);
|
||
}
|
||
}
|
||
|
||
// Soak time check.
|
||
if ( null !== $days_in_state && $days_in_state < $min_soak ) {
|
||
$days_remaining = $min_soak - $days_in_state;
|
||
return array(
|
||
'action' => 'WAIT',
|
||
'days_remaining' => $days_remaining,
|
||
'level' => 'info',
|
||
'reason' => sprintf(
|
||
/* translators: 1: days left, 2: state name, 3: min days */
|
||
__( '再等 %1$d 天即可推進。當前 %2$s 狀態需 ≥ %3$d 天 soak。', '2meet-data-optimizer' ),
|
||
$days_remaining,
|
||
$state,
|
||
$min_soak
|
||
),
|
||
'metrics' => $metrics,
|
||
);
|
||
}
|
||
|
||
// Promotion candidate.
|
||
$next = self::next_state( $state );
|
||
if ( null === $next ) {
|
||
return array(
|
||
'action' => 'HOLD',
|
||
'level' => 'info',
|
||
'reason' => __( '當前狀態為 terminal — 無下一步建議。', '2meet-data-optimizer' ),
|
||
'metrics' => $metrics,
|
||
);
|
||
}
|
||
return array(
|
||
'action' => 'PROMOTE',
|
||
'next_state' => $next,
|
||
'level' => 'info',
|
||
'reason' => sprintf(
|
||
/* translators: 1: from, 2: to, 3: days */
|
||
__( '可推進:%1$s → %2$s(已 soak %3$d 天)。', '2meet-data-optimizer' ),
|
||
$state,
|
||
$next,
|
||
$days_in_state
|
||
),
|
||
'metrics' => $metrics,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Build advice for every known module. Returns map module → advice.
|
||
*
|
||
* @return array<string,array>
|
||
*/
|
||
public static function advise_all(): array {
|
||
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
||
return array();
|
||
}
|
||
$out = array();
|
||
foreach ( TMDO_Feature_Flags::all() as $module => $_state ) {
|
||
$out[ $module ] = self::advise( $module );
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
// ─── private ──────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Return days since the module entered its current state.
|
||
*
|
||
* @param string $module Module slug.
|
||
* @return int|null Days since entering current state, or null if unknown.
|
||
*/
|
||
private static function days_in_state( string $module ): ?int {
|
||
$entered = (array) get_option( 'wpdo_fsm_state_entered', array() );
|
||
if ( ! isset( $entered[ $module ]['entered_at'] ) ) {
|
||
return null;
|
||
}
|
||
$ts = strtotime( (string) $entered[ $module ]['entered_at'] . ' UTC' );
|
||
if ( false === $ts || $ts <= 0 ) {
|
||
return null;
|
||
}
|
||
return (int) floor( ( time() - $ts ) / DAY_IN_SECONDS );
|
||
}
|
||
|
||
/**
|
||
* Return the shadow-diff divergence ratio for a module over the last 24 hours.
|
||
*
|
||
* @param string $module Module slug.
|
||
* @return array {ratio:float, count:int, total:int}
|
||
*/
|
||
private static function shadow_diff_ratio( string $module ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- $module reserved for per-module entity_type routing (future)
|
||
global $wpdb;
|
||
$out = array(
|
||
'ratio' => 0.0,
|
||
'count' => 0,
|
||
'total' => 0,
|
||
);
|
||
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
|
||
$exists = (int) $wpdb->get_var(
|
||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
||
$table
|
||
)
|
||
);
|
||
if ( 0 === $exists ) {
|
||
return $out;
|
||
}
|
||
// Module → entity_type mapping is loose — most HPCT modules map to 'post'.
|
||
// Use entity_type='post' as default proxy; downstream `level=warn` is
|
||
// honest about uncertainty.
|
||
$count = (int) $wpdb->get_var(
|
||
$wpdb->prepare(
|
||
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = %s AND ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is $wpdb->prefix . 'wpdo_shadow_diffs' (no user input)
|
||
'post'
|
||
)
|
||
);
|
||
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no variables; table name is a WP core property
|
||
"SELECT COUNT(*) FROM `{$wpdb->postmeta}` WHERE meta_id > 0"
|
||
);
|
||
$out['count'] = $count;
|
||
$out['total'] = max( 1, $total );
|
||
$out['ratio'] = $count / $out['total'];
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* Forward graph (mirrors TMDO_FSM_Guard::FORWARD_GRAPH but without the
|
||
* cycle check — advisor only suggests the canonical next step).
|
||
*
|
||
* @param string $state Current state.
|
||
* @return string|null Next state, or null when terminal.
|
||
*/
|
||
private static function next_state( string $state ): ?string {
|
||
$map = array(
|
||
'idle' => 'dual_write',
|
||
'dual_write' => 'backfill',
|
||
'backfill' => 'verify',
|
||
'verify' => 'cutover',
|
||
'cutover' => 'cleanup',
|
||
'cleanup' => 'complete',
|
||
'complete' => null,
|
||
);
|
||
return $map[ $state ] ?? null;
|
||
}
|
||
|
||
/**
|
||
* Stub advice when prerequisites are missing.
|
||
*
|
||
* @param string $action Reason code.
|
||
* @param string $reason Human reason.
|
||
* @return array
|
||
*/
|
||
private static function stub( string $action, string $reason ): array {
|
||
return array(
|
||
'action' => strtoupper( $action ),
|
||
'level' => 'info',
|
||
'reason' => $reason,
|
||
'metrics' => array(),
|
||
);
|
||
}
|
||
}
|