Files
2meet-data-optimizer/includes/migration/class-tmdo-migration-orchestrator.php
T
wpdev d36bb954d1 chore: initial snapshot of 2meet-data-optimizer v0.1.0
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
2026-07-31 05:06:36 +08:00

1108 lines
38 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
// phpcs:ignore WPDO.AntiEAV -- platform migration tool: WPDO v1->v2 legacy meta orchestration
/**
* One-click User Entity Migration Orchestrator.
*
* Drives the full demote → backfill → verify → promote → cleanup flow as a
* single state machine. Designed for both small (sync, <30s) and large
* (async via cron, minutes-to-hours) sites with auto-detection.
*
* Fast mode: bulk SQL pivot (INSERT...SELECT...GROUP BY) for text-only groups
* is 10-50× faster than per-row PHP loops. Per-row safe_unserialize fallback
* applies only to groups containing `json` fields (currently only `hp_user`).
*
* @package WP_Data_Optimizer
* @since 2.8.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable Squiz.Commenting.FunctionComment,Squiz.Commenting.VariableComment,Squiz.Commenting.ClassComment,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.Security.EscapeOutput.ExceptionNotEscaped,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- Internal state-machine helpers and Throwable messages — not user output. SQL composed with sanitize_column_name() + $wpdb->prepare() values.
/**
* One-click User Migration Orchestrator — drives the demote → backfill →
* verify → promote → cleanup state machine for the user entity.
*
* @since 2.8.0
*/
final class TMDO_Migration_Orchestrator {
const OPT_JOB = 'wpdo_migration_job';
const OPT_LOCK = 'wpdo_migration_lock';
const TRANS_PROGRESS = 'wpdo_migration_progress';
const TRANS_NEEDS = 'wpdo_migration_needs_attention';
const CRON_HOOK = 'wpdo_migration_tick';
const BACKUP_DIR_REL = 'wpdo-backups';
const MAX_LOG_LINES = 50;
const SYNC_THRESHOLD_SEC = 30;
const SYNC_DEADLINE_SEC = 110;
const VERIFY_SAMPLE_MIN = 500;
const VERIFY_SAMPLE_RATIO = 0.10;
const LOCK_TTL_SEC = 1800;
const NEEDS_TTL_SEC = 300;
const ENTITY_TYPE = 'user';
/**
* Phase order. Each phase is idempotent — re-entering returns ok if
* the desired state is already achieved.
*/
private const PHASES = array(
'diagnose',
'backup',
'demote',
'install_schema',
'backfill_bulk',
'backfill_unserialize',
'promote_shadow',
'verify_sample',
'promote_aeav',
'cleanup',
'completed',
);
// ─────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────
/**
* Read-only inspection — returns what `start()` would do.
*
* @return array{managed_keys:array,eav_rows:int,users:int,usermeta:int,ratio:float,mode:string,groups:array,estimated_strategy:string,estimated_sec:float}
*/
public static function preflight(): array {
global $wpdb;
$managed_keys = self::get_managed_keys();
$eav_rows = self::count_eav_residue( $managed_keys );
$users = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" );
$usermeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->usermeta}" );
$mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
$groups = array();
foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
$keys = TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group );
$has_json = self::group_has_json_field( $group );
$residue = $keys ? self::count_eav_residue( $keys ) : 0;
$flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group );
$flat_rows = TMDO_Schema_Manager::table_exists( $flat_table )
? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" )
: 0;
$groups[ $group ] = array(
'keys' => $keys,
'residue' => $residue,
'flat_rows' => $flat_rows,
'has_json' => $has_json,
'strategy' => $has_json ? 'row_by_row' : 'bulk_pivot',
);
}
// Heuristic: 5ms per bulk row, 80ms per per-row PHP entity.
$bulk_rows = 0;
$row_rows = 0;
foreach ( $groups as $g ) {
if ( $g['has_json'] ) {
$row_rows += $g['residue'];
} else {
$bulk_rows += $g['residue'];
}
}
$estimated_sec = ( $bulk_rows * 0.005 ) + ( $row_rows * 0.080 ) + 2.0;
$strategy = $estimated_sec <= self::SYNC_THRESHOLD_SEC ? 'sync' : 'async';
return array(
'managed_keys' => $managed_keys,
'eav_rows' => $eav_rows,
'users' => $users,
'usermeta' => $usermeta,
'ratio' => $users > 0 ? round( $usermeta / $users, 2 ) : 0,
'mode' => $mode,
'groups' => $groups,
'estimated_strategy' => $strategy,
'estimated_sec' => round( $estimated_sec, 1 ),
);
}
/**
* Cached attention summary for dashboard widget + tab-nav red dot.
*
* Hits a single SELECT against wp_usermeta + an in-process ratio
* calculation; cached for NEEDS_TTL_SEC (5 min) so dashboard widgets
* stay snappy. Bust the cache after migrations or mode changes via
* `bust_attention_cache()`.
*
* @return array{needs:bool,eav_rows:int,groups_with_residue:int,ratio:float,mode:string,job_state:string}
*/
public static function needs_attention(): array {
$cached = get_transient( self::TRANS_NEEDS );
if ( is_array( $cached ) ) {
return $cached;
}
global $wpdb;
$managed_keys = self::get_managed_keys();
$eav_rows = self::count_eav_residue( $managed_keys );
$groups_with_residue = 0;
foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
$keys = TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group );
if ( $keys && self::count_eav_residue( $keys ) > 0 ) {
++$groups_with_residue;
}
}
$users = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" );
$usermeta = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->usermeta}" );
$mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
$job_state = 'idle';
$job = get_option( self::OPT_JOB );
if ( is_array( $job ) && ! empty( $job['state'] ) ) {
$job_state = (string) $job['state'];
}
$result = array(
'needs' => $eav_rows > 0,
'eav_rows' => $eav_rows,
'groups_with_residue' => $groups_with_residue,
'ratio' => $users > 0 ? round( $usermeta / $users, 2 ) : 0,
'mode' => $mode,
'job_state' => $job_state,
);
set_transient( self::TRANS_NEEDS, $result, self::NEEDS_TTL_SEC );
return $result;
}
/**
* Bust the attention cache — call after migrations, mode changes,
* group registrations, etc.
*/
public static function bust_attention_cache(): void {
delete_transient( self::TRANS_NEEDS );
}
/**
* Begin a migration job. If small, runs to completion in this request.
* Otherwise schedules cron-driven ticks and returns immediately.
*
* @param array{verify_strict?:bool,verify_24h?:bool,auto_backup?:bool,force_async?:bool,dry_run?:bool} $options
* @return array{ok:bool,job_id?:string,strategy?:string,error?:string,reason?:string}
*/
public static function start( array $options = array() ): array {
if ( ! self::acquire_lock() ) {
return array(
'ok' => false,
'error' => __( '另一個 migration job 正在執行;請先取消或等候完成。', '2meet-data-optimizer' ),
);
}
$preflight = self::preflight();
// Idempotency: nothing to do.
if ( $preflight['eav_rows'] === 0 && TMDO_Mode_Manager::MODE_AEAV_ONLY === $preflight['mode'] ) {
self::release_lock();
self::bust_attention_cache();
return array(
'ok' => false,
'reason' => 'nothing_to_do',
'error' => sprintf(
/* translators: %s: ratio. */
__( '所有 entity group 已 aeav_only / 0 EAV 殘留。當前 ratio %s。', '2meet-data-optimizer' ),
(string) $preflight['ratio']
),
);
}
$options = wp_parse_args(
$options,
array(
'verify_strict' => true,
'verify_24h' => false,
'auto_backup' => true,
'force_async' => false,
'dry_run' => false,
)
);
$job = array(
'job_id' => 'mig_' . wp_generate_password( 12, false ),
'started_at' => time(),
'updated_at' => time(),
'phase' => 'diagnose',
'phase_index' => 0,
'phase_progress' => 0,
'overall_progress' => 0,
'log' => array(),
'options' => $options,
'metrics' => array(
'mode_start' => $preflight['mode'],
'ratio_start' => $preflight['ratio'],
'ratio_now' => $preflight['ratio'],
'eav_rows_start' => $preflight['eav_rows'],
'eav_rows_now' => $preflight['eav_rows'],
'users' => $preflight['users'],
),
'strategy' => $options['force_async'] ? 'async' : $preflight['estimated_strategy'],
'state' => 'running',
'errors' => array(),
'backup_path' => '',
);
self::log(
$job,
sprintf(
'Job started — strategy=%s, residue=%d rows across %d groups, mode=%s',
$job['strategy'],
$preflight['eav_rows'],
count( array_filter( $preflight['groups'], fn( $g ) => $g['residue'] > 0 ) ),
$preflight['mode']
)
);
self::persist_job( $job );
if ( 'sync' === $job['strategy'] ) {
self::run_sync_loop( $job );
} else {
self::schedule_next_tick();
}
return array(
'ok' => true,
'job_id' => $job['job_id'],
'strategy' => $job['strategy'],
);
}
/**
* Advance one phase. Used by cron and by sync inline loop.
*
* @return array{done:bool,phase:string,error?:string}
* @throws \RuntimeException When a phase callback throws or returns non-ok status; caught internally and converted to an `error` array entry.
*/
public static function tick(): array {
$job = get_option( self::OPT_JOB );
if ( ! is_array( $job ) || empty( $job['job_id'] ) ) {
return array(
'done' => true,
'phase' => 'idle',
'error' => 'No active job',
);
}
if ( 'running' !== ( $job['state'] ?? '' ) ) {
return array(
'done' => true,
'phase' => $job['phase'] ?? 'unknown',
);
}
$current = $job['phase'];
$method = 'phase_' . $current;
try {
if ( ! method_exists( __CLASS__, $method ) ) {
throw new \RuntimeException( "Unknown phase: {$current}" );
}
$result = self::{$method}( $job );
if ( 'in_progress' === ( $result['status'] ?? '' ) ) {
self::persist_job( $job );
if ( 'async' === $job['strategy'] ) {
self::schedule_next_tick();
}
return array(
'done' => false,
'phase' => $current,
);
}
if ( 'ok' !== ( $result['status'] ?? '' ) ) {
throw new \RuntimeException( $result['message'] ?? 'Phase returned non-ok status' );
}
$next = self::next_phase( $current );
self::log( $job, sprintf( '✓ %s (%.2fs)', $current, microtime( true ) - ( $result['_started_at'] ?? microtime( true ) ) ) );
$job['phase'] = $next;
$job['phase_index'] = array_search( $next, self::PHASES, true );
$job['phase_progress'] = 0;
$job['overall_progress'] = (int) round( ( $job['phase_index'] / ( count( self::PHASES ) - 1 ) ) * 100 );
$job['updated_at'] = time();
if ( 'completed' === $next ) {
$job['state'] = 'completed';
$job['completed_at'] = time();
$job['overall_progress'] = 100;
self::log(
$job,
sprintf(
'✅ Migration complete — ratio %s → %s (-%.0f%%)',
$job['metrics']['ratio_start'],
$job['metrics']['ratio_now'],
( $job['metrics']['ratio_start'] - $job['metrics']['ratio_now'] ) / max( $job['metrics']['ratio_start'], 0.01 ) * 100
)
);
self::persist_job( $job );
self::release_lock();
self::bust_attention_cache();
return array(
'done' => true,
'phase' => 'completed',
);
}
self::persist_job( $job );
if ( 'async' === $job['strategy'] ) {
self::schedule_next_tick();
}
return array(
'done' => false,
'phase' => $next,
);
} catch ( \Throwable $e ) {
self::handle_failure( $job, $e );
return array(
'done' => true,
'phase' => 'failed',
'error' => $e->getMessage(),
);
}
}
/**
* Inspection — read-only snapshot for the polling UI.
*/
public static function get_status(): array {
$cached = get_transient( self::TRANS_PROGRESS );
if ( is_array( $cached ) ) {
return $cached;
}
$job = get_option( self::OPT_JOB );
if ( ! is_array( $job ) ) {
return array( 'state' => 'idle' );
}
return self::project_status( $job );
}
public static function cancel(): bool {
$job = get_option( self::OPT_JOB );
if ( ! is_array( $job ) || 'running' !== ( $job['state'] ?? '' ) ) {
return false;
}
// Auto-rollback to safe state (dual_write) before clearing.
try {
$current_mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === $current_mode ) {
TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_SHADOW_READ );
TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_DUAL_WRITE );
} elseif ( TMDO_Mode_Manager::MODE_SHADOW_READ === $current_mode ) {
TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_DUAL_WRITE );
}
} catch ( \Throwable $e ) {
// Logged but non-fatal.
TMDO_Logger::warning( 'migration_cancel_rollback_failed', array( 'error' => $e->getMessage() ) );
}
$job['state'] = 'cancelled';
$job['cancelled_at'] = time();
self::log( $job, '⚠ Cancelled by operator — rolled back to dual_write.' );
self::persist_job( $job );
self::release_lock();
self::bust_attention_cache();
wp_clear_scheduled_hook( self::CRON_HOOK );
return true;
}
public static function resume(): array {
$job = get_option( self::OPT_JOB );
if ( ! is_array( $job ) ) {
return array(
'ok' => false,
'error' => 'No job to resume',
);
}
if ( ! in_array( $job['state'] ?? '', array( 'failed', 'paused' ), true ) ) {
return array(
'ok' => false,
'error' => 'Job is not in a resumable state',
);
}
$job['state'] = 'running';
$job['updated_at'] = time();
self::log( $job, '↻ Resumed by operator.' );
self::persist_job( $job );
self::acquire_lock();
if ( 'sync' === $job['strategy'] ) {
self::run_sync_loop( $job );
} else {
self::schedule_next_tick();
}
return array( 'ok' => true );
}
// ─────────────────────────────────────────────────────────────────────
// Sync inline loop — for sites where total work fits within
// SYNC_DEADLINE_SEC. Heartbeats progress via transient on every phase.
// ─────────────────────────────────────────────────────────────────────
private static function run_sync_loop( array $job ): void {
set_time_limit( self::SYNC_DEADLINE_SEC + 10 );
$deadline = microtime( true ) + self::SYNC_DEADLINE_SEC;
while ( microtime( true ) < $deadline ) {
$result = self::tick();
if ( $result['done'] ) {
return;
}
// Tiny pause to let DB breathe and avoid 100% CPU pegs.
usleep( 5000 );
$job = get_option( self::OPT_JOB );
if ( ! is_array( $job ) || 'running' !== ( $job['state'] ?? '' ) ) {
return;
}
}
// Deadline reached but not done — convert to async.
$job = get_option( self::OPT_JOB );
$job['strategy'] = 'async';
self::log( $job, '⏱ Sync deadline reached — switching to async (cron-driven) for remaining phases.' );
self::persist_job( $job );
self::schedule_next_tick();
}
// ─────────────────────────────────────────────────────────────────────
// Phases — each returns ['status' => 'ok'|'in_progress'|'error', 'message' => string]
// ─────────────────────────────────────────────────────────────────────
private static function phase_diagnose( array &$job ): array {
$preflight = self::preflight();
$job['metrics']['eav_rows_now'] = $preflight['eav_rows'];
$job['metrics']['ratio_now'] = $preflight['ratio'];
self::log(
$job,
sprintf(
'Diagnose: %d EAV residue rows, %d users, ratio %s, mode %s',
$preflight['eav_rows'],
$preflight['users'],
(string) $preflight['ratio'],
$preflight['mode']
)
);
return array(
'status' => 'ok',
'message' => 'Diagnose complete',
);
}
private static function phase_backup( array &$job ): array {
if ( empty( $job['options']['auto_backup'] ) ) {
self::log( $job, '↪ Backup skipped (auto_backup=false)' );
return array( 'status' => 'ok' );
}
$upload_dir = wp_upload_dir();
$backup_dir = trailingslashit( $upload_dir['basedir'] ) . self::BACKUP_DIR_REL;
if ( ! wp_mkdir_p( $backup_dir ) ) {
throw new \RuntimeException( "Cannot create backup directory: {$backup_dir}" );
}
// HTTP-level deny for Apache/IIS. Note: nginx silently ignores .htaccess —
// operators on nginx must add a `location ~ /wp-content/uploads/wpdo-backups/
// { deny all; }` block (logged in admin notice).
$htaccess = $backup_dir . '/.htaccess';
if ( ! file_exists( $htaccess ) ) {
file_put_contents( $htaccess, "Require all denied\n" );
@chmod( $htaccess, 0644 );
}
$webconfig = $backup_dir . '/web.config';
if ( ! file_exists( $webconfig ) ) {
file_put_contents(
$webconfig,
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<configuration><system.webServer><authorization><deny users=\"*\"/></authorization></system.webServer></configuration>\n"
);
@chmod( $webconfig, 0644 );
}
$index = $backup_dir . '/index.php';
if ( ! file_exists( $index ) ) {
file_put_contents( $index, "<?php // Silence is golden.\n" );
@chmod( $index, 0644 );
}
$filename = sprintf(
'wp_usermeta_%s_%s.sql',
gmdate( 'Ymd_His' ),
$job['job_id']
);
$backup_path = $backup_dir . '/' . $filename;
global $wpdb;
$rows = $wpdb->get_results(
"SELECT umeta_id, user_id, meta_key, meta_value FROM {$wpdb->usermeta} ORDER BY umeta_id ASC",
ARRAY_A
);
$fp = fopen( $backup_path, 'wb' );
if ( ! $fp ) {
throw new \RuntimeException( "Cannot open backup file for writing: {$backup_path}" );
}
// Owner-only read/write — backup contains user PII (emails, billing
// addresses, OAuth tokens, session blobs). Default umask leaks to
// other system users on shared hosts.
@chmod( $backup_path, 0600 );
fwrite( $fp, "-- WPDO migration backup of {$wpdb->usermeta}\n" );
fwrite( $fp, '-- Job: ' . $job['job_id'] . "\n" );
fwrite( $fp, '-- Generated: ' . gmdate( 'c' ) . "\n" );
fwrite( $fp, "-- Restore: mysql ... < this_file.sql\n\n" );
fwrite( $fp, "/*!40101 SET NAMES utf8mb4 */;\n" );
fwrite( $fp, "/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;\n" );
fwrite( $fp, "SET FOREIGN_KEY_CHECKS=0;\n" );
fwrite( $fp, "LOCK TABLES `{$wpdb->usermeta}` WRITE;\n" );
$batch = array();
$count = 0;
foreach ( $rows as $row ) {
// UNHEX-encode meta_value: avoids ALL escape edge cases (binary,
// embedded NULs, non-UTF8, sql_mode mismatches at restore time).
// meta_key is %-bound through esc_sql which is sufficient for an
// ASCII-only key universe.
$batch[] = sprintf(
"(%d,%d,'%s',UNHEX('%s'))",
(int) $row['umeta_id'],
(int) $row['user_id'],
esc_sql( (string) $row['meta_key'] ),
bin2hex( (string) ( $row['meta_value'] ?? '' ) )
);
++$count;
if ( count( $batch ) >= 500 ) {
fwrite( $fp, "INSERT INTO `{$wpdb->usermeta}` (umeta_id,user_id,meta_key,meta_value) VALUES\n" . implode( ",\n", $batch ) . ";\n" );
$batch = array();
}
}
if ( $batch ) {
fwrite( $fp, "INSERT INTO `{$wpdb->usermeta}` (umeta_id,user_id,meta_key,meta_value) VALUES\n" . implode( ",\n", $batch ) . ";\n" );
}
fwrite( $fp, "UNLOCK TABLES;\n" );
fwrite( $fp, "SET FOREIGN_KEY_CHECKS=1;\n" );
fwrite( $fp, "/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;\n" );
fclose( $fp );
$job['backup_path'] = $backup_path;
self::log(
$job,
sprintf(
'Backup written: %s (%d rows, %s, chmod 0600)',
$filename,
$count,
size_format( filesize( $backup_path ) )
)
);
// Best-effort web-server detection — log nginx warning so operator
// can add the manual location block (htaccess/web.config don't apply).
$server = isset( $_SERVER['SERVER_SOFTWARE'] ) ? strtolower( sanitize_text_field( wp_unslash( (string) $_SERVER['SERVER_SOFTWARE'] ) ) ) : '';
if ( str_contains( $server, 'nginx' ) ) {
self::log( $job, '⚠ nginx detected: add `location ~ /wp-content/uploads/wpdo-backups/ { deny all; }` to your nginx config — .htaccess does not apply.' );
}
return array( 'status' => 'ok' );
}
private static function phase_demote( array &$job ): array {
$current = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $current ) {
self::log( $job, "↪ Demote skipped (mode is {$current}, not aeav_only)" );
return array( 'status' => 'ok' );
}
// Two-step demotion: aeav_only → shadow_read → dual_write.
// (Safe transition: demotion always allowed.)
$r = TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_SHADOW_READ );
if ( is_wp_error( $r ) ) {
throw new \RuntimeException( 'Demote step 1 failed: ' . $r->get_error_message() );
}
$r = TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_DUAL_WRITE );
if ( is_wp_error( $r ) ) {
throw new \RuntimeException( 'Demote step 2 failed: ' . $r->get_error_message() );
}
self::log( $job, 'Mode: aeav_only → dual_write (reads now go to EAV)' );
return array( 'status' => 'ok' );
}
private static function phase_install_schema( array &$job ): array {
do_action( 'wpdo_register_entity_fields', TMDO_Entity_Registry::class );
TMDO_Schema_Manager::process_pending_migrations();
// Verify all 9 expected tables exist.
$missing = array();
foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
$table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
$missing[] = $group;
}
}
if ( $missing ) {
throw new \RuntimeException( 'Schema migration failed; missing tables: ' . implode( ',', $missing ) );
}
$count = count( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) );
self::log( $job, "Schema migration ok ({$count} flat tables verified)" );
return array( 'status' => 'ok' );
}
private static function phase_backfill_bulk( array &$job ): array {
if ( ! empty( $job['options']['dry_run'] ) ) {
self::log( $job, '↪ Backfill bulk skipped (dry_run)' );
return array( 'status' => 'ok' );
}
$total_groups = 0;
$total_rows = 0;
foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
if ( self::group_has_json_field( $group ) ) {
continue; // handled in phase_backfill_unserialize
}
$rows = self::execute_bulk_pivot( $group );
$total_rows += $rows;
++$total_groups;
self::log( $job, sprintf( ' • bulk pivot %s: %d row(s)', $group, $rows ) );
}
self::log( $job, sprintf( 'Bulk backfill: %d groups, %d rows total', $total_groups, $total_rows ) );
// Update live ratio.
$preflight = self::preflight();
$job['metrics']['eav_rows_now'] = $preflight['eav_rows'];
$job['metrics']['ratio_now'] = $preflight['ratio'];
return array( 'status' => 'ok' );
}
private static function phase_backfill_unserialize( array &$job ): array {
if ( ! empty( $job['options']['dry_run'] ) ) {
self::log( $job, '↪ Backfill unserialize skipped (dry_run)' );
return array( 'status' => 'ok' );
}
$total_rows = 0;
foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
if ( ! self::group_has_json_field( $group ) ) {
continue;
}
$result = TMDO_Entity_Migration_Engine::migrate_group(
self::ENTITY_TYPE,
$group,
array( 'sleep_ms' => 0 )
);
if ( ! empty( $result['error'] ) ) {
throw new \RuntimeException( "Row-by-row backfill {$group} failed: " . $result['error'] );
}
if ( ( $result['errors'] ?? 0 ) > 0 && 0 === ( $result['migrated'] ?? 0 ) ) {
throw new \RuntimeException( "All rows failed during {$group} backfill — see error_log" );
}
$total_rows += (int) ( $result['migrated'] ?? 0 );
self::log( $job, sprintf( ' • row-by-row %s: %d row(s)', $group, $result['migrated'] ?? 0 ) );
}
self::log( $job, sprintf( 'Row-by-row backfill: %d rows total', $total_rows ) );
return array( 'status' => 'ok' );
}
private static function phase_promote_shadow( array &$job ): array {
$current = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
if ( TMDO_Mode_Manager::MODE_DUAL_WRITE === $current ) {
$r = TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_SHADOW_READ );
if ( is_wp_error( $r ) ) {
throw new \RuntimeException( 'Promote to shadow_read failed: ' . $r->get_error_message() );
}
self::log( $job, 'Mode: dual_write → shadow_read (verifying flat reads)' );
} else {
self::log( $job, "↪ Already at {$current}; skip promote_shadow" );
}
return array( 'status' => 'ok' );
}
private static function phase_verify_sample( array &$job ): array {
global $wpdb;
if ( ! empty( $job['options']['verify_24h'] ) ) {
// Operator opted into the 24-hour shadow-read window — pause here.
$elapsed = time() - ( $job['phase_started_at'] ?? time() );
if ( ! isset( $job['phase_started_at'] ) ) {
$job['phase_started_at'] = time();
self::log( $job, '⏸ 24h shadow_read window started — wizard will resume after window elapses.' );
return array( 'status' => 'in_progress' );
}
if ( $elapsed < DAY_IN_SECONDS ) {
return array( 'status' => 'in_progress' );
}
self::log( $job, '⏳ 24h shadow_read window complete; running sample compare' );
}
$strict = ! empty( $job['options']['verify_strict'] );
$users_total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->users}" );
$sample_size = $strict
? (int) max( self::VERIFY_SAMPLE_MIN, ceil( $users_total * self::VERIFY_SAMPLE_RATIO ) )
: 100;
$sample_size = min( $sample_size, $users_total );
$sample_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->users} ORDER BY RAND() LIMIT %d",
$sample_size
)
);
$diffs = 0;
$compared = 0;
$managed_keys = self::get_managed_keys();
foreach ( $sample_ids as $uid ) {
foreach ( $managed_keys as $key ) {
$eav = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta_value FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s LIMIT 1",
$uid,
$key
)
);
// Skip: no EAV source-of-truth to verify against (key already cleaned
// or never existed). The wizard's invariant only flags as DIFF the
// case where EAV has data but flat doesn't match it — that is the
// only real "backfill error".
if ( null === $eav || '' === $eav ) {
continue;
}
++$compared;
$flat = TMDO_Hook_Bus::direct_read( self::ENTITY_TYPE, (int) $uid, $key );
if ( ! self::values_loose_equal( $flat, $eav ) ) {
++$diffs;
if ( $diffs <= 3 ) {
self::log(
$job,
sprintf(
' ! diff uid=%d key=%s flat=%s eav=%s',
$uid,
$key,
is_scalar( $flat ) ? (string) $flat : gettype( $flat ),
is_scalar( $eav ) ? (string) $eav : gettype( $eav )
)
);
}
}
}
}
self::log(
$job,
sprintf(
'Verify: sampled %d users × %d keys = %d compares, %d diff(s)',
count( $sample_ids ),
count( $managed_keys ),
$compared,
$diffs
)
);
if ( $diffs > 0 ) {
throw new \RuntimeException(
sprintf(
'Verification found %d divergence(s) across %d compares — aborting before destructive cleanup.',
$diffs,
$compared
)
);
}
return array( 'status' => 'ok' );
}
private static function phase_promote_aeav( array &$job ): array {
$current = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === $current ) {
self::log( $job, '↪ Already aeav_only' );
return array( 'status' => 'ok' );
}
$r = TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_AEAV_ONLY );
if ( is_wp_error( $r ) ) {
throw new \RuntimeException( 'Promote to aeav_only failed: ' . $r->get_error_message() );
}
self::log( $job, 'Mode: shadow_read → aeav_only (cutover complete)' );
return array( 'status' => 'ok' );
}
private static function phase_cleanup( array &$job ): array {
if ( ! empty( $job['options']['dry_run'] ) ) {
self::log( $job, '↪ Cleanup skipped (dry_run)' );
return array( 'status' => 'ok' );
}
// Hard guard: must be in aeav_only AND backup must exist (if requested).
$mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
throw new \RuntimeException( "Refusing cleanup — mode is {$mode}, must be aeav_only" );
}
if ( ! empty( $job['options']['auto_backup'] ) && empty( $job['backup_path'] ) ) {
throw new \RuntimeException( 'Refusing cleanup — auto_backup requested but no backup_path on record' );
}
global $wpdb;
$keys = self::get_managed_keys();
if ( empty( $keys ) ) {
self::log( $job, '↪ No managed keys to clean' );
return array( 'status' => 'ok' );
}
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
$deleted = (int) $wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->usermeta} WHERE meta_key IN ({$placeholders})",
...$keys
)
);
// Refresh metrics.
$preflight = self::preflight();
$job['metrics']['eav_rows_now'] = $preflight['eav_rows'];
$job['metrics']['ratio_now'] = $preflight['ratio'];
self::log( $job, sprintf( 'Cleanup: deleted %d EAV row(s); ratio now %s', $deleted, (string) $preflight['ratio'] ) );
return array( 'status' => 'ok' );
}
// ─────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────
/** Returns all meta_keys registered as entity fields for `user`. */
private static function get_managed_keys(): array {
$keys = array();
foreach ( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ) as $group ) {
$keys = array_merge( $keys, TMDO_Entity_Registry::get_group_keys( self::ENTITY_TYPE, $group ) );
}
return array_values( array_unique( $keys ) );
}
private static function group_has_json_field( string $group ): bool {
foreach ( TMDO_Entity_Registry::get_group_fields( self::ENTITY_TYPE, $group ) as $field ) {
if ( 'json' === ( $field['type'] ?? '' ) ) {
return true;
}
}
return false;
}
private static function count_eav_residue( array $keys = array() ): int {
global $wpdb;
if ( empty( $keys ) ) {
$keys = self::get_managed_keys();
}
if ( empty( $keys ) ) {
return 0;
}
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key IN ({$placeholders})",
...$keys
)
);
}
/**
* Single-statement bulk pivot for a text-only group.
*
* @param string $group Entity group name.
* @return int Affected rows.
* @throws \RuntimeException If $wpdb->query() fails.
*/
private static function execute_bulk_pivot( string $group ): int {
global $wpdb;
$fields = TMDO_Entity_Registry::get_group_fields( self::ENTITY_TYPE, $group );
if ( empty( $fields ) ) {
return 0;
}
$adapter = TMDO_Entity_Registry::get_adapter( self::ENTITY_TYPE );
$id_col = $adapter->get_entity_id_column();
$table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group );
$select_cases = array();
$update_cols = array();
$col_names = array();
$keys = array();
foreach ( $fields as $f ) {
$key = $f['key'];
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
$keys[] = $key;
$col_names[] = "`{$col}`";
// MAX(CASE WHEN meta_key='...' THEN meta_value END)
$select_cases[] = $wpdb->prepare(
"MAX(CASE WHEN um.meta_key = %s THEN um.meta_value END) AS `{$col}`",
$key
);
$update_cols[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)";
}
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
$sql = sprintf(
'INSERT INTO `%s` (`%s`, %s)
SELECT um.user_id, %s
FROM `%s` um
WHERE um.meta_key IN (%s)
GROUP BY um.user_id
ON DUPLICATE KEY UPDATE %s',
$table,
$id_col,
implode( ', ', $col_names ),
implode( ', ', $select_cases ),
$wpdb->usermeta,
$placeholders,
implode( ', ', $update_cols )
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- pivot SQL composed from registry-validated identifiers + prepared values inside CASE/IN.
$result = $wpdb->query( $wpdb->prepare( $sql, ...$keys ) );
if ( false === $result ) {
throw new \RuntimeException( "Bulk pivot failed for group {$group}: " . $wpdb->last_error );
}
return (int) $result;
}
private static function values_loose_equal( $a, $b ): bool {
if ( null === $a && null === $b ) {
return true;
}
if ( null === $a || null === $b ) {
$other = null === $a ? $b : $a;
return '' === $other || array() === $other || 0 === $other || '0' === $other;
}
if ( is_array( $a ) || is_array( $b ) ) {
return wp_json_encode( $a ) === wp_json_encode( $b );
}
return (string) $a === (string) $b;
}
private static function next_phase( string $current ): string {
$idx = array_search( $current, self::PHASES, true );
if ( false === $idx || $idx + 1 >= count( self::PHASES ) ) {
return 'completed';
}
return self::PHASES[ $idx + 1 ];
}
private static function log( array &$job, string $message ): void {
$line = '[' . gmdate( 'H:i:s' ) . '] ' . $message;
$job['log'][] = $line;
if ( count( $job['log'] ) > self::MAX_LOG_LINES ) {
$job['log'] = array_slice( $job['log'], -self::MAX_LOG_LINES );
}
$job['updated_at'] = time();
}
private static function persist_job( array $job ): void {
update_option( self::OPT_JOB, $job, false );
set_transient( self::TRANS_PROGRESS, self::project_status( $job ), 60 );
}
/** Public-safe projection of job state for the polling UI. */
private static function project_status( array $job ): array {
return array(
'job_id' => $job['job_id'] ?? '',
'state' => $job['state'] ?? 'idle',
'phase' => $job['phase'] ?? 'idle',
'phase_index' => $job['phase_index'] ?? 0,
'phase_total' => count( self::PHASES ) - 1,
'overall_progress' => $job['overall_progress'] ?? 0,
'log' => $job['log'] ?? array(),
'metrics' => $job['metrics'] ?? array(),
'strategy' => $job['strategy'] ?? 'sync',
'started_at' => $job['started_at'] ?? 0,
'updated_at' => $job['updated_at'] ?? 0,
'completed_at' => $job['completed_at'] ?? null,
'cancelled_at' => $job['cancelled_at'] ?? null,
'errors' => $job['errors'] ?? array(),
'backup_path' => isset( $job['backup_path'] ) ? basename( $job['backup_path'] ) : '',
);
}
private static function acquire_lock(): bool {
if ( false === add_option( self::OPT_LOCK, time(), '', false ) ) {
$existing = (int) get_option( self::OPT_LOCK, 0 );
if ( $existing > 0 && time() - $existing > self::LOCK_TTL_SEC ) {
delete_option( self::OPT_LOCK );
return add_option( self::OPT_LOCK, time(), '', false );
}
return false;
}
return true;
}
private static function release_lock(): void {
delete_option( self::OPT_LOCK );
}
private static function schedule_next_tick(): void {
if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
wp_schedule_single_event( time() + 1, self::CRON_HOOK );
}
}
private static function handle_failure( array &$job, \Throwable $e ): void {
$job['state'] = 'failed';
$job['errors'][] = array(
'phase' => $job['phase'] ?? 'unknown',
'message' => $e->getMessage(),
'at' => time(),
);
self::log( $job, sprintf( '✗ %s failed: %s', $job['phase'] ?? '?', $e->getMessage() ) );
// Auto-rollback: only if mode is currently aeav_only AND we haven't reached cleanup yet.
try {
$current = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
$phase = $job['phase'] ?? '';
$pre_cleanup = ! in_array( $phase, array( 'cleanup', 'completed' ), true );
if ( $pre_cleanup && TMDO_Mode_Manager::MODE_AEAV_ONLY === $current ) {
TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_SHADOW_READ );
TMDO_Mode_Manager::set( self::ENTITY_TYPE, TMDO_Mode_Manager::MODE_DUAL_WRITE );
self::log( $job, '↩ Auto-rolled back to dual_write (reads safe)' );
}
} catch ( \Throwable $inner ) {
TMDO_Logger::warning( 'migration_rollback_failed', array( 'error' => $inner->getMessage() ) );
}
TMDO_Logger::warning(
'migration_phase_failed',
array(
'phase' => $job['phase'] ?? '?',
'job' => $job['job_id'] ?? '?',
'error' => $e->getMessage(),
)
);
self::persist_job( $job );
self::release_lock();
self::bust_attention_cache();
}
/**
* Cron callback — wires CRON_HOOK to tick().
*/
public static function cron_tick(): void {
self::tick();
}
}