feat(2meet-data-optimizer): backfill writes made while the Hook Bus was off

- 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
This commit is contained in:
2026-09-27 00:41:43 +08:00
parent 1b21ee9add
commit a26500206a
4 changed files with 198 additions and 0 deletions
+7
View File
@@ -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' ) );
+2
View File
@@ -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.
* *
+65
View File
@@ -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' );