Files
2meet-data-optimizer/includes/advisor/class-tmdo-fsm-advisor.php
T
wpdev d36bb954d1 chore: initial snapshot of 2meet-data-optimizer v0.1.0
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
2026-07-31 05:06:36 +08:00

267 lines
8.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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(),
);
}
}