}>, 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_Schema_Manager::resynced( $entity_type ); // Groups upgraded while deferred use the flat table again. 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} * @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, 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 $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; } }