Files
2meet-data-optimizer/includes/safety/class-tmdo-fsm-guard.php
T
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 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
2026-07-31 06:13:33 +08:00

218 lines
6.7 KiB
PHP

<?php
/**
* TMDO_FSM_Guard — Safety rails for the 7-state FSM (v2.2.0 M2).
*
* Hooks `TMDO_Feature_Flags::set()` to:
* 1. Validate that the requested transition is in the allowed graph
* (idle → dual_write → backfill → verify → cutover → cleanup → complete,
* any state → idle for emergency rewind).
* 2. Take an automatic snapshot before destructive transitions
* (cutover → cleanup, cleanup → complete, any active state → idle).
* 3. Allow override via filter `wpdo/fsm_guard/bypass` (CLI `--force`).
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Stateless guard: check + snapshot, return WP_Error on block.
*/
class TMDO_FSM_Guard {
/**
* Allowed forward transitions. Maps from-state → list of valid to-states.
* Backward to `idle` is allowed from anywhere (emergency rewind).
*/
public const FORWARD_GRAPH = array(
'idle' => array( 'dual_write' ),
'dual_write' => array( 'backfill', 'verify' ),
'backfill' => array( 'verify' ),
'verify' => array( 'cutover' ),
'cutover' => array( 'cleanup' ),
'cleanup' => array( 'complete' ),
'complete' => array(), // terminal — only rewind to idle.
);
/**
* Transitions that destroy data on completion (write-only-custom or purge).
* These trigger an automatic pre-transition snapshot.
*/
public const DESTRUCTIVE_TRANSITIONS = array(
'cutover' => array( 'cleanup' ), // cleanup purges wp_*meta.
'cleanup' => array( 'complete' ), // complete = no fallback to wp_*meta.
);
/**
* "Active" states (writes hit custom tables). Reverting these to idle
* potentially loses data and warrants a snapshot.
*/
public const ACTIVE_STATES = array(
'dual_write',
'backfill',
'verify',
'cutover',
'cleanup',
'complete',
);
/**
* Check whether a transition is allowed. Returns true on allow,
* WP_Error on block.
*
* @param string $module Module name (must be a known module).
* @param string $from Current state.
* @param string $to Target state.
* @return true|WP_Error
*/
public static function can_transition( string $module, string $from, string $to ) {
// Allow filter-based bypass (CLI --force, integration tests, manual override).
$bypass = (bool) apply_filters( 'wpdo/fsm_guard/bypass', false, $module, $from, $to );
if ( $bypass ) {
return true;
}
// No-op transition.
if ( $from === $to ) {
return true;
}
// Backward to idle is always allowed (emergency rewind).
if ( 'idle' === $to ) {
return true;
}
// Validate against forward graph.
$allowed = self::FORWARD_GRAPH[ $from ] ?? array();
if ( ! in_array( $to, $allowed, true ) ) {
return new WP_Error(
'wpdo_fsm_invalid_transition',
sprintf(
/* translators: 1: module, 2: from-state, 3: to-state, 4: list of allowed states */
__( 'Cannot transition module %1$s from %2$s to %3$s. Allowed forward states from %2$s: %4$s. Pass --force or set the wpdo/fsm_guard/bypass filter to override.', '2meet-data-optimizer' ),
$module,
$from,
$to,
empty( $allowed ) ? __( '(none — terminal state)', '2meet-data-optimizer' ) : implode( ', ', $allowed )
),
array(
'module' => $module,
'from' => $from,
'to' => $to,
'allowed' => $allowed,
)
);
}
return true;
}
/**
* Decide whether a transition is destructive (warrants auto-snapshot).
*
* @param string $from Current state.
* @param string $to Target state.
* @return bool
*/
public static function is_destructive( string $from, string $to ): bool {
// Cleanup/complete transitions purge data.
if ( isset( self::DESTRUCTIVE_TRANSITIONS[ $from ] )
&& in_array( $to, self::DESTRUCTIVE_TRANSITIONS[ $from ], true ) ) {
return true;
}
// Reverting an active module to idle abandons custom-table data.
if ( 'idle' === $to && in_array( $from, self::ACTIVE_STATES, true ) ) {
return true;
}
return false;
}
/**
* Take an auto-snapshot for the pending FSM transition.
* Called from Feature_Flags::set() before update_option.
*
* @param string $module Module name.
* @param string $from Current state.
* @param string $to Target state.
* @return string|null Snapshot ID on success; null on skip/failure.
*/
public static function maybe_snapshot( string $module, string $from, string $to ): ?string {
if ( ! self::is_destructive( $from, $to ) ) {
return null;
}
// Check if Snapshot_Manager is loaded — defensive (in case unit tests skip include).
if ( ! class_exists( 'TMDO_Snapshot_Manager' ) ) {
return null;
}
// Allow filter to suppress (e.g. CLI passed --skip-snapshot for emergency rewind).
$skip = (bool) apply_filters( 'wpdo/fsm_guard/skip_snapshot', false, $module, $from, $to );
if ( $skip ) {
return null;
}
$result = TMDO_Snapshot_Manager::create(
'pre_fsm_transition',
array(),
array(
'notes' => sprintf( '[%s] %s → %s', $module, $from, $to ),
'retention_days' => 90, // Keep FSM-rewind snapshots longer.
)
);
return ! empty( $result['ok'] ) ? (string) $result['snapshot_id'] : null;
}
/**
* Convenience: check + snapshot + record entry time. Called from
* Feature_Flags::set(). Returns WP_Error to block, true to allow.
*
* @param string $module Module identifier.
* @param string $to Target state.
* @return true|WP_Error
*/
public static function before_transition( string $module, string $to ) {
$from = TMDO_Feature_Flags::get( $module );
$check = self::can_transition( $module, $from, $to );
if ( is_wp_error( $check ) ) {
return $check;
}
self::maybe_snapshot( $module, $from, $to );
return true;
}
/**
* Record state entry time after a successful transition. Stored in
* wp_options key `wpdo_fsm_state_entered` for verify-gate / wash-period
* checks (v2.3.0 M5 will consume this).
*
* @param string $module Module identifier.
* @param string $to New state.
* @return void
*/
public static function record_entry( string $module, string $to ): void {
$key = 'wpdo_fsm_state_entered';
$data = get_option( $key, array() );
if ( ! is_array( $data ) ) {
$data = array();
}
$data[ $module ] = array(
'state' => $to,
'entered_at' => current_time( 'mysql', true ),
);
update_option( $key, $data, false );
}
/**
* Read the entry timestamp for a module's current state.
*
* @param string $module Module identifier.
* @return string|null UTC mysql datetime, or null when unrecorded.
*/
public static function get_entry_time( string $module ): ?string {
$data = get_option( 'wpdo_fsm_state_entered', array() );
return is_array( $data ) && isset( $data[ $module ]['entered_at'] ) ? (string) $data[ $module ]['entered_at'] : null;
}
}