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
216 lines
6.7 KiB
PHP
216 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
|
|
*/
|
|
|
|
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;
|
|
}
|
|
}
|