d36bb954d1
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
429 lines
13 KiB
PHP
429 lines
13 KiB
PHP
<?php
|
|
/**
|
|
* TMDO_Term_Comment_Shadow_Verifier — Sample-and-compare flat vs wp_*meta
|
|
* for term + comment entities (v2.12.5 Phase 5).
|
|
*
|
|
* Mirror of TMDO_Post_Shadow_Verifier (v2.10.3). Used during shadow_read mode
|
|
* to verify the flat tables stay in sync with wp_termmeta / wp_commentmeta.
|
|
* Each sample picks a random entity + key, fetches both values, counts
|
|
* matches / diffs / missing rows. Divergent values are written to
|
|
* `wpdo_shadow_diffs` via TMDO_Shadow_Diff_Logger (entity_type-aware).
|
|
*
|
|
* Run pattern:
|
|
* - Manual: `wp wpdo term-comment-shadow-report`
|
|
* - Cron: wpdo_term_comment_shadow_verify event registered when EITHER
|
|
* term or comment mode is shadow_read; auto-unregistered when
|
|
* both modes are not shadow_read
|
|
*
|
|
* 🔒 Frozen contract: read-only verifier. Never modifies wp_termmeta /
|
|
* wp_commentmeta or flat tables.
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
* @since 2.12.5
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Internal verifier: $flat_table comes from registry, columns sanitized via Schema_Manager, user values use prepare(). Read-only.
|
|
|
|
/**
|
|
* Sample-and-compare verifier for term + comment entity flat tables.
|
|
*/
|
|
final class TMDO_Term_Comment_Shadow_Verifier {
|
|
|
|
/** Cron event hook fired hourly when at least one of term/comment modes is shadow_read. */
|
|
public const CRON_HOOK = 'wpdo_term_comment_shadow_verify';
|
|
|
|
/** Default sample size per entity per cron tick. */
|
|
public const DEFAULT_SAMPLE_SIZE = 100;
|
|
|
|
/**
|
|
* Group → flat table suffix mapping. Keep in sync with
|
|
* TMDO_Hivepress_Term_Comment_Fields registrations and the misc bucket.
|
|
*
|
|
* @var array<string, array{entity_type:string, table_suffix:string, has_misc:bool}>
|
|
*/
|
|
private const GROUP_MAP = array(
|
|
'hp_taxonomy' => array(
|
|
'entity_type' => 'term',
|
|
'table_suffix' => 'wpdo_term_hp_taxonomy',
|
|
),
|
|
'hp_review' => array(
|
|
'entity_type' => 'comment',
|
|
'table_suffix' => 'wpdo_comment_hp_review',
|
|
),
|
|
);
|
|
|
|
/**
|
|
* Run a sample-compare pass for a single entity group.
|
|
*
|
|
* @param string $entity_type 'term' or 'comment'.
|
|
* @param string $group_name Entity group name.
|
|
* @param string $flat_table Fully-qualified flat table name.
|
|
* @param string[] $keys Meta keys to compare.
|
|
* @param int $sample_size Number of entities to sample.
|
|
* @return array{
|
|
* sampled:int,
|
|
* matched:int,
|
|
* diffs:int,
|
|
* missing_flat:int,
|
|
* missing_meta:int,
|
|
* group:string,
|
|
* entity_type:string,
|
|
* }
|
|
* @throws InvalidArgumentException When inputs invalid.
|
|
*/
|
|
public static function sample_compare(
|
|
string $entity_type,
|
|
string $group_name,
|
|
string $flat_table,
|
|
array $keys,
|
|
int $sample_size = self::DEFAULT_SAMPLE_SIZE
|
|
): array {
|
|
if ( ! in_array( $entity_type, array( 'term', 'comment' ), true ) ) {
|
|
$msg = 'entity_type must be term or comment';
|
|
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
|
}
|
|
if ( $sample_size <= 0 ) {
|
|
$msg = 'Sample size must be > 0';
|
|
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
|
}
|
|
if ( empty( $keys ) ) {
|
|
$msg = 'Keys array cannot be empty';
|
|
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
|
|
}
|
|
|
|
global $wpdb;
|
|
|
|
// Source table + id column + meta table differ per entity type.
|
|
if ( 'term' === $entity_type ) {
|
|
$source_table = $wpdb->terms;
|
|
$source_id_col = 'term_id';
|
|
$meta_table = $wpdb->termmeta;
|
|
$meta_id_col = 'term_id';
|
|
$flat_id_col = 'term_id';
|
|
} else {
|
|
$source_table = $wpdb->comments;
|
|
$source_id_col = 'comment_ID';
|
|
$meta_table = $wpdb->commentmeta;
|
|
$meta_id_col = 'comment_id';
|
|
$flat_id_col = 'comment_id';
|
|
}
|
|
|
|
$ids = $wpdb->get_col(
|
|
$wpdb->prepare(
|
|
"SELECT `{$source_id_col}` FROM `{$source_table}` ORDER BY RAND() LIMIT %d",
|
|
$sample_size
|
|
)
|
|
);
|
|
|
|
$sampled = count( $ids );
|
|
$matched = 0;
|
|
$diffs = 0;
|
|
$missing_flat = 0;
|
|
$missing_meta = 0;
|
|
|
|
if ( 0 === $sampled ) {
|
|
return array(
|
|
'sampled' => 0,
|
|
'matched' => 0,
|
|
'diffs' => 0,
|
|
'missing_flat' => 0,
|
|
'missing_meta' => 0,
|
|
'group' => $group_name,
|
|
'entity_type' => $entity_type,
|
|
);
|
|
}
|
|
|
|
foreach ( $ids as $object_id ) {
|
|
$object_id = (int) $object_id;
|
|
foreach ( $keys as $key ) {
|
|
$col = self::sanitize_column( $key );
|
|
$flat_val = $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT `{$col}` FROM `{$flat_table}` WHERE `{$flat_id_col}` = %d LIMIT 1",
|
|
$object_id
|
|
)
|
|
);
|
|
$meta_val = $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT meta_value FROM `{$meta_table}` WHERE `{$meta_id_col}` = %d AND meta_key = %s LIMIT 1",
|
|
$object_id,
|
|
$key
|
|
)
|
|
);
|
|
|
|
$flat_present = null !== $flat_val && '' !== $flat_val;
|
|
$meta_present = null !== $meta_val && '' !== $meta_val;
|
|
|
|
// In-memory counters only. Hook Bus auto-logs diffs to
|
|
// wpdo_shadow_diffs on every read in shadow_read mode (via
|
|
// TMDO_Shadow_Diff_Logger::compare_and_log) — this verifier
|
|
// is a sample-based snapshot, not the persistent log.
|
|
if ( ! $flat_present && ! $meta_present ) {
|
|
++$matched;
|
|
continue;
|
|
}
|
|
if ( ! $flat_present && $meta_present ) {
|
|
++$missing_flat;
|
|
continue;
|
|
}
|
|
if ( $flat_present && ! $meta_present ) {
|
|
++$missing_meta;
|
|
continue;
|
|
}
|
|
|
|
if ( self::values_loose_equal( $meta_val, $flat_val ) ) {
|
|
++$matched;
|
|
} else {
|
|
++$diffs;
|
|
}
|
|
}
|
|
}
|
|
|
|
return array(
|
|
'sampled' => $sampled,
|
|
'matched' => $matched,
|
|
'diffs' => $diffs,
|
|
'missing_flat' => $missing_flat,
|
|
'missing_meta' => $missing_meta,
|
|
'group' => $group_name,
|
|
'entity_type' => $entity_type,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Cron handler: runs sample_compare for every group whose entity is in
|
|
* shadow_read. No-op when neither term nor comment is shadow_read.
|
|
*
|
|
* @return void
|
|
*/
|
|
public static function cron_tick(): void {
|
|
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
|
|
return;
|
|
}
|
|
$term_shadow = 'shadow_read' === TMDO_Mode_Manager::get( 'term' );
|
|
$comment_shadow = 'shadow_read' === TMDO_Mode_Manager::get( 'comment' );
|
|
if ( ! $term_shadow && ! $comment_shadow ) {
|
|
return;
|
|
}
|
|
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
|
return;
|
|
}
|
|
|
|
global $wpdb;
|
|
|
|
foreach ( self::GROUP_MAP as $group => $cfg ) {
|
|
$entity_type = $cfg['entity_type'];
|
|
|
|
// Only run when the entity is in shadow_read mode.
|
|
if ( 'term' === $entity_type && ! $term_shadow ) {
|
|
continue;
|
|
}
|
|
if ( 'comment' === $entity_type && ! $comment_shadow ) {
|
|
continue;
|
|
}
|
|
|
|
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
|
|
if ( empty( $keys ) ) {
|
|
continue;
|
|
}
|
|
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
|
|
|
|
try {
|
|
self::sample_compare( $entity_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE );
|
|
} catch ( \Throwable $e ) {
|
|
if ( class_exists( 'TMDO_Logger' ) ) {
|
|
TMDO_Logger::error(
|
|
'term_comment_shadow_verifier',
|
|
'cron_tick',
|
|
$e->getMessage()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recent diff records for term + comment entity types.
|
|
*
|
|
* @param int $limit Max rows.
|
|
* @return array
|
|
*/
|
|
public static function recent_diffs( int $limit = 100 ): array {
|
|
if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) {
|
|
return array();
|
|
}
|
|
|
|
// Logger is entity-type-aware; gather both sides.
|
|
$term_diffs = TMDO_Shadow_Diff_Logger::recent( (int) ceil( $limit / 2 ), 'term' );
|
|
$comment_diffs = TMDO_Shadow_Diff_Logger::recent( (int) ceil( $limit / 2 ), 'comment' );
|
|
|
|
return array_merge( (array) $term_diffs, (array) $comment_diffs );
|
|
}
|
|
|
|
/**
|
|
* Aggregate diff stats for term+comment over recent N hours.
|
|
*
|
|
* @param int $hours Window (default 24).
|
|
* @return array{total:int,term:int,comment:int,by_key:array<string,int>}
|
|
*/
|
|
public static function diff_stats( int $hours = 24 ): array {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
|
|
|
|
$exists = (bool) $wpdb->get_var(
|
|
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
|
);
|
|
if ( ! $exists ) {
|
|
return array(
|
|
'total' => 0,
|
|
'term' => 0,
|
|
'comment' => 0,
|
|
'by_key' => array(),
|
|
);
|
|
}
|
|
|
|
$since = gmdate( 'Y-m-d H:i:s', time() - ( $hours * HOUR_IN_SECONDS ) );
|
|
|
|
$term_total = (int) $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'term' AND ts >= %s",
|
|
$since
|
|
)
|
|
);
|
|
$comment_total = (int) $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'comment' AND ts >= %s",
|
|
$since
|
|
)
|
|
);
|
|
|
|
$rows = $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT meta_key, COUNT(*) AS n FROM `{$table}` WHERE entity_type IN ('term','comment') AND ts >= %s GROUP BY meta_key ORDER BY n DESC",
|
|
$since
|
|
),
|
|
ARRAY_A
|
|
);
|
|
|
|
$by_key = array();
|
|
foreach ( (array) $rows as $row ) {
|
|
$by_key[ (string) $row['meta_key'] ] = (int) $row['n'];
|
|
}
|
|
|
|
return array(
|
|
'total' => $term_total + $comment_total,
|
|
'term' => $term_total,
|
|
'comment' => $comment_total,
|
|
'by_key' => $by_key,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Run sample_compare for ALL groups (regardless of mode). Used by
|
|
* `wp wpdo term-comment-shadow-report` so users can see drift even
|
|
* before promoting to shadow_read.
|
|
*
|
|
* @param int $sample_size Per-group sample size.
|
|
* @return array<string,array> Map of `group_name` → sample_compare() result.
|
|
*/
|
|
public static function run_all( int $sample_size = self::DEFAULT_SAMPLE_SIZE ): array {
|
|
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
|
return array();
|
|
}
|
|
|
|
global $wpdb;
|
|
$out = array();
|
|
|
|
foreach ( self::GROUP_MAP as $group => $cfg ) {
|
|
$entity_type = $cfg['entity_type'];
|
|
$keys = TMDO_Entity_Registry::get_group_keys( $entity_type, $group );
|
|
if ( empty( $keys ) ) {
|
|
continue;
|
|
}
|
|
$flat_table = $wpdb->prefix . $cfg['table_suffix'];
|
|
|
|
try {
|
|
$out[ $group ] = self::sample_compare( $entity_type, $group, $flat_table, $keys, $sample_size );
|
|
} catch ( \Throwable $e ) {
|
|
$out[ $group ] = array(
|
|
'error' => $e->getMessage(),
|
|
'group' => $group,
|
|
);
|
|
}
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Sanitize meta_key into a column name (mirrors Schema_Manager rules).
|
|
*
|
|
* @param string $key Meta key.
|
|
* @return string
|
|
*/
|
|
private static function sanitize_column( string $key ): string {
|
|
if ( class_exists( 'TMDO_Schema_Manager' ) ) {
|
|
return TMDO_Schema_Manager::sanitize_column_name( $key );
|
|
}
|
|
return preg_replace( '/[^a-zA-Z0-9_]/', '_', $key );
|
|
}
|
|
|
|
/**
|
|
* Loose equality for verifier — handles type widening and serialization
|
|
* format differences. Layers fail-fast: identical → numeric → decoded.
|
|
*
|
|
* @param mixed $a Side A value.
|
|
* @param mixed $b Side B value.
|
|
* @return bool
|
|
*/
|
|
private static function values_loose_equal( $a, $b ): bool {
|
|
// 1. Identical strings.
|
|
if ( (string) $a === (string) $b ) {
|
|
return true;
|
|
}
|
|
// 2. Numeric loose match.
|
|
if ( is_numeric( $a ) && is_numeric( $b ) && (float) $a === (float) $b ) {
|
|
return true;
|
|
}
|
|
// 3. Decoded match (serialized vs JSON).
|
|
$decoded_a = self::decode_value( (string) $a );
|
|
$decoded_b = self::decode_value( (string) $b );
|
|
return $decoded_a === $decoded_b;
|
|
}
|
|
|
|
/**
|
|
* Decode a stored value: try unserialize → JSON decode → as-is.
|
|
*
|
|
* @param string $value Raw stored value.
|
|
* @return mixed
|
|
*/
|
|
private static function decode_value( string $value ) {
|
|
// Try unserialize for postmeta-style serialized payloads.
|
|
if ( '' !== $value ) {
|
|
$first_two = substr( $value, 0, 2 );
|
|
if ( 'a:' === $first_two || 'O:' === $first_two || 's:' === $first_two || 'i:' === $first_two || 'b:' === $first_two || 'd:' === $first_two ) {
|
|
$unserialized = @unserialize( $value, array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize,WordPress.PHP.NoSilencedErrors
|
|
if ( false !== $unserialized || 'b:0;' === $value ) {
|
|
return $unserialized;
|
|
}
|
|
}
|
|
}
|
|
// Try JSON for flat-table json-typed columns.
|
|
if ( '' !== $value && ( '{' === $value[0] || '[' === $value[0] ) ) {
|
|
$json = json_decode( $value, true );
|
|
if ( null !== $json ) {
|
|
return $json;
|
|
}
|
|
}
|
|
return $value;
|
|
}
|
|
}
|