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
438 lines
13 KiB
PHP
438 lines
13 KiB
PHP
<?php
|
|
// phpcs:ignore WPDO.AntiEAV -- platform shadow verifier: must read raw meta to verify zone correctness
|
|
/**
|
|
* TMDO_Post_Shadow_Verifier — Sample-and-compare flat vs wp_postmeta (v2.10.3).
|
|
*
|
|
* Companion to TMDO_Post_Migration. Used during shadow_read mode to verify
|
|
* the flat tables stay in sync with wp_postmeta. Each sample picks a random
|
|
* post + key, fetches both values (flat row vs wp_postmeta row), and counts
|
|
* matches / diffs / missing rows. Divergent values are written to the
|
|
* shared `wpdo_shadow_diffs` table via `TMDO_Shadow_Diff_Logger` (which is
|
|
* already entity_type-aware — pass 'post' to differentiate from user diffs).
|
|
*
|
|
* Run pattern:
|
|
* - Manual: `wp wpdo post-shadow-report`
|
|
* - Cron: wpdo_post_shadow_verify event registered when post mode is
|
|
* shadow_read; auto-unregistered when mode changes to anything else
|
|
*
|
|
* 🔒 Frozen contract: never touches user-side flat tables, never modifies
|
|
* wp_postmeta or wp_posts (read-only verifier).
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
* @since 2.10.3
|
|
*/
|
|
|
|
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(). Verifier is read-only.
|
|
|
|
/**
|
|
* Sample-and-compare verifier for post entity flat tables.
|
|
*/
|
|
final class TMDO_Post_Shadow_Verifier {
|
|
|
|
/**
|
|
* Cron event hook fired hourly when post mode is shadow_read.
|
|
*/
|
|
public const CRON_HOOK = 'wpdo_post_shadow_verify';
|
|
|
|
/**
|
|
* Default sample size per cron tick (capped to available post count).
|
|
*/
|
|
public const DEFAULT_SAMPLE_SIZE = 100;
|
|
|
|
/**
|
|
* Run a sample-compare pass.
|
|
*
|
|
* Picks $sample_size random posts of $post_type, fetches their flat row
|
|
* + their wp_postmeta values for each $keys entry, and counts matches.
|
|
* Divergences are recorded via TMDO_Shadow_Diff_Logger when the class
|
|
* is available.
|
|
*
|
|
* @param string $post_type WP post_type to sample.
|
|
* @param string $group_name Entity group name (e.g. 'wc_product').
|
|
* @param string $flat_table Fully qualified flat table name.
|
|
* @param string[] $keys Meta keys to compare (each post checks all).
|
|
* @param int $sample_size Number of posts to sample (capped to DB count).
|
|
* @return array{
|
|
* sampled:int,
|
|
* matched:int,
|
|
* diffs:int,
|
|
* missing_flat:int,
|
|
* missing_postmeta:int,
|
|
* group:string,
|
|
* post_type:string,
|
|
* }
|
|
* @throws InvalidArgumentException When inputs invalid.
|
|
*/
|
|
public static function sample_compare(
|
|
string $post_type,
|
|
string $group_name,
|
|
string $flat_table,
|
|
array $keys,
|
|
int $sample_size = self::DEFAULT_SAMPLE_SIZE
|
|
): array {
|
|
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;
|
|
|
|
$post_ids = $wpdb->get_col(
|
|
$wpdb->prepare(
|
|
"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s ORDER BY RAND() LIMIT %d",
|
|
$post_type,
|
|
$sample_size
|
|
)
|
|
);
|
|
|
|
$sampled = count( $post_ids );
|
|
$matched = 0;
|
|
$diffs = 0;
|
|
$missing_flat = 0;
|
|
$missing_postmeta = 0;
|
|
|
|
if ( 0 === $sampled ) {
|
|
return array(
|
|
'sampled' => 0,
|
|
'matched' => 0,
|
|
'diffs' => 0,
|
|
'missing_flat' => 0,
|
|
'missing_postmeta' => 0,
|
|
'group' => $group_name,
|
|
'post_type' => $post_type,
|
|
);
|
|
}
|
|
|
|
foreach ( $post_ids as $post_id ) {
|
|
$post_id = (int) $post_id;
|
|
foreach ( $keys as $key ) {
|
|
$col = self::sanitize_column( $key );
|
|
$flat_val = $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT `{$col}` FROM `{$flat_table}` WHERE post_id = %d LIMIT 1",
|
|
$post_id
|
|
)
|
|
);
|
|
$pm_val = $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1",
|
|
$post_id,
|
|
$key
|
|
)
|
|
);
|
|
|
|
$flat_present = null !== $flat_val && '' !== $flat_val;
|
|
$pm_present = null !== $pm_val && '' !== $pm_val;
|
|
|
|
if ( ! $flat_present && ! $pm_present ) {
|
|
// Both empty — neither side has the value, count as match
|
|
// (key truly absent for this post).
|
|
++$matched;
|
|
continue;
|
|
}
|
|
if ( ! $flat_present && $pm_present ) {
|
|
++$missing_flat;
|
|
self::log_diff( $post_id, $key, (string) $pm_val, '(missing)' );
|
|
continue;
|
|
}
|
|
if ( $flat_present && ! $pm_present ) {
|
|
++$missing_postmeta;
|
|
self::log_diff( $post_id, $key, '(missing)', (string) $flat_val );
|
|
continue;
|
|
}
|
|
|
|
// Loose equality — flat may have widened types (e.g. tinyint→bigint)
|
|
// or full-precision decimals (10,2 → 18,6).
|
|
if ( self::values_loose_equal( $pm_val, $flat_val ) ) {
|
|
++$matched;
|
|
} else {
|
|
++$diffs;
|
|
self::log_diff( $post_id, $key, (string) $pm_val, (string) $flat_val );
|
|
}
|
|
}
|
|
}
|
|
|
|
return array(
|
|
'sampled' => $sampled,
|
|
'matched' => $matched,
|
|
'diffs' => $diffs,
|
|
'missing_flat' => $missing_flat,
|
|
'missing_postmeta' => $missing_postmeta,
|
|
'group' => $group_name,
|
|
'post_type' => $post_type,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Cron handler: runs sample_compare for each registered group when post
|
|
* mode is shadow_read. No-op otherwise.
|
|
*
|
|
* @return void
|
|
*/
|
|
public static function cron_tick(): void {
|
|
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
|
|
return;
|
|
}
|
|
if ( 'shadow_read' !== TMDO_Mode_Manager::get( 'post' ) ) {
|
|
return;
|
|
}
|
|
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
|
return;
|
|
}
|
|
|
|
global $wpdb;
|
|
|
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
|
|
$keys = TMDO_Entity_Registry::get_group_keys( 'post', $group );
|
|
if ( empty( $keys ) ) {
|
|
continue;
|
|
}
|
|
$post_type = self::group_post_type( $group );
|
|
if ( null === $post_type ) {
|
|
continue; // wp_core spans all types, skip in cron tick.
|
|
}
|
|
$flat_table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
|
|
|
|
try {
|
|
self::sample_compare( $post_type, $group, $flat_table, $keys, self::DEFAULT_SAMPLE_SIZE );
|
|
} catch ( \Throwable $e ) {
|
|
if ( class_exists( 'TMDO_Logger' ) ) {
|
|
TMDO_Logger::error(
|
|
'post_shadow_verifier',
|
|
'cron_tick',
|
|
$e->getMessage()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recent diff records for entity_type='post'.
|
|
*
|
|
* @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();
|
|
}
|
|
return TMDO_Shadow_Diff_Logger::recent( $limit, 'post' );
|
|
}
|
|
|
|
/**
|
|
* Aggregate diff stats for entity_type='post' over recent N hours.
|
|
*
|
|
* @param int $hours Window in hours (default 24).
|
|
* @return array{total: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,
|
|
'by_key' => array(),
|
|
);
|
|
}
|
|
|
|
$since = gmdate( 'Y-m-d H:i:s', time() - ( $hours * HOUR_IN_SECONDS ) );
|
|
|
|
$total = (int) $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = 'post' AND ts >= %s",
|
|
$since
|
|
)
|
|
);
|
|
|
|
$rows = $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT meta_key, COUNT(*) AS n FROM `{$table}` WHERE entity_type = 'post' 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' => $total,
|
|
'by_key' => $by_key,
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// 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 );
|
|
}
|
|
|
|
/**
|
|
* Map entity group → primary post_type (null = cross-cutting like wp_core).
|
|
*
|
|
* @param string $group Entity group name.
|
|
* @return string|null
|
|
*/
|
|
private static function group_post_type( string $group ): ?string {
|
|
switch ( $group ) {
|
|
case 'attachment':
|
|
return 'attachment';
|
|
case 'wc_product':
|
|
return 'product';
|
|
case 'hp_listing_core':
|
|
return 'hp_listing';
|
|
case 'hp_request_core':
|
|
return 'hp_request';
|
|
case 'hp_vendor_core':
|
|
return 'hp_vendor';
|
|
case 'nav_menu_item':
|
|
return 'nav_menu_item';
|
|
case 'wp_core':
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loose equality for verifier — handles widened types (decimal precision,
|
|
* tinyint→bigint, etc.) and serialization-format differences without
|
|
* false-positive divergence reports.
|
|
*
|
|
* Layers (fail-fast on first match):
|
|
* 1. Identical strings
|
|
* 2. Numeric loose match (handles "50" vs "50.000000")
|
|
* 3. v2.10.5: Decoded match for serialized vs JSON values
|
|
* (postmeta uses PHP serialize, flat tables use wp_json_encode for
|
|
* json-typed fields — same logical data, different storage format)
|
|
*
|
|
* @param mixed $a Side A value.
|
|
* @param mixed $b Side B value.
|
|
* @return bool
|
|
*/
|
|
private static function values_loose_equal( $a, $b ): bool {
|
|
// Both null/empty already filtered out by caller.
|
|
$as = (string) $a;
|
|
$bs = (string) $b;
|
|
if ( $as === $bs ) {
|
|
return true;
|
|
}
|
|
// Numeric loose match (handles "50" vs "50.000000").
|
|
if ( is_numeric( $as ) && is_numeric( $bs ) ) {
|
|
return (float) $as === (float) $bs;
|
|
}
|
|
// v2.10.5: try decoded comparison for serialized/JSON values.
|
|
$a_decoded = self::decode_value( $as );
|
|
$b_decoded = self::decode_value( $bs );
|
|
if ( ( null !== $a_decoded || null !== $b_decoded ) && $a_decoded === $b_decoded ) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Best-effort decode of a string value: try PHP unserialize (object-safe),
|
|
* then JSON. Returns the decoded structure, or null when neither succeeds
|
|
* (so the caller can short-circuit instead of false-positive matching
|
|
* against a literal "null" string).
|
|
*
|
|
* Returns null in three cases:
|
|
* - Both decoders failed (input is plain non-encoded string)
|
|
* - unserialize returned NULL legitimately (input was 'N;')
|
|
* - Empty string
|
|
*
|
|
* The caller distinguishes these by combining with an identical-string
|
|
* fast-path that runs first; non-encoded strings hit equality before this
|
|
* decoder runs.
|
|
*
|
|
* @param string $s Raw string value.
|
|
* @return mixed|null Decoded value, or null on failure.
|
|
*/
|
|
private static function decode_value( string $s ) {
|
|
if ( '' === $s ) {
|
|
return null;
|
|
}
|
|
// PHP serialize: 'a:N:{...}' / 'O:N:"...":...' / 'i:N;' / 's:N:"..."' / 'b:0|1;' / 'N;' / 'd:N;'.
|
|
if ( preg_match( '/^[aOidsbN]:/', $s ) || 'N;' === $s ) {
|
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors,Generic.PHP.NoSilencedErrors,WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- allowed_classes=false hardens against object injection; @ swallows malformed-payload notices.
|
|
$v = @unserialize( $s, array( 'allowed_classes' => false ) );
|
|
if ( false !== $v || 'b:0;' === $s ) {
|
|
return $v;
|
|
}
|
|
}
|
|
// JSON: arrays/objects start with [ or {.
|
|
$first = $s[0] ?? '';
|
|
if ( '[' === $first || '{' === $first ) {
|
|
$v = json_decode( $s, true );
|
|
if ( null !== $v ) {
|
|
return $v;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Record a divergence via the shared shadow diff logger.
|
|
*
|
|
* @param int $post_id Post ID.
|
|
* @param string $key Meta key.
|
|
* @param string $pm_val Postmeta value (or '(missing)').
|
|
* @param string $flat_val Flat value (or '(missing)').
|
|
* @return void
|
|
*/
|
|
private static function log_diff( int $post_id, string $key, string $pm_val, string $flat_val ): void {
|
|
if ( ! class_exists( 'TMDO_Shadow_Diff_Logger' ) ) {
|
|
return;
|
|
}
|
|
// Use the public record() method if the logger exposes one; otherwise
|
|
// fall through silently. v2.5.x exposes compare_and_log() which does
|
|
// internal compare; for verifier we already know they diverged so we
|
|
// write directly to the table.
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
|
|
$exists = (bool) $wpdb->get_var(
|
|
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
|
);
|
|
if ( ! $exists ) {
|
|
return;
|
|
}
|
|
$wpdb->insert(
|
|
$table,
|
|
array(
|
|
'entity_type' => 'post',
|
|
'entity_id' => $post_id,
|
|
'meta_key' => $key,
|
|
'postmeta_value' => substr( $pm_val, 0, 1000 ),
|
|
'zone_value' => substr( $flat_val, 0, 1000 ),
|
|
'diff_hash' => md5( $pm_val . '|' . $flat_val ),
|
|
'ts' => gmdate( 'Y-m-d H:i:s' ),
|
|
)
|
|
);
|
|
}
|
|
}
|