hardening 2.2: 2meet-data-optimizer 1.2.0 (drift detection, scope, CLI and diagnostics) #6
@@ -299,6 +299,13 @@ class TMDO_Core {
|
|||||||
// 1.2.0: daily flat-vs-native drift sample (read by the post query router).
|
// 1.2.0: daily flat-vs-native drift sample (read by the post query router).
|
||||||
add_action( 'wpdo_daily_health_check', array( 'TMDO_Drift_Check', 'cron_tick' ) );
|
add_action( 'wpdo_daily_health_check', array( 'TMDO_Drift_Check', 'cron_tick' ) );
|
||||||
|
|
||||||
|
// 1.2.0: rebuild flat from native after writes bypassed it (entity was
|
||||||
|
// disabled, or the Hook Bus was off).
|
||||||
|
add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Drift_Check', 'on_mode_changed' ), 10, 3 );
|
||||||
|
add_action( 'update_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_toggled' ), 10, 2 );
|
||||||
|
add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );
|
||||||
|
add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );
|
||||||
|
|
||||||
// v2.3.0 M6: daily snapshot prune cron (paired with health check).
|
// v2.3.0 M6: daily snapshot prune cron (paired with health check).
|
||||||
add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) );
|
add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) );
|
||||||
|
|
||||||
|
|||||||
@@ -788,6 +788,7 @@ class TMDO_Installer {
|
|||||||
wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
|
wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' );
|
||||||
wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
|
wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
|
||||||
wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' );
|
wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' );
|
||||||
|
wp_clear_scheduled_hook( 'tmdo_drift_resync' );
|
||||||
wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' );
|
wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' );
|
||||||
wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
|
wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
|
||||||
wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
|
wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
|
||||||
@@ -991,6 +992,7 @@ class TMDO_Installer {
|
|||||||
'wpdo_health_snapshot_monthly',
|
'wpdo_health_snapshot_monthly',
|
||||||
'wpdo_post_shadow_verify',
|
'wpdo_post_shadow_verify',
|
||||||
'wpdo_term_comment_shadow_verify',
|
'wpdo_term_comment_shadow_verify',
|
||||||
|
'tmdo_drift_resync',
|
||||||
'wpdo_post_stress_test_batch',
|
'wpdo_post_stress_test_batch',
|
||||||
'wpdo_user_stress_test_batch',
|
'wpdo_user_stress_test_batch',
|
||||||
'wpdo_term_stress_test_batch',
|
'wpdo_term_stress_test_batch',
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ final class TMDO_Drift_Check {
|
|||||||
*/
|
*/
|
||||||
public const CRON_SAMPLE = 500;
|
public const CRON_SAMPLE = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single WP-Cron event rebuilding an entity's flat tables from native after
|
||||||
|
* writes bypassed them. Argument: the entity type.
|
||||||
|
*/
|
||||||
|
public const RESYNC_HOOK = 'tmdo_drift_resync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seconds before the scheduled rebuild, so the switch that triggered it
|
||||||
|
* finishes first.
|
||||||
|
*/
|
||||||
|
private const RESYNC_DELAY = 60;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MySQL regex for a plain decimal number, used to skip rows that obviously match.
|
* MySQL regex for a plain decimal number, used to skip rows that obviously match.
|
||||||
* Exact matches compare BINARY: meta and flat tables may differ in collation,
|
* Exact matches compare BINARY: meta and flat tables may differ in collation,
|
||||||
@@ -122,6 +134,118 @@ final class TMDO_Drift_Check {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entities to rebuild after a mode change: one whose flat tables were not
|
||||||
|
* written (disabled) is written next to native again, and values written
|
||||||
|
* meanwhile are missing from flat.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $new_mode New mode.
|
||||||
|
* @param string $old_mode Previous mode.
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function resync_after_mode_change( string $entity_type, string $new_mode, string $old_mode ): array {
|
||||||
|
$keeps_native = array( TMDO_Mode_Manager::MODE_DUAL_WRITE, TMDO_Mode_Manager::MODE_SHADOW_READ );
|
||||||
|
return TMDO_Mode_Manager::MODE_DISABLED === $old_mode && in_array( $new_mode, $keeps_native, true ) ? array( $entity_type ) : array();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entities to rebuild after the Hook Bus flag changes: no write reached
|
||||||
|
* flat while it was off. Every entity written to both native and flat
|
||||||
|
* (aeav_only keeps flat as the source of truth).
|
||||||
|
*
|
||||||
|
* @param mixed $old_value Previous flag value.
|
||||||
|
* @param mixed $value New flag value.
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function resync_after_hook_bus_change( $old_value, $value ): array {
|
||||||
|
if ( self::flag_on( $old_value ) || ! self::flag_on( $value ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
$entities = array();
|
||||||
|
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
|
||||||
|
if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::writes_to_eav( $entity_type ) ) {
|
||||||
|
$entities[] = $entity_type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `wpdo_bridge_mode_changed` handler.
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
* @param string $new_mode New mode.
|
||||||
|
* @param string $old_mode Previous mode.
|
||||||
|
*/
|
||||||
|
public static function on_mode_changed( string $entity_type, string $new_mode, string $old_mode ): void {
|
||||||
|
self::schedule_resync( self::resync_after_mode_change( $entity_type, $new_mode, $old_mode ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `update_option_wpdo_hook_bus_enabled` handler.
|
||||||
|
*
|
||||||
|
* @param mixed $old_value Previous flag value.
|
||||||
|
* @param mixed $value New flag value.
|
||||||
|
*/
|
||||||
|
public static function on_hook_bus_toggled( $old_value, $value ): void {
|
||||||
|
self::schedule_resync( self::resync_after_hook_bus_change( $old_value, $value ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `add_option_wpdo_hook_bus_enabled` handler: the flag is stored for the first time.
|
||||||
|
*
|
||||||
|
* @param string $option Option name.
|
||||||
|
* @param mixed $value Flag value.
|
||||||
|
*/
|
||||||
|
public static function on_hook_bus_added( $option, $value ): void {
|
||||||
|
unset( $option );
|
||||||
|
self::schedule_resync( self::resync_after_hook_bus_change( '0', $value ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RESYNC_HOOK handler. An entity that has meanwhile moved to aeav_only is
|
||||||
|
* left alone (fix() refuses).
|
||||||
|
*
|
||||||
|
* @param string $entity_type Entity type.
|
||||||
|
*/
|
||||||
|
public static function run_scheduled_fix( string $entity_type ): void {
|
||||||
|
try {
|
||||||
|
$result = self::fix( $entity_type );
|
||||||
|
TMDO_Logger::info( 'drift_resync', array( 'entity' => $entity_type ) + $result['fixed'] );
|
||||||
|
} catch ( InvalidArgumentException | RuntimeException $e ) {
|
||||||
|
TMDO_Logger::info(
|
||||||
|
'drift_resync_skipped',
|
||||||
|
array(
|
||||||
|
'entity' => $entity_type,
|
||||||
|
'reason' => $e->getMessage(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule one rebuild per entity type (not twice while one is pending).
|
||||||
|
*
|
||||||
|
* @param string[] $entities Entity types.
|
||||||
|
*/
|
||||||
|
private static function schedule_resync( array $entities ): void {
|
||||||
|
foreach ( $entities as $entity_type ) {
|
||||||
|
if ( ! wp_next_scheduled( self::RESYNC_HOOK, array( $entity_type ) ) ) {
|
||||||
|
wp_schedule_single_event( time() + self::RESYNC_DELAY, self::RESYNC_HOOK, array( $entity_type ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a stored Hook Bus flag value means "on" (as TMDO_Hook_Bus_Bridge reads it).
|
||||||
|
*
|
||||||
|
* @param mixed $value Flag value.
|
||||||
|
*/
|
||||||
|
private static function flag_on( $value ): bool {
|
||||||
|
return '1' === (string) $value || true === $value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuild drifted flat rows from native meta, then check again.
|
* Rebuild drifted flat rows from native meta, then check again.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -241,6 +241,71 @@ class DriftCheckTest extends TestCase {
|
|||||||
$this->assertSame( $flat, md5( wp_json_encode( $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) );
|
$this->assertSame( $flat, md5( wp_json_encode( $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── resync after writes bypassed flat (2.2 PR-074) ──────────────────────
|
||||||
|
|
||||||
|
public function test_turning_writes_on_schedules_a_fix_for_that_entity(): void {
|
||||||
|
$this->assertSame( array( 'user' ), TMDO_Drift_Check::resync_after_mode_change( 'user', 'dual_write', 'disabled' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_other_mode_changes_schedule_nothing(): void {
|
||||||
|
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'shadow_read', 'dual_write' ) );
|
||||||
|
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'aeav_only', 'shadow_read' ) );
|
||||||
|
$this->assertSame( array(), TMDO_Drift_Check::resync_after_mode_change( 'user', 'disabled', 'dual_write' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_turning_the_hook_bus_on_schedules_a_fix_for_entities_that_keep_native(): void {
|
||||||
|
self::set_modes( array( 'post' => 'aeav_only', 'user' => 'dual_write', 'term' => 'shadow_read', 'comment' => 'disabled' ) );
|
||||||
|
try {
|
||||||
|
$on = TMDO_Drift_Check::resync_after_hook_bus_change( '0', '1' );
|
||||||
|
$off = TMDO_Drift_Check::resync_after_hook_bus_change( '1', '0' );
|
||||||
|
} finally {
|
||||||
|
self::set_modes( null );
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame( array( 'user', 'term' ), $on );
|
||||||
|
$this->assertSame( array(), $off );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_scheduled_fix_repairs_the_entity(): void {
|
||||||
|
self::set_modes( array( 'post' => 'disabled', 'user' => 'dual_write', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
try {
|
||||||
|
TMDO_Drift_Check::run_scheduled_fix( 'user' );
|
||||||
|
} finally {
|
||||||
|
self::set_modes( null );
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp'];
|
||||||
|
$this->assertSame( 0, $status['miss'] + $status['diff'] + $status['extra'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_scheduled_fix_leaves_aeav_only_alone(): void {
|
||||||
|
self::set_modes( array( 'post' => 'disabled', 'user' => 'aeav_only', 'term' => 'disabled', 'comment' => 'disabled' ) );
|
||||||
|
try {
|
||||||
|
TMDO_Drift_Check::run_scheduled_fix( 'user' );
|
||||||
|
} finally {
|
||||||
|
self::set_modes( null );
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertFalse( get_option( TMDO_Drift_Check::OPTION ) );
|
||||||
|
$this->assertSame( 'B', $this->flat_row( 2 )['name'], 'flat untouched' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_resync_triggers_are_hooked(): void {
|
||||||
|
$source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' );
|
||||||
|
|
||||||
|
foreach ( array(
|
||||||
|
"add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Drift_Check', 'on_mode_changed' ), 10, 3 );",
|
||||||
|
"add_action( 'update_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_toggled' ), 10, 2 );",
|
||||||
|
"add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );",
|
||||||
|
"add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );",
|
||||||
|
) as $line ) {
|
||||||
|
$this->assertStringContainsString( $line, $source );
|
||||||
|
}
|
||||||
|
// Literal: uninstall.php does not load TMDO_Drift_Check.
|
||||||
|
$installer = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-installer.php' );
|
||||||
|
$this->assertSame( 2, substr_count( $installer, "'" . TMDO_Drift_Check::RESYNC_HOOK . "'" ), 'cleared on deactivate and uninstall' );
|
||||||
|
}
|
||||||
|
|
||||||
public function test_the_daily_tick_runs_on_the_daily_health_check_hook(): void {
|
public function test_the_daily_tick_runs_on_the_daily_health_check_hook(): void {
|
||||||
$source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' );
|
$source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' );
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user