Files
2meet-data-optimizer/includes/diagnostic/class-tmdo-drift-check.php
T
wpdev 9d1a7c5591 feat(2meet-data-optimizer): throttle shadow_read diffs per key and promote on clean drift checks
- The shadow diff logger records at most 20 rows per entity type + key per hour across all objects, on top of the existing per-object 5-minute window: a key stale on every object (WooCommerce total_sales, written by direct SQL) no longer logs a row per product read.
- The auto-promoter's third condition is now TMDO_Drift_Check::clean_since(): every backfillable group of the entity checked within 2 days (sampled or full) with no drift; no record, no promotion. The shadow diff count no longer gates it (the filter's 4th argument is passed as 0). The Promotion Guard still refuses HivePress / WooCommerce sites.
- Reason classification was dropped (decision 2026-09-27): it needs a schema change and drift-check already reports miss / diff / extra per key.
- Verify: ShadowDiffThrottleTest, AutoPromoterDriftTest (RED before); dev36 simulated user shadow_read: clean_since yes, promotion still refused by the guard, mode unchanged.
- PR: PR-075
2026-09-27 00:48:18 +08:00

533 lines
21 KiB
PHP

<?php
/**
* TMDO_Drift_Check — read-only flat-vs-native comparison for every entity type.
*
* For each backfillable group and key, the flat column is compared with what
* native meta would put there (first native row → safe unserialize →
* TMDO_Type_Caster::to_db()):
*
* miss — native holds a non-empty value, the flat column is NULL or has no row
* diff — both hold a value and the values differ (numbers, JSON and
* serialized values are compared by value, not by formatting)
* extra — the flat column holds a value, native has no row for the key
* (a required column holding its default counts as absent); rows of
* another post type or of a deleted entity count too
*
* run() writes nothing except the per-group outcome in the wpdo_drift_status
* option. fix() rebuilds the drifted rows from native (native is never written).
*
* @package WP_Data_Optimizer
* @since 1.2.0
*/
// phpcs:ignore WPDO.AntiEAV -- drift checker: must read raw meta to compare it with the flat tables
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Read-only checker: table and column names come from the registry / adapters, meta keys and ids go through prepare() or intval.
/**
* Flat-vs-native drift checker.
*/
final class TMDO_Drift_Check {
/**
* Option holding the last outcome per entity / group.
*/
public const OPTION = 'wpdo_drift_status';
/**
* Entities per group sampled by the daily 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,
* and a case-insensitive one would hide 'b' vs 'B'.
*/
private const SQL_NUMERIC = '^-?[0-9]+(\\\\.[0-9]+)?$';
/**
* Compare one entity type's flat tables with native meta.
*
* @param string $entity_type post | user | term | comment.
* @param string $group One group, or '' for every backfillable group.
* @param int $sample 0 checks every entity; N checks N random entities per group.
* @return array{entity:string, sample:int, groups:array<string, array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>}>, total:array{miss:int, diff:int, extra:int}}
* @throws InvalidArgumentException Unknown entity type or group.
*/
public static function run( string $entity_type, string $group = '', int $sample = 0 ): array {
list( $adapter, $groups ) = self::resolve( $entity_type, $group );
$report = array(
'entity' => $entity_type,
'sample' => $sample,
'groups' => array(),
'total' => array(
'miss' => 0,
'diff' => 0,
'extra' => 0,
),
);
$status = get_option( self::OPTION, array() );
$status = is_array( $status ) ? $status : array();
foreach ( $groups as $name ) {
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $name );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
continue;
}
$result = self::check_group( $adapter, $entity_type, $name, $table, $sample );
unset( $result['ids'] );
$report['groups'][ $name ] = $result;
foreach ( array( 'miss', 'diff', 'extra' ) as $kind ) {
$report['total'][ $kind ] += $result[ $kind ];
}
$status[ $entity_type ][ $name ] = array(
'miss' => $result['miss'],
'diff' => $result['diff'],
'extra' => $result['extra'],
'sample' => $sample,
'checked_at' => gmdate( 'Y-m-d H:i:s' ),
);
}
update_option( self::OPTION, $status );
return $report;
}
/**
* Every backfillable group of the entity was checked within $max_age
* seconds (sampled or full) and none drifted. No record counts as unclean.
*
* @param string $entity_type Entity type.
* @param int $max_age Seconds.
*/
public static function clean_since( string $entity_type, int $max_age ): bool {
$status = get_option( self::OPTION, array() );
$status = is_array( $status ) ? ( $status[ $entity_type ] ?? array() ) : array();
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
if ( empty( $groups ) ) {
return false;
}
foreach ( $groups as $group ) {
$last = $status[ $group ] ?? null;
if ( ! is_array( $last )
|| (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0
|| (int) strtotime( ( $last['checked_at'] ?? '' ) . ' UTC' ) < time() - $max_age ) {
return false;
}
}
return true;
}
/**
* Daily check (wpdo_daily_health_check): sample every entity type written
* to both native and flat (dual_write, shadow_read). In aeav_only flat is
* the source of truth and native is stale by design, so drift means nothing
* there. The outcome replaces the recorded status, so a group found
* drifting leaves the router at once.
*/
public static function cron_tick(): void {
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 ) ) {
continue;
}
try {
self::run( $entity_type, '', self::CRON_SAMPLE );
} catch ( InvalidArgumentException $e ) {
continue; // No adapter for this entity type on this site.
}
}
}
/**
* 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.
*
* Rows of deleted entities (or, for post groups, of posts outside the
* group's post types) are deleted. Every other entity with drift in any key
* gets its whole row rebuilt from native; when native holds none of the
* group's keys the row is kept with its columns cleared. Row caches of the
* touched entities are invalidated. Native meta is never written.
*
* @param string $entity_type post | user | term | comment.
* @param string $group One group, or '' for every backfillable group.
* @return array{fixed:array{rebuilt:int, cleared:int, deleted_rows:int}, after:array<string, mixed>}
* @throws RuntimeException In aeav_only, where flat is the source of truth
* (and InvalidArgumentException via resolve() for an
* unknown entity type or group).
*/
public static function fix( string $entity_type, string $group = '' ): array {
global $wpdb;
list( $adapter, $groups ) = self::resolve( $entity_type, $group );
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( $entity_type ) ) {
throw new RuntimeException( esc_html( $entity_type ) . ' is in aeav_only: flat is the source of truth and native meta is stale, so it cannot be rebuilt from native. Write flat back first (wp tmdo bridge-restore-native).' );
}
$id = $adapter->get_entity_id_column();
$fixed = array(
'rebuilt' => 0,
'cleared' => 0,
'deleted_rows' => 0,
);
foreach ( $groups as $name ) {
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $name );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
continue;
}
foreach ( self::stray_ids( $adapter, $entity_type, $name, $table ) as $eid ) {
$fixed['deleted_rows'] += (int) $wpdb->delete( $table, array( $id => $eid ), array( '%d' ) );
TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name );
}
foreach ( self::check_group( $adapter, $entity_type, $name, $table, 0 )['ids'] as $eid ) {
if ( TMDO_Entity_Migration_Engine::migrate_entity( $entity_type, $name, $eid ) ) {
++$fixed['rebuilt'];
} else {
self::clear_row( $entity_type, $name, $table, $id, $eid );
++$fixed['cleared'];
}
TMDO_Cache_Orchestrator::invalidate( $entity_type, $eid, $name );
}
}
return array(
'fixed' => $fixed,
'after' => self::run( $entity_type, $group ),
);
}
/**
* Adapter and group list for a run.
*
* @param string $entity_type Entity type.
* @param string $group One group, or '' for every backfillable group.
* @return array{0:TMDO_Entity_Adapter_Interface, 1:string[]}
* @throws InvalidArgumentException Unknown entity type or group.
*/
private static function resolve( string $entity_type, string $group ): array {
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
throw new InvalidArgumentException( 'Unknown entity type: ' . esc_html( $entity_type ) );
}
$groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type );
if ( '' !== $group ) {
if ( ! in_array( $group, $groups, true ) ) {
throw new InvalidArgumentException( 'Unknown or orphan ' . esc_html( $entity_type ) . ' group: ' . esc_html( $group ) );
}
$groups = array( $group );
}
return array( $adapter, $groups );
}
/**
* Compare every key of one group.
*
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param string $table Group flat table.
* @param int $sample 0, or the number of random entities.
* @return array{miss:int, diff:int, extra:int, keys:array<string, array{miss:int, diff:int, extra:int}>, ids:int[]}
*/
private static function check_group( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table, int $sample ): array {
global $wpdb;
$meta = $adapter->get_native_meta_table();
$id = $adapter->get_entity_id_column();
$mid = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
$scope = self::scope_sql( $adapter, $entity_type, $group, $sample );
$result = array(
'miss' => 0,
'diff' => 0,
'extra' => 0,
'keys' => array(),
'ids' => array(),
);
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
$key = (string) $field['key'];
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
$type = $field['type'] ?? 'text';
// First native row per entity — what a single get_*_meta() read returns.
$native = $wpdb->prepare( "SELECT m.`{$id}` AS eid, m.meta_value AS v FROM `{$meta}` m JOIN ( SELECT `{$id}`, MIN(`{$mid}`) AS mid FROM `{$meta}` WHERE meta_key = %s GROUP BY `{$id}` ) x ON x.mid = m.`{$mid}` WHERE 1=1", $key )
. str_replace( '{id}', "m.`{$id}`", $scope );
// Text beyond the column width is stored as NULL by design (Type_Caster::fit_text).
$fits = 'text' === $type ? ' AND CHAR_LENGTH(n.v) <= ' . TMDO_Type_Caster::TEXT_MAX_CHARS : '';
$miss = array_map( 'intval', $wpdb->get_col( "SELECT n.eid FROM ( {$native} ) n LEFT JOIN `{$table}` f ON f.`{$id}` = n.eid WHERE LENGTH(n.v) > 0 AND f.`{$col}` IS NULL{$fits}" ) );
$candidates = $wpdb->get_results(
"SELECT n.eid, n.v, f.`{$col}` AS flat FROM ( {$native} ) n JOIN `{$table}` f ON f.`{$id}` = n.eid
WHERE f.`{$col}` IS NOT NULL AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY n.v
OR ( n.v REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(n.v AS DECIMAL(30,6)) ) )",
ARRAY_A
);
$diff = array();
foreach ( (array) $candidates as $row ) {
if ( ! self::same( (string) $row['v'], (string) $row['flat'], $field ) ) {
$diff[] = (int) $row['eid'];
}
}
$absent = $wpdb->prepare( "NOT EXISTS ( SELECT 1 FROM `{$meta}` m WHERE m.`{$id}` = f.`{$id}` AND m.meta_key = %s )", $key );
if ( ! empty( $field['required'] ) ) {
$default = (string) TMDO_Type_Caster::to_db( null, $field );
$absent .= $wpdb->prepare(
" AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY %s OR ( %s REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(%s AS DECIMAL(30,6)) ) )",
$default,
$default,
$default
);
}
// Every flat row counts, including rows of other post types or deleted
// entities; only sample mode narrows the rows.
$extra = array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f WHERE f.`{$col}` IS NOT NULL AND {$absent}" . ( $sample > 0 ? str_replace( '{id}', "f.`{$id}`", $scope ) : '' ) ) );
$counts = array(
'miss' => count( $miss ),
'diff' => count( $diff ),
'extra' => count( $extra ),
);
$result['keys'][ $key ] = $counts;
$result['miss'] += $counts['miss'];
$result['diff'] += $counts['diff'];
$result['extra'] += $counts['extra'];
$result['ids'] = array_merge( $result['ids'], $miss, $diff, $extra );
}
$result['ids'] = array_values( array_unique( $result['ids'] ) );
return $result;
}
/**
* Flat rows whose entity no longer exists or, for post groups, whose post
* is outside the group's post types.
*
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param string $table Group flat table.
* @return int[]
*/
private static function stray_ids( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table ): array {
global $wpdb;
$id = $adapter->get_entity_id_column();
$primary = $adapter->get_primary_table();
$pk = $adapter->get_primary_id_column();
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array();
$type_sql = $post_types ? " AND p.post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')" : '';
return array_map( 'intval', $wpdb->get_col( "SELECT f.`{$id}` FROM `{$table}` f LEFT JOIN `{$primary}` p ON p.`{$pk}` = f.`{$id}`{$type_sql} WHERE p.`{$pk}` IS NULL" ) );
}
/**
* Clear a row's columns: native holds none of the group's keys. The row is
* kept (NULL-only rows are not deleted, as the router may join on them);
* required columns go back to their default.
*
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param string $table Group flat table.
* @param string $id Entity id column.
* @param int $eid Entity id.
*/
private static function clear_row( string $entity_type, string $group, string $table, string $id, int $eid ): void {
global $wpdb;
$data = array();
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
$data[ TMDO_Schema_Manager::sanitize_column_name( (string) $field['key'] ) ] = TMDO_Type_Caster::to_db( null, $field );
}
$wpdb->update( $table, $data, array( $id => $eid ) );
}
/**
* SQL restricting the checked entities: the group's post types, and in
* sample mode a random set of entity ids. `{id}` stands for the id column.
*
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $entity_type Entity type.
* @param string $group Group name.
* @param int $sample 0, or the number of random entities.
*/
private static function scope_sql( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, int $sample ): string {
global $wpdb;
$post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array();
$type_sql = $post_types
? " post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')"
: '';
if ( $sample <= 0 ) {
return $type_sql ? " AND {id} IN ( SELECT ID FROM {$wpdb->posts} WHERE{$type_sql} )" : '';
}
$primary = $adapter->get_primary_table();
$pk = $adapter->get_primary_id_column();
$ids = $wpdb->get_col( "SELECT `{$pk}` FROM `{$primary}`" . ( $type_sql ? " WHERE{$type_sql}" : '' ) . ' ORDER BY RAND() LIMIT ' . (int) $sample );
return ' AND {id} IN (' . ( $ids ? implode( ',', array_map( 'intval', $ids ) ) : '0' ) . ')';
}
/**
* Whether a flat value is what native meta would store.
*
* @param string $native Raw native meta_value.
* @param string $flat Flat column value.
* @param array<string, mixed> $field Registered field definition.
*/
private static function same( string $native, string $flat, array $field ): bool {
$type = $field['type'] ?? 'text';
$value = TMDO_Safe_Unserialize::run( $native );
if ( 'text' === $type && is_scalar( $value ) && mb_strlen( (string) $value ) > TMDO_Type_Caster::TEXT_MAX_CHARS ) {
return false; // Belongs in flat as NULL, yet this row holds a value.
}
$expected = TMDO_Type_Caster::to_db( $value, $field );
if ( null === $expected ) {
return false;
}
if ( 'json' === $type ) {
// Key order and int-vs-float formatting are not drift.
return json_decode( $flat, true ) == json_decode( (string) $expected, true ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
}
if ( is_numeric( $expected ) && is_numeric( $flat ) ) {
return abs( (float) $expected - (float) $flat ) < 0.000001;
}
return (string) $expected === $flat;
}
}