From a26500206a0259106562ae1377cbe7f8ebd07607 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sun, 27 Sep 2026 00:41:43 +0800 Subject: [PATCH] feat(2meet-data-optimizer): backfill writes made while the Hook Bus was off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - When an entity goes from disabled to dual_write / shadow_read, or the Hook Bus flag is switched on, a single WP-Cron event (tmdo_drift_resync, one per entity, 60 s later) runs drift-check --fix for the entities that keep native meta, so values written while flat was bypassed land in flat. aeav_only is never touched. - A full fix instead of the planned post_modified_gmt increment (decision 2026-09-27): meta-only writes such as WooCommerce's direct stock SQL do not bump post_modified. No wpdo_bridge_off_since. - The resync hook is cleared on deactivate / uninstall (literal name: uninstall.php does not load TMDO_Drift_Check). - Verify: DriftCheckTest resync cases (RED before); dev36 end to end with real WP-Cron: write while user disabled → miss 1 → bridge-mode user dual_write schedules tmdo_drift_resync [user] → run → drift 0. - PR: PR-074 --- includes/class-tmdo-core.php | 7 + includes/class-tmdo-installer.php | 2 + .../diagnostic/class-tmdo-drift-check.php | 124 ++++++++++++++++++ tests/integration/DriftCheckTest.php | 65 +++++++++ 4 files changed, 198 insertions(+) diff --git a/includes/class-tmdo-core.php b/includes/class-tmdo-core.php index 4c28256..fa8fb2e 100644 --- a/includes/class-tmdo-core.php +++ b/includes/class-tmdo-core.php @@ -299,6 +299,13 @@ class TMDO_Core { // 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' ) ); + // 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). add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) ); diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php index 27c1eae..7dd5fe8 100644 --- a/includes/class-tmdo-installer.php +++ b/includes/class-tmdo-installer.php @@ -788,6 +788,7 @@ class TMDO_Installer { wp_clear_scheduled_hook( 'wpdo_health_snapshot_monthly' ); wp_clear_scheduled_hook( 'wpdo_post_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_user_stress_test_batch' ); wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' ); @@ -991,6 +992,7 @@ class TMDO_Installer { 'wpdo_health_snapshot_monthly', 'wpdo_post_shadow_verify', 'wpdo_term_comment_shadow_verify', + 'tmdo_drift_resync', 'wpdo_post_stress_test_batch', 'wpdo_user_stress_test_batch', 'wpdo_term_stress_test_batch', diff --git a/includes/diagnostic/class-tmdo-drift-check.php b/includes/diagnostic/class-tmdo-drift-check.php index 0ea49d2..7c21206 100644 --- a/includes/diagnostic/class-tmdo-drift-check.php +++ b/includes/diagnostic/class-tmdo-drift-check.php @@ -45,6 +45,18 @@ final class TMDO_Drift_Check { */ 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. * 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. * diff --git a/tests/integration/DriftCheckTest.php b/tests/integration/DriftCheckTest.php index 1a66471..c646b4c 100644 --- a/tests/integration/DriftCheckTest.php +++ b/tests/integration/DriftCheckTest.php @@ -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 ) ) ) ); } + // ── 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 { $source = (string) file_get_contents( WPDO_PLUGIN_DIR . 'includes/class-tmdo-core.php' );