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
This commit is contained in:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
@@ -0,0 +1,168 @@
<?php
/**
* Zone D (Archive) migration for postmeta from trashed/old posts.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone D (Archive) migration — postmeta from trashed/old posts → wpdo_archive.
*
* Unlike other zone migrations that copy specific registered meta_keys,
* the archive migration sweeps ALL postmeta for posts that meet archival criteria:
* - Post is in 'trash' status
* - Post was last modified more than $days ago (default: 90 days)
*
* Data is optionally gzip-compressed before storage.
*/
class TMDO_Archive_Migration extends TMDO_Migration_Base {
/**
* Minimum age in days for archival.
*
* @var int
*/
private int $days;
/**
* Whether to gzip-compress archived values.
*
* @var bool
*/
private bool $compress;
/**
* Constructor.
*
* @param int $days Minimum age in days for archival.
* @param bool $compress Whether to gzip-compress archived values.
*/
public function __construct( int $days = 90, bool $compress = true ) {
$this->days = $days;
$this->compress = $compress;
}
/**
* Returns the module identifier.
*
* @return string Module name.
*/
public function get_module(): string {
return 'archive';
}
/**
* Returns the zone identifier.
*
* @return string Zone name.
*/
public function get_zone(): string {
return 'archive';
}
/**
* Returns the total number of archive-eligible postmeta rows.
*
* @return int Total row count.
*/
protected function count_source(): int {
global $wpdb;
$cutoff = $this->get_cutoff();
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*)
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_status = 'trash'
AND p.post_modified_gmt < %s",
$cutoff
)
);
}
/**
* Migrates one batch of eligible postmeta rows to the archive table.
*
* @param int $offset Starting row offset.
* @return int Number of rows processed.
*/
protected function migrate_batch( int $offset ): int {
global $wpdb;
$cutoff = $this->get_cutoff();
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_status = 'trash'
AND p.post_modified_gmt < %s
ORDER BY pm.meta_id ASC
LIMIT %d OFFSET %d",
$cutoff,
self::BATCH_SIZE,
$offset
),
ARRAY_A
);
if ( empty( $rows ) ) {
return 0;
}
$entries = array();
foreach ( $rows as $row ) {
$entries[] = array(
'post_id' => $row['post_id'],
'post_type' => $row['post_type'],
'meta_key' => $row['meta_key'],
'meta_value' => $row['meta_value'],
'meta_id' => $row['meta_id'],
);
}
TMDO_Zone_Archive::archive_batch( $entries, $this->compress );
return count( $rows );
}
/**
* Verifies that archive table has rows when eligible source rows exist.
*
* @return bool True if verification passes.
*/
public function verify_counts(): bool {
global $wpdb;
$source = $this->count_source();
$table = TMDO_Zone_Archive::table();
$target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Archive::table()
// Archive may have more rows than current source (trashed posts may have
// been permanently deleted after archival). So we just check target > 0
// when source is 0, or target >= some threshold.
if ( 0 === $source ) {
return true;
}
return $target >= $source;
}
// ── Private helpers ───────────────────────────────────────────────────
/**
* Returns the cutoff datetime string for archival eligibility.
*
* @return string MySQL datetime string.
*/
private function get_cutoff(): string {
return gmdate( 'Y-m-d H:i:s', time() - ( $this->days * DAY_IN_SECONDS ) );
}
}
@@ -0,0 +1,166 @@
<?php
/**
* Zone C (Cold) migration for postmeta to JSON blob cold table.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone C (Cold) migration — postmeta → JSON blob cold table.
*
* For each post, gathers all registered cold meta_keys and stores them
* as a single JSON blob in wpdo_cold_{post_type}.
*/
class TMDO_Cold_Migration extends TMDO_Migration_Base {
/**
* Post type being migrated.
*
* @var string
*/
private string $post_type;
/**
* Constructor.
*
* @param string $post_type Post type slug to migrate.
*/
public function __construct( string $post_type ) {
$this->post_type = $post_type;
}
/**
* Returns the module identifier.
*
* @return string Module name.
*/
public function get_module(): string {
return 'cold_' . sanitize_key( $this->post_type );
}
/**
* Returns the zone identifier.
*
* @return string Zone name.
*/
public function get_zone(): string {
return 'cold';
}
/**
* Returns the total number of posts to migrate.
*
* @return int Total post count.
*/
protected function count_source(): int {
global $wpdb;
$meta_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $this->post_type );
if ( empty( $meta_keys ) ) {
return 0;
}
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
$args = array_merge( array( $this->post_type ), $meta_keys );
// Count distinct posts that have at least one cold field.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta/$wpdb->posts are core properties.
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT pm.post_id)
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})",
...$args
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
}
/**
* Migrates one batch of posts to the cold table.
*
* @param int $offset Starting post offset.
* @return int Number of posts processed.
*/
protected function migrate_batch( int $offset ): int {
global $wpdb;
$meta_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $this->post_type );
if ( empty( $meta_keys ) ) {
return 0;
}
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
// Get batch of distinct post IDs.
$args = array_merge( array( $this->post_type ), $meta_keys, array( self::BATCH_SIZE, $offset ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders/$id_placeholders from array_fill; $wpdb->postmeta/$wpdb->posts are core properties.
$post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT pm.post_id
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})
ORDER BY pm.post_id ASC
LIMIT %d OFFSET %d",
...$args
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
// Ensure cold table exists before any write (even if this batch is empty).
TMDO_Zone_Cold::ensure_table( $this->post_type );
if ( empty( $post_ids ) ) {
return 0;
}
// Fetch all cold meta for these posts.
$id_placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$meta_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_key, meta_value
FROM {$wpdb->postmeta}
WHERE post_id IN ({$id_placeholders}) AND meta_key IN ({$placeholders})",
...array_merge( array_map( 'intval', $post_ids ), $meta_keys )
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
// Group by post_id.
$grouped = array();
foreach ( $meta_rows ?: array() as $row ) {
$grouped[ $row['post_id'] ][ $row['meta_key'] ] = $row['meta_value'];
}
// Write each post's cold data as a JSON blob.
foreach ( $grouped as $pid => $data ) {
TMDO_Zone_Cold::set_many( (int) $pid, $this->post_type, $data );
}
return count( $post_ids );
}
/**
* Verifies that the cold table row count is at least as large as the source.
*
* @return bool True if verification passes.
*/
public function verify_counts(): bool {
global $wpdb;
$source = $this->count_source();
$table = TMDO_Zone_Cold::table( $this->post_type );
$target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Cold::table()
return $target >= $source;
}
}
@@ -0,0 +1,191 @@
<?php
/**
* Zone A (Hot) migration for postmeta to flat-column hot table.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone A (Hot) migration — postmeta → flat-column hot table.
*
* Reads registered hot fields from Schema Registry for a specific post type,
* bulk-copies their postmeta values into wpdo_hot_{post_type}.
*/
class TMDO_Hot_Migration extends TMDO_Migration_Base {
/**
* Post type being migrated.
*
* @var string
*/
private string $post_type;
/**
* Constructor.
*
* @param string $post_type Post type slug to migrate.
*/
public function __construct( string $post_type ) {
$this->post_type = $post_type;
}
/**
* Returns the module identifier.
*
* @return string Module name.
*/
public function get_module(): string {
return 'hot_' . sanitize_key( $this->post_type );
}
/**
* Returns the zone identifier.
*
* @return string Zone name.
*/
public function get_zone(): string {
return 'hot';
}
/**
* Returns the total number of posts to migrate.
*
* @return int Total post count.
*/
protected function count_source(): int {
global $wpdb;
$meta_keys = $this->get_meta_keys();
if ( empty( $meta_keys ) ) {
return 0;
}
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
$args = array_merge( array( $this->post_type ), $meta_keys );
// Count distinct posts that have at least one hot field.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders built from array_fill with %s; $wpdb->postmeta/$wpdb->posts are core properties.
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT pm.post_id)
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})",
...$args
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
}
/**
* Migrates one batch of posts to the hot table.
*
* @param int $offset Starting post offset.
* @return int Number of posts processed.
*/
protected function migrate_batch( int $offset ): int {
global $wpdb;
$meta_keys = $this->get_meta_keys();
if ( empty( $meta_keys ) ) {
return 0;
}
$columns = TMDO_Schema_Registry::instance()->get_hot_columns( $this->post_type );
$key_map = $this->build_key_to_column_map();
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
// Get batch of distinct post IDs.
$args = array_merge( array( $this->post_type ), $meta_keys, array( self::BATCH_SIZE, $offset ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders/$id_placeholders built from array_fill; $wpdb->postmeta/$wpdb->posts are core properties.
$post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT pm.post_id
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})
ORDER BY pm.post_id ASC
LIMIT %d OFFSET %d",
...$args
)
);
if ( empty( $post_ids ) ) {
return 0;
}
// Ensure hot table exists.
TMDO_Zone_Hot::ensure_table( $this->post_type );
// For each post, gather all hot meta and upsert.
$id_placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) );
$meta_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_key, meta_value
FROM {$wpdb->postmeta}
WHERE post_id IN ({$id_placeholders}) AND meta_key IN ({$placeholders})",
...array_merge( array_map( 'intval', $post_ids ), $meta_keys )
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
// Group by post_id.
$grouped = array();
foreach ( $meta_rows ?: array() as $row ) {
$col = $key_map[ $row['meta_key'] ] ?? null;
if ( $col ) {
$grouped[ $row['post_id'] ][ $col ] = $row['meta_value'];
}
}
// Upsert each post's hot data.
foreach ( $grouped as $pid => $data ) {
TMDO_Zone_Hot::set_many( (int) $pid, $this->post_type, $data );
}
return count( $post_ids );
}
/**
* Verifies that the hot table row count is at least as large as the source.
*
* @return bool True if verification passes.
*/
public function verify_counts(): bool {
global $wpdb;
$source = $this->count_source();
$table = TMDO_Zone_Hot::table( $this->post_type );
$target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Hot::table()
return $target >= $source;
}
// ── Private helpers ───────────────────────────────────────────────────
/**
* Get all registered hot meta_keys for this post type.
*/
private function get_meta_keys(): array {
$fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'hot', $this->post_type );
return array_column( $fields, 'meta_key' );
}
/**
* Build meta_key → column_name map.
*/
private function build_key_to_column_map(): array {
$fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'hot', $this->post_type );
$map = array();
foreach ( $fields as $field ) {
$map[ $field['meta_key'] ] = $field['column'];
}
return $map;
}
}
@@ -0,0 +1,287 @@
<?php
/**
* Abstract base for all WPDO data migrations.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Abstract base for all WPDO data migrations.
*
* Each subclass implements:
* - get_module() — module name (TMDO_Feature_Flags key)
* - get_zone() — zone identifier ('' for HPCT modules, 'hot'/'warm'/'cold'/'archive' for zone modules)
* - count_source() — total number of native rows to migrate
* - migrate_batch() — migrate one batch starting at $offset, returns rows processed
* - verify_counts() — returns true when custom table count >= native count
*
* Migration record is tracked in wpdo_migrations table.
* Batch size: 500 rows. Timeout: 28 seconds per run.
*/
abstract class TMDO_Migration_Base {
protected const BATCH_SIZE = 500;
protected const TIMEOUT = 28; // Seconds.
/**
* Returns the module identifier for this migration.
*
* @return string Module name.
*/
abstract public function get_module(): string;
/**
* Zone identifier. Empty string for HPCT-inherited modules.
*/
public function get_zone(): string {
return '';
}
/**
* Returns the total number of source rows to migrate.
*
* @return int Total row count.
*/
abstract protected function count_source(): int;
/**
* Migrate one batch of rows.
*
* @param int $offset Starting row offset.
* @return int Number of rows processed in this batch.
*/
abstract protected function migrate_batch( int $offset ): int;
/**
* Verify that the custom table count is >= native count.
*/
abstract public function verify_counts(): bool;
// ── Migration record helpers ──────────────────────────────────────────
/**
* Retrieves the current migration record from the database.
*
* @return array|null Migration record array, or null if not found.
*/
public function get_record(): ?array {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_migrations' );
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM `{$table}` WHERE module = %s", $this->get_module() ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
ARRAY_A
);
return $row ?: null;
}
/**
* Initialize or reset the migration record.
*/
public function init_record(): void {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_migrations' );
$now = TMDO_DB::now();
$total = $this->count_source();
$existing = $this->get_record();
if ( $existing ) {
$wpdb->update(
$table,
array(
'state' => 'backfill',
'zone' => $this->get_zone(),
'total_rows' => $total,
'processed_rows' => 0,
'last_offset' => 0,
'error_count' => 0,
'started_at' => $now,
'completed_at' => null,
'updated_at' => $now,
),
array( 'module' => $this->get_module() ),
array( '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%s' ),
array( '%s' )
);
} else {
$wpdb->insert(
$table,
array(
'module' => $this->get_module(),
'zone' => $this->get_zone(),
'state' => 'backfill',
'total_rows' => $total,
'processed_rows' => 0,
'last_offset' => 0,
'error_count' => 0,
'started_at' => $now,
'created_at' => $now,
'updated_at' => $now,
),
array( '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%s', '%s', '%s' )
);
}
}
/**
* Resume an existing migration (does NOT reset processed_rows).
*/
public function resume_record(): void {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_migrations' );
$wpdb->update(
$table,
array(
'state' => 'backfill',
'updated_at' => TMDO_DB::now(),
),
array( 'module' => $this->get_module() ),
array( '%s', '%s' ),
array( '%s' )
);
}
/**
* Update progress after each batch.
*
* @param int $processed Number of rows processed in this batch.
* @param int $last_offset Last row offset processed.
* @return void
*/
public function update_progress( int $processed, int $last_offset ): void {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_migrations' );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
$wpdb->query(
$wpdb->prepare(
"UPDATE `{$table}` SET
processed_rows = processed_rows + %d,
last_offset = %d,
updated_at = %s
WHERE module = %s",
$processed,
$last_offset,
TMDO_DB::now(),
$this->get_module()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Mark migration as completed (state → verify).
*/
public function mark_complete(): void {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_migrations' );
$now = TMDO_DB::now();
$wpdb->update(
$table,
array(
'state' => 'verify',
'completed_at' => $now,
'updated_at' => $now,
),
array( 'module' => $this->get_module() ),
array( '%s', '%s', '%s' ),
array( '%s' )
);
}
/**
* Increment error count.
*/
public function increment_errors(): void {
global $wpdb;
$table = TMDO_DB::table( 'wpdo_migrations' );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_DB::table().
$wpdb->query(
$wpdb->prepare(
"UPDATE `{$table}` SET error_count = error_count + 1, updated_at = %s WHERE module = %s",
TMDO_DB::now(),
$this->get_module()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
* Run the migration in batches. Stops after ~28 seconds.
*
* @param bool $resume If true, resumes from last_offset.
* @param callable|null $progress_callback Called after each batch with (processed, total).
* @return bool True if migration completed, false if timed out (resume needed).
*/
public function run( bool $resume = false, ?callable $progress_callback = null ): bool {
// Set feature flag to dual_write before starting.
TMDO_Feature_Flags::set( $this->get_module(), 'dual_write' );
if ( $resume ) {
$record = $this->get_record();
$offset = (int) ( $record['last_offset'] ?? 0 );
$this->resume_record();
} else {
$offset = 0;
$this->init_record();
}
// Advance to backfill state.
TMDO_Feature_Flags::set( $this->get_module(), 'backfill' );
$start = microtime( true );
do {
try {
$batch_count = $this->migrate_batch( $offset );
} catch ( \Throwable $e ) {
$this->increment_errors();
TMDO_Logger::error(
$this->get_module(),
'migrate_batch',
$e->getMessage(),
array(
'offset' => $offset,
'zone' => $this->get_zone(),
)
);
$batch_count = 0;
// Check if too many errors.
$record = $this->get_record();
if ( $record && (int) $record['error_count'] >= 10 ) {
TMDO_Feature_Flags::reset( $this->get_module() );
return false;
}
}
if ( $batch_count > 0 ) {
$this->update_progress( $batch_count, $offset + $batch_count );
$offset += $batch_count;
}
if ( $progress_callback ) {
$record = $this->get_record();
$progress_callback( (int) $record['processed_rows'], (int) $record['total_rows'] );
}
if ( ( microtime( true ) - $start ) > self::TIMEOUT ) {
return false; // Timed out — next run will resume.
}
} while ( $batch_count >= self::BATCH_SIZE );
// Backfill done → move to verify.
$this->mark_complete();
TMDO_Feature_Flags::set( $this->get_module(), 'verify' );
return true;
}
}
@@ -0,0 +1,329 @@
<?php
/**
* Migration Engine for 7-state module lifecycle control.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Migration Engine — 7-state lifecycle controller.
*
* Manages the full migration lifecycle for any module:
* idle → dual_write → backfill → verify → cutover → cleanup → complete
*
* Provides:
* - State transitions with validation
* - Orchestration of backfill + verify + cutover + cleanup steps
* - Rollback to idle from any state
* - CLI and Admin integration points
*
* Each module has a corresponding TMDO_Migration_Base subclass that handles
* the actual data migration logic (batch processing, counting, verifying).
*/
class TMDO_Migration_Engine {
/**
* Valid state transitions.
* Key = current state, value = array of allowed next states.
* 'idle' is always allowed from any state (rollback).
*/
private const TRANSITIONS = array(
'idle' => array( 'dual_write' ),
'dual_write' => array( 'backfill', 'idle' ),
'backfill' => array( 'verify', 'dual_write', 'idle' ),
'verify' => array( 'cutover', 'dual_write', 'idle' ),
'cutover' => array( 'cleanup', 'idle' ),
'cleanup' => array( 'complete', 'idle' ),
'complete' => array( 'idle' ),
);
/**
* Registry of migration class instances, keyed by module name.
*
* @var array<string, TMDO_Migration_Base>
*/
private static array $migrations = array();
/**
* Register a migration class for a module.
*
* @param string $module Module identifier.
* @param TMDO_Migration_Base $migration Migration instance.
* @return void
*/
public static function register( string $module, TMDO_Migration_Base $migration ): void {
self::$migrations[ $module ] = $migration;
}
/**
* Get the migration instance for a module.
*
* @param string $module Module identifier.
* @return TMDO_Migration_Base|null Migration instance, or null if not found.
*/
public static function get_migration( string $module ): ?TMDO_Migration_Base {
return self::$migrations[ $module ] ?? null;
}
/**
* Get all registered migration instances.
*
* @return array<string, TMDO_Migration_Base>
*/
public static function all(): array {
return self::$migrations;
}
// ── State transitions ─────────────────────────────────────────────────
/**
* Check if a state transition is valid.
*
* @param string $module Module identifier.
* @param string $target_state Target state to transition to.
* @return bool True if the transition is allowed.
*/
public static function can_transition( string $module, string $target_state ): bool {
$current = TMDO_Feature_Flags::get( $module );
// Rollback to idle is always allowed.
if ( 'idle' === $target_state ) {
return true;
}
$allowed = self::TRANSITIONS[ $current ] ?? array();
return in_array( $target_state, $allowed, true );
}
/**
* Transition a module to a new state.
*
* @param string $module Module identifier.
* @param string $target_state Target state to transition to.
* @return bool True on success, false if transition is invalid.
*/
public static function transition( string $module, string $target_state ): bool {
if ( ! self::can_transition( $module, $target_state ) ) {
return false;
}
return TMDO_Feature_Flags::set( $module, $target_state );
}
// ── High-level operations ─────────────────────────────────────────────
/**
* Start or resume migration for a module.
*
* Flow: idle → dual_write → backfill (run batches)
*
* @param string $module Module name.
* @param callable|null $progress_callback Called after each batch.
* @return array{status: string, message: string}
*/
public static function migrate( string $module, ?callable $progress_callback = null ): array {
$migration = self::get_migration( $module );
if ( ! $migration ) {
return array(
'status' => 'error',
'message' => "No migration registered for module: {$module}",
);
}
$current = TMDO_Feature_Flags::get( $module );
// Already in backfill — resume.
if ( 'backfill' === $current ) {
$completed = $migration->run( true, $progress_callback );
return array(
'status' => $completed ? 'verify' : 'backfill',
'message' => $completed ? 'Backfill complete. Ready to verify.' : 'Backfill timed out. Resume to continue.',
);
}
// Already past backfill.
if ( in_array( $current, array( 'verify', 'cutover', 'cleanup', 'complete' ), true ) ) {
return array(
'status' => $current,
'message' => "Module is already in state: {$current}.",
);
}
// Start fresh: idle or dual_write → backfill.
if ( ! self::transition( $module, 'dual_write' ) && 'dual_write' !== $current ) {
return array(
'status' => 'error',
'message' => "Cannot start migration from state: {$current}",
);
}
$completed = $migration->run( false, $progress_callback );
return array(
'status' => $completed ? 'verify' : 'backfill',
'message' => $completed ? 'Backfill complete. Ready to verify.' : 'Backfill timed out. Resume to continue.',
);
}
/**
* Verify data consistency for a module.
*
* @param string $module Module identifier.
* @return array{status: string, message: string, verified: bool} Verification result.
*/
public static function verify( string $module ): array {
$migration = self::get_migration( $module );
if ( ! $migration ) {
return array(
'status' => 'error',
'message' => "No migration registered for module: {$module}",
'verified' => false,
);
}
$current = TMDO_Feature_Flags::get( $module );
if ( 'verify' !== $current ) {
return array(
'status' => 'error',
'message' => "Module must be in 'verify' state. Current: {$current}",
'verified' => false,
);
}
$ok = $migration->verify_counts();
if ( ! $ok ) {
// Verification failed — allow retry via dual_write → backfill.
self::transition( $module, 'dual_write' );
return array(
'status' => 'dual_write',
'message' => 'Verification failed. Rolled back to dual_write for retry.',
'verified' => false,
);
}
return array(
'status' => 'verify',
'message' => 'Verification passed. Ready for cutover.',
'verified' => true,
);
}
/**
* Cutover: switch reads to the custom table.
*
* @param string $module Module identifier.
* @return array{status: string, message: string} Operation result.
*/
public static function cutover( string $module ): array {
$current = TMDO_Feature_Flags::get( $module );
if ( 'verify' !== $current ) {
return array(
'status' => 'error',
'message' => "Module must be in 'verify' state. Current: {$current}",
);
}
self::transition( $module, 'cutover' );
return array(
'status' => 'cutover',
'message' => 'Cutover complete. Reads now come from the custom table. Run cleanup when ready.',
);
}
/**
* Rollback: return to idle state from any state.
*
* @param string $module Module identifier.
* @return array{status: string, message: string} Operation result.
*/
public static function rollback( string $module ): array {
$current = TMDO_Feature_Flags::get( $module );
if ( 'idle' === $current ) {
return array(
'status' => 'idle',
'message' => 'Module is already idle.',
);
}
TMDO_Feature_Flags::reset( $module );
return array(
'status' => 'idle',
'message' => "Module rolled back from '{$current}' to idle.",
);
}
/**
* Cleanup: stop writing to native postmeta.
*
* @param string $module Module identifier.
* @return array{status: string, message: string} Operation result.
*/
public static function cleanup( string $module ): array {
$current = TMDO_Feature_Flags::get( $module );
if ( 'cutover' !== $current ) {
return array(
'status' => 'error',
'message' => "Module must be in 'cutover' state. Current: {$current}",
);
}
self::transition( $module, 'cleanup' );
return array(
'status' => 'cleanup',
'message' => 'Cleanup started. Native postmeta writes are stopped. Run enable to complete.',
);
}
/**
* Enable: mark migration fully complete.
*
* @param string $module Module identifier.
* @return array{status: string, message: string} Operation result.
*/
public static function enable( string $module ): array {
$current = TMDO_Feature_Flags::get( $module );
if ( 'cleanup' !== $current ) {
return array(
'status' => 'error',
'message' => "Module must be in 'cleanup' state. Current: {$current}",
);
}
self::transition( $module, 'complete' );
return array(
'status' => 'complete',
'message' => 'Module fully enabled. All reads and writes use the custom table.',
);
}
/**
* Get comprehensive status for a module.
*
* @param string $module Module identifier.
* @return array{module: string, state: string, zone: string, record: ?array} Module status.
*/
public static function status( string $module ): array {
$migration = self::get_migration( $module );
$record = $migration ? $migration->get_record() : null;
return array(
'module' => $module,
'state' => TMDO_Feature_Flags::get( $module ),
'zone' => $migration ? $migration->get_zone() : '',
'record' => $record,
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,628 @@
<?php
// phpcs:ignore WPDO.AntiEAV -- platform migration tool: WPDO v1->v2 legacy post meta migration
/**
* TMDO_Post_Migration — Post entity migration core (v2.9.3).
*
* Independent of the user-side TMDO_Migration_Orchestrator. The user
* orchestrator is intentionally frozen (1105 lines, hardcoded ENTITY_TYPE='user'
* via `self::ENTITY_TYPE` const) — this class implements the equivalent
* post-side flow without touching any user code path.
*
* Phase coverage (compared to user 10-phase orchestrator):
* diagnose ✓ implemented
* backup ✗ skipped — v2.9.0 postmeta-cleanup CLI handles garbage;
* full wp_postmeta backup deferred to v2.9.4 (admin tab
* + DB hook) since wp_postmeta tends to be very large
* (29k+ rows on dev10) and requires streaming approach
* demote ✗ not applicable — post mode starts at 'disabled', no
* aeav_only state to demote from
* install_schema ✗ already done in v2.9.1 by Schema_Manager auto-create
* backfill_bulk ✓ implemented (per-group, by post_type filter)
* backfill_unserialize ✗ deferred to v2.9.4 (json groups: attachment +
* nav_menu_item have only ~200 rows on dev10)
* promote_shadow ✓ implemented (set_mode dual_write → shadow_read)
* verify_sample ✓ implemented (sample-and-compare)
* promote_aeav ✓ implemented (set_mode → aeav_only)
* cleanup ✓ implemented (DELETE managed wp_postmeta keys)
*
* 🔒 v2.9.x frozen contract: this class must NEVER touch wp_usermeta,
* wp_users, or any wp_wpdo_user_* table. All operations target wp_posts /
* wp_postmeta / wp_wpdo_post_*.
*
* @package WP_Data_Optimizer
* @since 2.9.3
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Internal migration: $flat_table goes through Schema_Manager::sanitize_column_name + TMDO_Entity_Registry; $columns_sql/$cases_sql/$update_sql composed from the same sanitized sources; user-controlled values use prepare() placeholders. Multi-statement IN clauses with array_fill('%s') trigger false positives.
/**
* Post entity migration core. Static API mirrors TMDO_Migration_Orchestrator
* for predictability, but each method is post-only.
*/
final class TMDO_Post_Migration {
private const ENTITY_TYPE = 'post';
/**
* Read-only inspection — what's the current post EAV state?
*
* @return array{
* posts:int,
* postmeta:int,
* ratio:float,
* mode:string,
* groups:array<string,array{keys:string[],eav_rows:int,flat_rows:int,post_type:string|null}>
* }
*/
public static function diagnose(): array {
global $wpdb;
$posts_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts}" );
$postmeta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta}" );
$ratio = $posts_count > 0 ? round( $postmeta_count / $posts_count, 2 ) : 0.0;
$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 );
$post_type = self::group_post_type( $group );
$eav_rows = $keys ? self::count_eav_residue( $keys, $post_type ) : 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}`" ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
: 0;
$groups[ $group ] = array(
'keys' => $keys,
'eav_rows' => $eav_rows,
'flat_rows' => $flat_rows,
'post_type' => $post_type,
);
}
return array(
'posts' => $posts_count,
'postmeta' => $postmeta_count,
'ratio' => $ratio,
'mode' => TMDO_Mode_Manager::get( self::ENTITY_TYPE ),
'groups' => $groups,
);
}
/**
* Bulk SQL pivot for one group: select managed keys from wp_postmeta,
* filter by post_type, pivot via MAX(CASE) GROUP BY post_id, UPSERT
* into the group's flat table.
*
* @param string $group_name Entity group name (e.g. 'wc_product').
* @return array{migrated:int,group:string,post_type:string|null}
* @throws InvalidArgumentException When group is not registered.
* @throws RuntimeException When the pivot SQL fails.
*/
public static function backfill_group( string $group_name ): array {
global $wpdb;
$fields = TMDO_Entity_Registry::get_group_fields( self::ENTITY_TYPE, $group_name );
if ( empty( $fields ) ) {
throw new InvalidArgumentException(
'Unknown post entity group: ' . esc_html( $group_name )
);
}
$post_type = self::group_post_type( $group_name );
$flat_table = TMDO_Schema_Manager::get_table_name( self::ENTITY_TYPE, $group_name );
// Introspect target table columns so we only pivot fields that actually
// exist in the flat schema. Defends against partial schema environments
// (e.g. v2.9.3 deployed before Schema_Manager auto-create has run, or
// custom installs that intentionally pruned columns).
$existing_cols = self::get_existing_columns( $flat_table );
if ( empty( $existing_cols ) ) {
throw new RuntimeException(
'Flat table missing or has no columns: ' . esc_html( $flat_table )
);
}
// Build column list and CASE expressions.
// Skip json/textarea types from the bulk pivot — those need row-by-row
// unserialize handling (deferred to v2.9.4 backfill_unserialize phase).
$columns = array();
$cases = array();
$update_parts = array();
foreach ( $fields as $field ) {
$type = $field['type'] ?? 'text';
if ( 'json' === $type ) {
continue;
}
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
if ( ! isset( $existing_cols[ $col ] ) ) {
continue; // Column not present in this table — skip silently.
}
$key = esc_sql( (string) $field['key'] );
$columns[] = "`{$col}`";
$cases[] = "MAX(CASE WHEN meta_key = '{$key}' THEN meta_value END) AS `{$col}`";
$update_parts[] = "`{$col}` = COALESCE(VALUES(`{$col}`), `{$col}`)";
}
if ( empty( $columns ) ) {
return array(
'migrated' => 0,
'group' => $group_name,
'post_type' => $post_type,
);
}
$columns_sql = implode( ', ', $columns );
$cases_sql = implode( ', ', $cases );
$update_sql = implode( ', ', $update_parts );
$post_type_filter = $post_type ? $wpdb->prepare( 'AND p.post_type = %s', $post_type ) : '';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->query(
"INSERT INTO `{$flat_table}` (post_id, {$columns_sql})
SELECT pm.post_id, {$cases_sql}
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE 1=1 {$post_type_filter}
GROUP BY pm.post_id
ON DUPLICATE KEY UPDATE {$update_sql}"
);
if ( false === $rows ) {
throw new RuntimeException(
'backfill_group SQL failed: ' . esc_html( (string) $wpdb->last_error )
);
}
// MySQL ON DUPLICATE KEY UPDATE counts changes as 2 per affected row;
// $rows = 2 * matched if pure update; for our migrate use case we just
// want to confirm the operation completed. Re-count flat table rows
// limited to $post_type for a clean migrated count.
$count_sql = $post_type
? $wpdb->prepare(
"SELECT COUNT(DISTINCT pm.post_id) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type = %s",
$post_type
)
: "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta}";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$migrated = (int) $wpdb->get_var( $count_sql );
return array(
'migrated' => $migrated,
'group' => $group_name,
'post_type' => $post_type,
);
}
/**
* Promote post mode along the safe transition path.
*
* Path: disabled → dual_write → shadow_read → aeav_only.
* Must be invoked separately for each step (caller decides timing).
*
* @param string $target_mode One of TMDO_Mode_Manager::MODE_* constants.
* @return true|WP_Error
*/
public static function set_mode( string $target_mode ) {
return TMDO_Mode_Manager::set( self::ENTITY_TYPE, $target_mode );
}
/**
* Cleanup: DELETE managed keys from wp_postmeta. Only callable when
* post mode is aeav_only — otherwise EAV is still authoritative source.
*
* @return array{deleted:int}
* @throws RuntimeException When mode != aeav_only.
*/
public static function cleanup(): array {
$mode = TMDO_Mode_Manager::get( self::ENTITY_TYPE );
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
throw new RuntimeException(
'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only'
);
}
global $wpdb;
$keys = self::get_managed_keys();
if ( empty( $keys ) ) {
return array( 'deleted' => 0 );
}
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
$deleted = (int) $wpdb->query(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
"DELETE FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})",
...$keys
)
);
return array( 'deleted' => $deleted );
}
/**
* Row-by-row backfill for groups containing json-typed fields (v2.10.4).
*
* Bulk SQL pivot in backfill_group() can't handle json/serialized values
* because the conversion serialize() → wp_json_encode() requires PHP-level
* processing. This method delegates to TMDO_Entity_Migration_Engine which
* already handles safe_unserialize and json encoding row-by-row.
*
* Idempotent — uses checkpoint cursor; safe to re-run.
*
* @param string $group_name Entity group name (e.g. 'attachment', 'nav_menu_item').
* @param array $options Optional: batch_size (default 500), sleep_ms (50),
* resume (true), dry_run (false).
* @return array Engine result tuple including migrated/errors/skipped.
*/
public static function backfill_group_json( string $group_name, array $options = array() ): array {
if ( ! class_exists( 'TMDO_Entity_Migration_Engine' ) ) {
return array( 'error' => 'TMDO_Entity_Migration_Engine class not available' );
}
return TMDO_Entity_Migration_Engine::migrate_group(
self::ENTITY_TYPE,
$group_name,
$options
);
}
// ─────────────────────────────────────────────────────────────────────────
// Legacy zone-table cutover (v2.9.5)
// ─────────────────────────────────────────────────────────────────────────
/**
* Non-destructive copy of a legacy `wpdo_hot_<post_type>` zone table
* into the new `wp_wpdo_post_<group>` flat table.
*
* Copies the intersection of columns (by name), excluding `id` and
* `updated_at` so the flat table manages those itself. Uses ON DUPLICATE
* KEY UPDATE so the operation is idempotent — re-running is safe.
*
* The legacy table is left UNTOUCHED — this is critical for v3.0.0 rollback
* safety. The legacy table is only DROP'd at v3.0.0 release after several
* release cycles of the new flat table being authoritative.
*
* @param string $post_type Post type the legacy table targets.
* @param string $hot_table Fully-qualified legacy table name.
* @param string $flat_table Fully-qualified target flat table name.
* @return array{copied:int,common_columns:string[],post_type:string}
* @throws RuntimeException When tables missing or copy SQL fails.
*/
public static function copy_legacy_hot_table(
string $post_type,
string $hot_table,
string $flat_table
): array {
global $wpdb;
$hot_cols = self::get_existing_columns( $hot_table );
$flat_cols = self::get_existing_columns( $flat_table );
if ( empty( $hot_cols ) ) {
throw new RuntimeException(
'Legacy hot table missing or empty: ' . esc_html( $hot_table )
);
}
if ( empty( $flat_cols ) ) {
throw new RuntimeException(
'Target flat table missing: ' . esc_html( $flat_table )
);
}
// Intersect columns by name, excluding ones the flat table manages itself.
$skip = array( 'id', 'updated_at', 'created_at' );
$common = array();
foreach ( $hot_cols as $name => $_ ) {
if ( in_array( $name, $skip, true ) ) {
continue;
}
if ( ! isset( $flat_cols[ $name ] ) ) {
continue;
}
$common[] = $name;
}
// post_id is the unique key — must always be present.
if ( ! in_array( 'post_id', $common, true ) ) {
throw new RuntimeException(
'Cannot copy: post_id column not present in both tables'
);
}
$cols_quoted = '`' . implode( '`, `', $common ) . '`';
$update_parts = array();
foreach ( $common as $col ) {
if ( 'post_id' === $col ) {
continue;
}
$update_parts[] = "`{$col}` = VALUES(`{$col}`)";
}
$update_sql = implode( ', ', $update_parts );
$rows = $wpdb->query(
"INSERT INTO `{$flat_table}` ({$cols_quoted})
SELECT {$cols_quoted} FROM `{$hot_table}`
ON DUPLICATE KEY UPDATE {$update_sql}"
);
if ( false === $rows ) {
throw new RuntimeException(
'copy_legacy_hot_table SQL failed: ' . esc_html( (string) $wpdb->last_error )
);
}
// MySQL counts UPSERT modified rows differently from inserted rows;
// re-count flat table by post_id intersection for clean number.
$copied = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$flat_table}` f
INNER JOIN `{$hot_table}` h ON h.post_id = f.post_id"
);
return array(
'copied' => $copied,
'common_columns' => $common,
'post_type' => $post_type,
);
}
/**
* Verify legacy cutover by row count + sampled value comparison.
*
* Note: only checks row count + post_id presence. Per-column value
* comparison would require knowing both tables' column types and
* applying lossy/lossless conversion rules — out of scope for v2.9.5
* (the COPY operation itself uses INSERT...SELECT which preserves
* values byte-for-byte where types match).
*
* @param string $hot_table Legacy hot table name.
* @param string $flat_table Target flat table name.
* @return array{hot_rows:int,flat_rows:int,mismatched_rows:int,ok:bool}
*/
public static function verify_legacy_cutover( string $hot_table, string $flat_table ): array {
global $wpdb;
$hot_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$hot_table}`" );
$flat_rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$flat_table}`" );
// Find post_ids in hot but not in flat (i.e. failed copies).
$mismatched = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$hot_table}` h
LEFT JOIN `{$flat_table}` f ON f.post_id = h.post_id
WHERE f.post_id IS NULL"
);
return array(
'hot_rows' => $hot_rows,
'flat_rows' => $flat_rows,
'mismatched_rows' => $mismatched,
'ok' => 0 === $mismatched && $flat_rows >= $hot_rows,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Query Router benchmark (v2.10.2)
// ─────────────────────────────────────────────────────────────────────────
/**
* Compare query latency between wp_postmeta JOIN path and flat-table path
* for a single meta_key/value/compare combination.
*
* Both queries are functionally equivalent but use different storage:
* postmeta path → INNER JOIN wp_postmeta (the legacy WP behavior)
* flat path → INNER JOIN wp_wpdo_post_<group> (the v2.10.1 router target)
*
* Speed-up = postmeta_avg_ms / flat_avg_ms. Higher is better.
*
* @param string $post_type WP post_type to filter by.
* @param string $meta_key Meta key to query.
* @param string $compare Comparison operator (=, !=, <, <=, >, >=, LIKE).
* @param string $value Value to compare against.
* @param string $flat_table Fully qualified flat table name (must contain $meta_key column).
* @param int $samples Number of times to run each query (default 50).
* @return array{
* samples:int,
* postmeta_avg_ms:float,
* flat_avg_ms:float,
* speedup:float,
* postmeta_rows:int,
* flat_rows:int,
* meta_key:string,
* post_type:string,
* }
* @throws InvalidArgumentException When $samples <= 0 or compare invalid.
* @throws RuntimeException When flat table doesn't exist.
*/
public static function benchmark_query(
string $post_type,
string $meta_key,
string $compare,
string $value,
string $flat_table,
int $samples = 50
): array {
if ( $samples <= 0 ) {
$msg = 'Samples must be > 0, got ' . $samples;
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
$allowed_compare = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'LIKE' );
if ( ! in_array( strtoupper( $compare ), $allowed_compare, true ) ) {
$msg = 'Invalid compare: ' . $compare . '. Allowed: ' . implode( ', ', $allowed_compare );
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
// Verify flat table exists.
$existing = self::get_existing_columns( $flat_table );
if ( empty( $existing ) ) {
throw new RuntimeException(
'Benchmark target flat table missing: ' . esc_html( $flat_table )
);
}
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
if ( ! isset( $existing[ $col ] ) ) {
throw new RuntimeException(
'Flat table does not have column for ' . esc_html( $meta_key )
);
}
global $wpdb;
// Build both query templates (parameterized via prepare in the loop).
$pm_sql = $wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID
WHERE p.post_type = %s AND pm.meta_key = %s AND pm.meta_value {$compare} %s",
$post_type,
$meta_key,
$value
);
$flat_sql = $wpdb->prepare(
"SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
INNER JOIN `{$flat_table}` f ON f.post_id = p.ID
WHERE p.post_type = %s AND f.`{$col}` {$compare} %s",
$post_type,
$value
);
// Warm up MySQL query cache so the first run isn't penalized.
$wpdb->get_var( $pm_sql );
$wpdb->get_var( $flat_sql );
$pm_total = 0.0;
$flat_total = 0.0;
$pm_rows = 0;
$flat_rows = 0;
for ( $i = 0; $i < $samples; $i++ ) {
$start = microtime( true );
$pm_rows = (int) $wpdb->get_var( $pm_sql );
$pm_total += microtime( true ) - $start;
$start = microtime( true );
$flat_rows = (int) $wpdb->get_var( $flat_sql );
$flat_total += microtime( true ) - $start;
}
$pm_avg_ms = ( $pm_total / $samples ) * 1000;
$flat_avg_ms = ( $flat_total / $samples ) * 1000;
$speedup = $flat_avg_ms > 0 ? ( $pm_avg_ms / $flat_avg_ms ) : 0.0;
return array(
'samples' => $samples,
'postmeta_avg_ms' => round( $pm_avg_ms, 3 ),
'flat_avg_ms' => round( $flat_avg_ms, 3 ),
'speedup' => round( $speedup, 2 ),
'postmeta_rows' => $pm_rows,
'flat_rows' => $flat_rows,
'meta_key' => $meta_key,
'post_type' => $post_type,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* All meta_keys registered for any post entity group.
*
* @return string[]
*/
public 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 ) );
}
/**
* Map group name to the post_type it targets. The mapping is canonical
* to v2.9.1 group definitions in TMDO_Post_Fields.
*
* Returns null for cross-post_type groups (e.g. '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;
}
}
/**
* Map of column_name → true for every column that exists in $table.
* Returns empty array if table missing.
*
* @param string $table Fully qualified table name.
* @return array<string,bool>
*/
private static function get_existing_columns( string $table ): array {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results( "SHOW COLUMNS FROM `{$table}`", ARRAY_A );
if ( empty( $rows ) ) {
return array();
}
$cols = array();
foreach ( $rows as $r ) {
$cols[ (string) ( $r['Field'] ?? '' ) ] = true;
}
return $cols;
}
/**
* Count wp_postmeta rows matching $keys, optionally filtered by post_type.
*
* @param string[] $keys Meta keys to match.
* @param string|null $post_type Optional post_type filter.
* @return int
*/
private static function count_eav_residue( array $keys, ?string $post_type = null ): int {
global $wpdb;
if ( empty( $keys ) ) {
return 0;
}
$placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) );
if ( $post_type ) {
return (int) $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
"SELECT COUNT(*) FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE p.post_type = %s AND pm.meta_key IN ({$placeholders})",
$post_type,
...$keys
)
);
}
return (int) $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})",
...$keys
)
);
}
}
@@ -0,0 +1,140 @@
<?php
/**
* Zone B (Warm) migration for postmeta to KV warm table.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone B (Warm) migration — postmeta → wpdo_warm KV table with TTL.
*
* Reads registered warm fields from Schema Registry,
* copies their postmeta values into the warm table with optional TTL.
*/
class TMDO_Warm_Migration extends TMDO_Migration_Base {
/**
* Returns the module identifier.
*
* @return string Module name.
*/
public function get_module(): string {
return 'warm';
}
/**
* Returns the zone identifier.
*
* @return string Zone name.
*/
public function get_zone(): string {
return 'warm';
}
/**
* Returns the total number of warm meta rows to migrate.
*
* @return int Total row count.
*/
protected function count_source(): int {
global $wpdb;
$meta_keys = $this->get_meta_keys();
if ( empty( $meta_keys ) ) {
return 0;
}
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta is a core property.
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key IN ({$placeholders})", // phpcs:ignore WPDO.AntiEAV.no-direct-postmeta-select -- Warm migration: postmeta → Zone B path.
...$meta_keys
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
}
/**
* Migrates one batch of warm meta rows.
*
* @param int $offset Starting row offset.
* @return int Number of rows processed.
*/
protected function migrate_batch( int $offset ): int {
global $wpdb;
$meta_keys = $this->get_meta_keys();
if ( empty( $meta_keys ) ) {
return 0;
}
$registry = TMDO_Schema_Registry::instance();
$placeholders = implode( ',', array_fill( 0, count( $meta_keys ), '%s' ) );
$args = array_merge( $meta_keys, array( self::BATCH_SIZE, $offset ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $placeholders from array_fill; $wpdb->postmeta is a core property.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_key, meta_value
FROM {$wpdb->postmeta}
WHERE meta_key IN ({$placeholders})
ORDER BY meta_id ASC
LIMIT %d OFFSET %d",
...$args
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
if ( empty( $rows ) ) {
return 0;
}
foreach ( $rows as $row ) {
$field = $registry->get_warm_field( $row['meta_key'] );
$ttl = $field['ttl'] ?? null;
TMDO_Zone_Warm::set(
(int) $row['post_id'],
$row['meta_key'],
$row['meta_value'],
$ttl
);
}
return count( $rows );
}
/**
* Verifies that the warm table row count is at least as large as the source.
*
* @return bool True if verification passes.
*/
public function verify_counts(): bool {
global $wpdb;
$source = $this->count_source();
$table = TMDO_Zone_Warm::table();
$target = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_Zone_Warm::table()
return $target >= $source;
}
// ── Private helpers ───────────────────────────────────────────────────
/**
* Get all registered warm meta_keys.
*
* @return array List of meta key strings.
*/
private function get_meta_keys(): array {
$fields = TMDO_Schema_Registry::instance()->get_zone_fields( 'warm' );
return array_column( $fields, 'meta_key' );
}
}