Files
2meet-data-optimizer/includes/diagnostic/class-tmdo-site-health.php
T
wpdev 1653011f4f feat(2meet-data-optimizer): guard orphaned flat-table groups
- On init:1, re-register aeav_only groups whose flat table exists and whose definition is in wpdo_registry_meta but no active plugin registers them, so their data stays readable (routing only, never DDL)
- Keys owned by a live group, or with rows in native meta (newer data written while orphaned), are left alone; the native-meta check is cached for a day
- Surface restored groups via Site Health (wpdo_orphaned_entity_groups), the admin Doctor tab, and wp wpdo doctor
- Verify: EntityRegistryRestoreTest (9), OrphanGroupRestoreTest (7); core unit 622 / integration 439, HP 141/49, WC 10/22 OK; dry-run on dev20–22 restores 16/17/4 groups, keeps hp_taxonomy hp_sort_order/hp_icon on native
- PR: PR-018
2026-09-25 23:38:12 +08:00

581 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3).
*
* Registers 9 tests under Tools → Site Health → Status:
* 1. wpdo_schema_drift (critical)
* 2. wpdo_error_budget (recommended)
* 3. wpdo_hook_conflicts (recommended)
* 4. wpdo_autoload_bloat (recommended)
* 5. wpdo_postmeta_explosion (recommended)
* 6. wpdo_orphan_zone_rows (recommended)
* 7. wpdo_missing_snapshot (critical)
* 8. wpdo_entity_group_collisions (recommended)
* 9. wpdo_orphaned_entity_groups (recommended)
*
* Each test result is cached for 5 minutes to keep Site Health responsive.
*
* @package WP_Data_Optimizer
*/
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for site health
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Site Health test suite. Hook on `init` admin context.
*/
class TMDO_Site_Health {
/** Transient cache TTL for individual checks. */
private const CACHE_TTL = 300;
/** Test name → callable suffix mapping. */
private const TESTS = array(
'wpdo_schema_drift' => 'check_schema_drift',
'wpdo_error_budget' => 'check_error_budget',
'wpdo_hook_conflicts' => 'check_hook_conflicts',
'wpdo_autoload_bloat' => 'check_autoload_bloat',
'wpdo_postmeta_explosion' => 'check_postmeta_explosion',
'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows',
'wpdo_missing_snapshot' => 'check_missing_snapshot',
'wpdo_entity_group_collisions' => 'check_entity_group_collisions',
'wpdo_orphaned_entity_groups' => 'check_orphaned_entity_groups',
);
/**
* Hook into Site Health.
*
* @return void
*/
public static function register(): void {
add_filter( 'site_status_tests', array( __CLASS__, 'register_tests' ) );
}
/**
* Register the 9 tests with WP Site Health.
*
* @param array $tests Existing tests.
* @return array
*/
public static function register_tests( array $tests ): array {
foreach ( self::TESTS as $key => $cb_suffix ) {
$tests['direct'][ $key ] = array(
'label' => self::label_for( $key ),
'test' => array( __CLASS__, $cb_suffix ),
);
}
return $tests;
}
/**
* Human-readable label for each test.
*
* @param string $key Test slug.
* @return string
*/
private static function label_for( string $key ): string {
$map = array(
'wpdo_schema_drift' => __( 'WPDO schema drift', '2meet-data-optimizer' ),
'wpdo_error_budget' => __( 'WPDO error budget', '2meet-data-optimizer' ),
'wpdo_hook_conflicts' => __( 'WPDO hook conflicts', '2meet-data-optimizer' ),
'wpdo_autoload_bloat' => __( 'WPDO autoload bloat', '2meet-data-optimizer' ),
'wpdo_postmeta_explosion' => __( 'WPDO postmeta explosion', '2meet-data-optimizer' ),
'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ),
'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ),
'wpdo_entity_group_collisions' => __( 'WPDO entity group collisions', '2meet-data-optimizer' ),
'wpdo_orphaned_entity_groups' => __( 'WPDO orphaned entity groups', '2meet-data-optimizer' ),
);
return $map[ $key ] ?? $key;
}
// ─── Test 1: Schema drift ─────────────────────────────────────────────
/**
* Verify all expected v2 tables exist.
*
* @return array Site Health result.
*/
public static function check_schema_drift(): array {
$result = self::cached(
'wpdo_sh_schema_drift',
static function () {
if ( ! class_exists( 'TMDO_Installer' ) ) {
return self::pass( __( 'WPDO installer not loaded.', '2meet-data-optimizer' ) );
}
if ( ! method_exists( 'TMDO_Installer', 'v2_tables_status' ) ) {
return self::pass( __( 'Schema check unavailable on this version.', '2meet-data-optimizer' ) );
}
$status = TMDO_Installer::v2_tables_status();
$missing = array_keys( array_filter( $status, static fn( $exists ) => ! $exists ) );
if ( empty( $missing ) ) {
return self::pass( __( 'All WPDO v2 tables exist.', '2meet-data-optimizer' ) );
}
return self::fail(
__( 'WPDO v2 tables missing', '2meet-data-optimizer' ),
sprintf(
/* translators: %s: comma-separated list of missing table names */
__( 'Missing tables: %s. Run wp wpdo install or re-activate the plugin.', '2meet-data-optimizer' ),
implode( ', ', $missing )
),
'critical'
);
}
);
return self::wrap( 'wpdo_schema_drift', $result );
}
// ─── Test 2: Error budget (last 7 days) ──────────────────────────────
/**
* Count errors in wp_wpdo_errors over the last 7 days.
*
* @return array Site Health result.
*/
public static function check_error_budget(): array {
$result = self::cached(
'wpdo_sh_error_budget',
static function () {
global $wpdb;
$table = $wpdb->prefix . 'wpdo_errors';
$exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$table
)
);
if ( 0 === $exists ) {
return self::pass( __( 'Error log not present (yet) — clean.', '2meet-data-optimizer' ) );
}
$threshold = (int) apply_filters( 'wpdo/site_health/error_budget_threshold', 100 );
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 7 DAY)" ); // phpcs:ignore WordPress.DB
if ( $count <= $threshold ) {
return self::pass(
sprintf(
/* translators: %d: error count */
__( '%d errors in the last 7 days (within budget).', '2meet-data-optimizer' ),
$count
)
);
}
return self::fail(
__( 'WPDO error budget exceeded', '2meet-data-optimizer' ),
sprintf(
/* translators: 1: error count, 2: threshold */
__( '%1$d errors in the last 7 days (threshold %2$d). Inspect under Tools → WP Data Optimizer → Logs.', '2meet-data-optimizer' ),
$count,
$threshold
),
'recommended'
);
}
);
return self::wrap( 'wpdo_error_budget', $result );
}
// ─── Test 3: Hook conflicts ──────────────────────────────────────────
/**
* Check for interceptor or hook conflicts via TMDO_Conflict_Monitor.
*
* @return array Site Health result.
*/
public static function check_hook_conflicts(): array {
$result = self::cached(
'wpdo_sh_hook_conflicts',
static function () {
if ( ! class_exists( 'TMDO_Conflict_Monitor' ) ) {
return self::pass( __( 'Conflict monitor not loaded.', '2meet-data-optimizer' ) );
}
$summary = TMDO_Conflict_Monitor::get_summary();
$total = (int) ( $summary['total'] ?? 0 );
if ( 0 === $total ) {
return self::pass( __( 'No interceptor / hook conflicts detected.', '2meet-data-optimizer' ) );
}
return self::fail(
__( 'WPDO hook conflicts detected', '2meet-data-optimizer' ),
sprintf(
/* translators: %d: number of conflicts */
__( '%d interceptor/hook conflicts detected. Run wp wpdo conflict-scan for details.', '2meet-data-optimizer' ),
$total
),
'recommended'
);
}
);
return self::wrap( 'wpdo_hook_conflicts', $result );
}
// ─── Test 4: Autoload bloat ──────────────────────────────────────────
/**
* Check total autoloaded options size against a configurable threshold.
*
* @return array Site Health result.
*/
public static function check_autoload_bloat(): array {
$result = self::cached(
'wpdo_sh_autoload_bloat',
static function () {
global $wpdb;
$threshold_mb = (int) apply_filters( 'wpdo/site_health/autoload_threshold_mb', 5 );
$bytes = (int) $wpdb->get_var( "SELECT SUM(LENGTH(option_value)) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB
$mb = $bytes / 1024 / 1024;
if ( $mb < $threshold_mb ) {
return self::pass(
sprintf(
/* translators: 1: actual size in MB */
__( 'Autoload total %1$0.2f MB (under %2$d MB threshold).', '2meet-data-optimizer' ),
$mb,
$threshold_mb
)
);
}
return self::fail(
__( 'Autoload size large', '2meet-data-optimizer' ),
sprintf(
/* translators: 1: actual size in MB, 2: threshold in MB */
__( 'Autoload total %1$0.2f MB exceeds %2$d MB threshold. Consider migrating large autoloaded options to wp_wpdo_uni_options.', '2meet-data-optimizer' ),
$mb,
$threshold_mb
),
'recommended'
);
}
);
return self::wrap( 'wpdo_autoload_bloat', $result );
}
// ─── Test 5: Postmeta explosion ──────────────────────────────────────
/**
* Check whether wp_postmeta row count exceeds the explosion threshold.
*
* @return array Site Health result.
*/
public static function check_postmeta_explosion(): array {
$result = self::cached(
'wpdo_sh_postmeta_explosion',
static function () {
global $wpdb;
$threshold = (int) apply_filters( 'wpdo/site_health/postmeta_threshold', 5_000_000 );
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->postmeta}`" ); // phpcs:ignore WordPress.DB
if ( $count < $threshold ) {
return self::pass(
sprintf(
/* translators: %s: row count */
__( 'wp_postmeta has %s rows (under threshold).', '2meet-data-optimizer' ),
number_format_i18n( $count )
)
);
}
// Also check if any zone module is active.
$any_active = false;
if ( class_exists( 'TMDO_Feature_Flags' ) ) {
foreach ( TMDO_Feature_Flags::all() as $state ) {
if ( 'idle' !== $state ) {
$any_active = true;
break;
}
}
}
$severity = $any_active ? 'recommended' : 'critical';
return self::fail(
__( 'wp_postmeta is large', '2meet-data-optimizer' ),
sprintf(
/* translators: %s: row count */
__( 'wp_postmeta has %s rows. Run the Classifier to identify candidates for migration into Hot/Warm/Cold/Archive zones.', '2meet-data-optimizer' ),
number_format_i18n( $count )
),
$severity
);
}
);
return self::wrap( 'wpdo_postmeta_explosion', $result );
}
// ─── Test 6: Orphan zone rows ────────────────────────────────────────
/**
* Detect zone table rows that belong to modules currently in idle state.
*
* @return array Site Health result.
*/
public static function check_orphan_zone_rows(): array {
$result = self::cached(
'wpdo_sh_orphan_zone',
static function () {
global $wpdb;
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
return self::pass( __( 'Feature flags not loaded.', '2meet-data-optimizer' ) );
}
$idle_modules = array_keys( array_filter( TMDO_Feature_Flags::all(), static fn( $state ) => 'idle' === $state ) );
$orphans = array();
foreach ( $idle_modules as $module ) {
// Best-effort: zone tables for hot_*/cold_* are dynamically named.
$candidates = array(
$wpdb->prefix . 'wpdo_warm',
$wpdb->prefix . 'wpdo_archive',
);
foreach ( $candidates as $table ) {
$exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$table
)
);
if ( 0 === $exists ) {
continue;
}
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
if ( $count > 0 ) {
$orphans[ $table ] = $count;
}
}
}
if ( empty( $orphans ) ) {
return self::pass( __( 'No orphan zone rows from idle modules.', '2meet-data-optimizer' ) );
}
$lines = array();
foreach ( $orphans as $t => $n ) {
$lines[] = sprintf( '%s (%s rows)', $t, number_format_i18n( $n ) );
}
return self::fail(
__( 'Orphan zone rows detected', '2meet-data-optimizer' ),
sprintf(
/* translators: %s: list of tables and row counts */
__( 'Modules in idle state but zone tables still hold data: %s. These rows are typically cleanup leftovers — verify before truncating.', '2meet-data-optimizer' ),
implode( ', ', $lines )
),
'recommended'
);
}
);
return self::wrap( 'wpdo_orphan_zone_rows', $result );
}
// ─── Test 7: Missing snapshot ────────────────────────────────────────
/**
* Verify that a recent snapshot exists when modules are in risk states.
*
* @return array Site Health result.
*/
public static function check_missing_snapshot(): array {
$result = self::cached(
'wpdo_sh_missing_snapshot',
static function () {
global $wpdb;
if ( ! class_exists( 'TMDO_Snapshot_Manager' ) || ! class_exists( 'TMDO_Feature_Flags' ) ) {
return self::pass( __( 'Snapshot system not yet available.', '2meet-data-optimizer' ) );
}
// Risk only applies to modules in cutover/cleanup/complete (data is in custom tables).
$risk_modules = array_keys(
array_filter(
TMDO_Feature_Flags::all(),
static fn( $state ) => in_array( $state, array( 'cutover', 'cleanup', 'complete' ), true )
)
);
if ( empty( $risk_modules ) ) {
return self::pass( __( 'No modules in risk state — snapshot not required.', '2meet-data-optimizer' ) );
}
$snap_table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
$exists = (int) $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
$snap_table
)
);
if ( 0 === $exists ) {
return self::fail(
__( 'Snapshot table missing', '2meet-data-optimizer' ),
__( 'Snapshot system table not present. Run wp wpdo install.', '2meet-data-optimizer' ),
'critical'
);
}
$days = (int) apply_filters( 'wpdo/site_health/snapshot_max_age_days', 7 );
$recent = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$snap_table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$snap_table} is $wpdb->prefix + TABLE_SLUG constant (no user input)
$days
)
);
if ( $recent > 0 ) {
return self::pass(
sprintf(
/* translators: 1: count, 2: days */
__( 'Found %1$d snapshot(s) within the last %2$d days.', '2meet-data-optimizer' ),
$recent,
$days
)
);
}
return self::fail(
__( 'No recent WPDO snapshot', '2meet-data-optimizer' ),
sprintf(
/* translators: 1: comma-separated module names, 2: days */
__( 'Modules in risk state (%1$s) but no snapshot in last %2$d days. Run: wp wpdo snapshot create --trigger=manual --notes="catch-up safety net"', '2meet-data-optimizer' ),
implode( ', ', $risk_modules ),
$days
),
'critical'
);
}
);
return self::wrap( 'wpdo_missing_snapshot', $result );
}
// ─── helpers ──────────────────────────────────────────────────────────
// ─── Test 8: Entity group name collisions ───────────────────────────
/**
* Report entity groups registered twice with different definitions.
*
* Not cached: the collision list is in-memory state rebuilt on every
* request, so reading it is free and a transient would only go stale.
*
* @return array Site Health result.
*/
public static function check_entity_group_collisions(): array {
$collisions = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_collisions() : array();
if ( empty( $collisions ) ) {
return self::wrap( 'wpdo_entity_group_collisions', self::pass( __( 'No entity group name collisions.', '2meet-data-optimizer' ) ) );
}
$lines = array();
foreach ( $collisions as $c ) {
$lines[] = sprintf(
'%s/%s (+%s −%s ~%s)',
$c['entity_type'],
$c['group'],
implode( ',', $c['added'] ) ?: '-',
implode( ',', $c['removed'] ) ?: '-',
implode( ',', $c['changed'] ) ?: '-'
);
}
return self::wrap(
'wpdo_entity_group_collisions',
self::fail(
__( 'WPDO entity group name collisions', '2meet-data-optimizer' ),
sprintf(
/* translators: %s: list of entity/group names with field differences */
__( 'Another plugin registered these groups with a different field list; its definition was ignored and the first one kept: %s', '2meet-data-optimizer' ),
implode( '; ', $lines )
),
'recommended'
)
);
}
// ─── Test 9: Orphaned entity groups ─────────────────────────────────
/**
* Report flat-table groups kept alive from wpdo_registry_meta because no
* active plugin registers them any more.
*
* Not cached for the same reason as check_entity_group_collisions().
*
* @return array Site Health result.
*/
public static function check_orphaned_entity_groups(): array {
$restored = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_restored_groups() : array();
if ( empty( $restored ) ) {
return self::wrap( 'wpdo_orphaned_entity_groups', self::pass( __( 'Every flat-table group is registered by an active plugin.', '2meet-data-optimizer' ) ) );
}
$lines = array();
foreach ( $restored as $r ) {
$lines[] = sprintf(
'%s/%s (%d fields%s%s)',
$r['entity_type'],
$r['group'],
$r['fields'],
$r['skipped'] ? ', skipped: ' . implode( ',', $r['skipped'] ) : '',
$r['native'] ? ', left on native meta (newer data there): ' . implode( ',', $r['native'] ) : ''
);
}
return self::wrap(
'wpdo_orphaned_entity_groups',
self::fail(
__( 'WPDO entity groups restored from stored definitions', '2meet-data-optimizer' ),
sprintf(
/* translators: %s: list of entity/group names */
__( 'No active plugin registers these groups, so their stored definitions were re-applied to keep the flat-table data readable. Reactivate the owning plugin, or write the data back to native meta before removing it: %s', '2meet-data-optimizer' ),
implode( '; ', $lines )
),
'recommended'
)
);
}
/**
* Wrap a raw check result with required Site Health envelope fields.
*
* @param string $key Test slug.
* @param array $result Raw result with keys label, status, description, severity.
* @return array
*/
private static function wrap( string $key, array $result ): array {
$result['test'] = $key;
$result['badge'] = array(
'label' => 'WPDO',
'color' => 'blue',
);
return $result;
}
/**
* Cache a callable's return value via transient.
*
* @param string $key Transient key.
* @param callable $producer Callable returning result array.
* @return array
*/
private static function cached( string $key, callable $producer ): array {
$cached = get_transient( $key );
if ( is_array( $cached ) ) {
return $cached;
}
$result = $producer();
set_transient( $key, $result, self::CACHE_TTL );
return $result;
}
/**
* Build a "pass" result envelope.
*
* @param string $description Body text.
* @return array
*/
private static function pass( string $description ): array {
return array(
'label' => __( 'WPDO check passed', '2meet-data-optimizer' ),
'status' => 'good',
'description' => '<p>' . esc_html( $description ) . '</p>',
'severity' => 'good',
'actions' => '',
);
}
/**
* Build a fail / warning result envelope.
*
* @param string $label Test heading.
* @param string $description Body.
* @param string $severity 'critical' | 'recommended'.
* @return array
*/
private static function fail( string $label, string $description, string $severity ): array {
return array(
'label' => $label,
'status' => 'critical' === $severity ? 'critical' : 'recommended',
'description' => '<p>' . esc_html( $description ) . '</p>',
'severity' => $severity,
'actions' => '<p><a href="' . esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ) . '">' . esc_html__( 'Open WPDO admin', '2meet-data-optimizer' ) . '</a></p>',
);
}
}