Files
2meet-data-optimizer/includes/class-tmdo-post-stress-tester.php
T
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入
並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:13:33 +08:00

1046 lines
35 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
declare(strict_types=1);
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
/**
* TMDO_Post_Stress_Tester — Bulk fixture generator for post entity migration (v2.9.4).
*
* Companion to TMDO_User_Stress_Tester but post-only. v2.9.4 ships the
* minimum API needed to validate v2.9.5/v2.9.6 end-to-end migrations:
*
* create($post_type, $count) → bulk INSERT N posts + per-post wp_postmeta
* count_test_posts() → number of stress posts currently present
* cleanup() → DELETE all stress posts + cascading postmeta
* + flat table rows (when present)
*
* Test posts use post_title prefix TMDO_STRESS_TEST_ for unambiguous
* identification — cleanup will never touch real production posts even
* if titles happen to overlap by chance.
*
* Cron pump / progress polling / live benchmark UI from the user stress
* tester are intentionally OUT OF SCOPE — v2.9.4 needs deterministic
* fixture generation, not interactive load tests.
*
* 🔒 v2.9.x frozen contract: never touches wp_users / wp_usermeta /
* wp_wpdo_user_*.
*
* @package WP_Data_Optimizer
* @since 2.9.4
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Internal stress fixture: $wpdb->posts/postmeta are WP-managed; meta_key strings are static class constants; user-controlled values use prepare() placeholders.
/**
* Bulk fixture generator for post entity stress tests.
*/
final class TMDO_Post_Stress_Tester {
/** Prefix for stress test post titles — used for cleanup matching. */
public const TEST_POST_PREFIX = 'TMDO_STRESS_TEST_';
/** Hard cap to prevent runaway create() calls. */
private const MAX_COUNT = 100000;
// v2.11.4 state machine constants (mirrors TMDO_User_Stress_Tester).
public const OPT_STATE = 'wpdo_post_stress_test_state';
public const CRON_HOOK = 'wpdo_post_stress_test_batch';
public const CANCEL_FLAG = 'wpdo_post_stress_cancel_flag';
public const DEFAULT_BATCH_SIZE = 200;
public const MAX_BATCH_SIZE = 1000;
public const MIN_BATCH_DELAY_SEC = 1;
public const BATCH_DEADLINE_SEC = 8;
public const MODE_FAST = 'fast';
public const MODE_REALISTIC = 'realistic';
/**
* Per-post_type seed map: meta_key → value generator (closure or static value).
* Static values are sufficient for fixture purposes; randomization belongs in
* the user-side realistic_mode benchmarks (out of scope for v2.9.4).
*
* @var array<string,array<string,mixed>>|null
*/
private static ?array $seed_map_cache = null;
// ─────────────────────────────────────────────────────────────────────────
// Public API
// ─────────────────────────────────────────────────────────────────────────
/**
* Bulk-create N stress test posts of the given post_type, plus their
* canonical postmeta rows (matching the v2.9.1 entity group definitions).
*
* @param string $post_type One of: product, hp_listing, hp_request, hp_vendor,
* attachment, nav_menu_item, post.
* @param int $count Number of posts to insert. Capped at MAX_COUNT.
* @return array{created:int,post_type:string,first_id:int|null,last_id:int|null}
* @throws InvalidArgumentException When post_type unsupported or $count out of range.
*/
public static function create( string $post_type, int $count ): array {
$seed_map = self::seed_map();
if ( ! isset( $seed_map[ $post_type ] ) ) {
throw new InvalidArgumentException(
'Unsupported post_type for stress test: ' . esc_html( $post_type )
. '. Supported: ' . esc_html( implode( ', ', array_keys( $seed_map ) ) )
);
}
if ( $count <= 0 ) {
throw new InvalidArgumentException( 'Count must be > 0.' );
}
if ( $count > self::MAX_COUNT ) {
$msg = 'Count exceeds MAX_COUNT (' . self::MAX_COUNT . '). Use multiple smaller batches.';
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
global $wpdb;
$now = current_time( 'mysql' );
$now_gmt = current_time( 'mysql', true );
$first_id = null;
$last_id = null;
// Insert posts one-by-one — bulk INSERT would require shared insert_id
// gymnastics for the postmeta foreign-key relationship. For fixture
// volumes (target ≤ 10k), per-row insert is fine and keeps the code
// straightforward.
for ( $i = 0; $i < $count; $i++ ) {
$title = self::TEST_POST_PREFIX . $post_type . '_' . wp_generate_password( 8, false );
$ok = $wpdb->insert(
$wpdb->posts,
array(
'post_title' => $title,
'post_type' => $post_type,
'post_status' => 'publish',
'post_date' => $now,
'post_date_gmt' => $now_gmt,
'post_modified' => $now,
'post_modified_gmt' => $now_gmt,
'post_content' => '',
'post_excerpt' => '',
'post_content_filtered' => '',
'to_ping' => '',
'pinged' => '',
'post_name' => sanitize_title( $title ),
'guid' => '',
)
);
if ( ! $ok ) {
continue;
}
$post_id = (int) $wpdb->insert_id;
if ( null === $first_id ) {
$first_id = $post_id;
}
$last_id = $post_id;
// Seed canonical meta keys for this post_type.
foreach ( $seed_map[ $post_type ] as $meta_key => $value_spec ) {
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
$wpdb->insert(
$wpdb->postmeta,
array(
'post_id' => $post_id,
'meta_key' => $meta_key,
'meta_value' => (string) $value,
)
);
}
}
return array(
'created' => $count,
'post_type' => $post_type,
'first_id' => $first_id,
'last_id' => $last_id,
);
}
/**
* Realistic-mode counterpart of create() (v2.11.2) — uses wp_insert_post()
* and update_post_meta() instead of direct $wpdb->insert. Exercises the
* full WP filter chain so Hook Bus interception (when post mode is
* dual_write or higher) is naturally triggered.
*
* Use this mode when:
* - Validating production write path (mode=dual_write+ → flat tables auto-fill)
* - Benchmarking realistic insert latency vs fast-path
* - Stress-testing the Hook Bus + Sync_Bridge guard for post entity
*
* @param string $post_type One of: product, hp_listing, hp_request, hp_vendor,
* attachment, nav_menu_item, post.
* @param int $count Number of posts to insert. Capped at MAX_COUNT.
* @return array{created:int,post_type:string,mode:string,first_id:int|null,last_id:int|null}
* @throws InvalidArgumentException When post_type unsupported or $count out of range.
*/
public static function create_realistic( string $post_type, int $count ): array {
$seed_map = self::seed_map();
if ( ! isset( $seed_map[ $post_type ] ) ) {
throw new InvalidArgumentException(
'Unsupported post_type for stress test: ' . esc_html( $post_type )
. '. Supported: ' . esc_html( implode( ', ', array_keys( $seed_map ) ) )
);
}
if ( $count <= 0 ) {
throw new InvalidArgumentException( 'Count must be > 0.' );
}
if ( $count > self::MAX_COUNT ) {
$msg = 'Count exceeds MAX_COUNT (' . self::MAX_COUNT . '). Use multiple smaller batches.';
throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
$first_id = null;
$last_id = null;
for ( $i = 0; $i < $count; $i++ ) {
$title = self::TEST_POST_PREFIX . $post_type . '_' . wp_generate_password( 8, false );
$post_id = wp_insert_post(
array(
'post_title' => $title,
'post_type' => $post_type,
'post_status' => 'publish',
)
);
if ( ! $post_id || is_wp_error( $post_id ) ) {
continue;
}
$post_id = (int) $post_id;
if ( null === $first_id ) {
$first_id = $post_id;
}
$last_id = $post_id;
// Use update_post_meta() so WP fires update_post_metadata filter →
// Hook Bus intercepts when post mode is dual_write or higher.
foreach ( $seed_map[ $post_type ] as $meta_key => $value_spec ) {
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
update_post_meta( $post_id, $meta_key, $value );
}
}
return array(
'created' => $count,
'post_type' => $post_type,
'mode' => 'realistic',
'first_id' => $first_id,
'last_id' => $last_id,
);
}
/**
* Count posts matching the stress-test title prefix.
*
* @return int
*/
public static function count_test_posts(): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_title LIKE %s",
$wpdb->esc_like( self::TEST_POST_PREFIX ) . '%'
)
);
}
/**
* Delete every stress-test post + its postmeta + any flat-table rows
* still keyed to those post IDs.
*
* @return array{deleted_posts:int,deleted_meta:int,deleted_flat_rows:int}
*/
public static function cleanup(): array {
global $wpdb;
$post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts} WHERE post_title LIKE %s",
$wpdb->esc_like( self::TEST_POST_PREFIX ) . '%'
)
);
if ( empty( $post_ids ) ) {
return array(
'deleted_posts' => 0,
'deleted_meta' => 0,
'deleted_flat_rows' => 0,
);
}
$id_list = implode( ',', array_map( 'absint', $post_ids ) );
// Delete flat rows first (best-effort — tables may not exist yet).
$flat_deleted = 0;
foreach ( self::get_post_flat_tables() as $tbl ) {
$exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $tbl )
);
if ( ! $exists ) {
continue;
}
$rows = (int) $wpdb->query( "DELETE FROM `{$tbl}` WHERE post_id IN ({$id_list})" );
$flat_deleted += $rows;
}
// Delete postmeta rows for these post IDs.
$meta_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->postmeta} WHERE post_id IN ({$id_list})" );
// Finally delete the posts.
$post_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->posts} WHERE ID IN ({$id_list})" );
return array(
'deleted_posts' => $post_deleted,
'deleted_meta' => $meta_deleted,
'deleted_flat_rows' => $flat_deleted,
);
}
// ─────────────────────────────────────────────────────────────────────────
// v2.11.4 — State machine (cron pump + progress polling), mirrors User side
// ─────────────────────────────────────────────────────────────────────────
/**
* Start an async stress run.
*
* Persists state in `wpdo_post_stress_test_state` and schedules the first
* batch via wp_schedule_single_event(). The first batch does NOT run sync —
* it is pushed by the cron event or by `pump_if_due()` on the next polling
* request.
*
* @param string $post_type One of seed_map() keys.
* @param int $target Total posts to create (1..MAX_COUNT).
* @param string $mode MODE_FAST | MODE_REALISTIC.
* @param int $batch_size Per-batch insert count (1..MAX_BATCH_SIZE).
* @return array{ok:bool,error?:string,state?:array}
*/
public static function start( string $post_type, int $target, string $mode = self::MODE_FAST, int $batch_size = self::DEFAULT_BATCH_SIZE ): array {
$seed_map = self::seed_map();
if ( ! isset( $seed_map[ $post_type ] ) ) {
return array(
'ok' => false,
'error' => 'unsupported_post_type',
);
}
if ( $target < 1 ) {
return array(
'ok' => false,
'error' => 'target must be >= 1',
);
}
if ( $target > self::MAX_COUNT ) {
return array(
'ok' => false,
'error' => 'target too large (max ' . self::MAX_COUNT . ')',
);
}
if ( ! in_array( $mode, array( self::MODE_FAST, self::MODE_REALISTIC ), true ) ) {
return array(
'ok' => false,
'error' => 'invalid mode',
);
}
$batch_size = max( 1, min( self::MAX_BATCH_SIZE, $batch_size ) );
$current = self::get_state();
if ( ! empty( $current['status'] ) && 'running' === $current['status'] ) {
return array(
'ok' => false,
'error' => 'already_running',
'state' => $current,
);
}
$state = array(
'job_id' => uniqid( 'pstress_', true ),
'status' => 'running',
'mode' => $mode,
'post_type' => $post_type,
'target' => $target,
'batch_size' => $batch_size,
'started_at' => time(),
'processed' => 0,
'batches_done' => 0,
'batches_log' => array(),
'errors' => array(),
'peak_memory' => 0,
'completed_at' => null,
'last_pushed_at' => 0,
'benchmark' => null,
);
update_option( self::OPT_STATE, $state, false );
// 清掉前次留下的 cancellation flag, avoid 新測試啟動被誤判為已取消.
delete_transient( self::CANCEL_FLAG );
wp_clear_scheduled_hook( self::CRON_HOOK );
wp_schedule_single_event( time(), self::CRON_HOOK );
return array(
'ok' => true,
'state' => $state,
);
}
/**
* Cancel a running stress test. Sets cancellation flag for in-flight batch
* to detect on next iteration; cron events are cleared.
*
* @return array{ok:bool,state?:array,message?:string}
*/
public static function cancel(): array {
$state = self::get_state();
if ( empty( $state ) ) {
return array(
'ok' => true,
'message' => 'no_active_job',
);
}
set_transient( self::CANCEL_FLAG, 1, 600 );
wp_clear_scheduled_hook( self::CRON_HOOK );
$state = self::get_state();
$state['status'] = 'cancelled';
$state['completed_at'] = time();
update_option( self::OPT_STATE, $state, false );
return array(
'ok' => true,
'state' => $state,
);
}
/**
* Read raw state from option storage.
*
* @return array
*/
public static function get_state(): array {
$state = get_option( self::OPT_STATE, array() );
return is_array( $state ) ? $state : array();
}
/**
* Get progress with computed pct/rate/ETA.
*
* @param bool $pump When true, opportunistically advance one batch if cron
* is overdue. The REST status endpoint passes true so
* admin polling makes progress without an external
* cron worker (dev / low-traffic environments).
* @return array
*/
public static function get_progress( bool $pump = true ): array {
if ( $pump ) {
self::pump_if_due();
}
$state = self::get_state();
if ( empty( $state ) ) {
return array(
'status' => 'idle',
'processed' => 0,
'target' => 0,
'pct' => 0,
);
}
$processed = (int) ( $state['processed'] ?? 0 );
$target = (int) ( $state['target'] ?? 0 );
$started = (int) ( $state['started_at'] ?? 0 );
$ended = (int) ( $state['completed_at'] ?? 0 );
$now = $ended > 0 ? $ended : time();
$elapsed = max( 1, $now - $started );
$rate = $processed > 0 ? round( $processed / $elapsed, 1 ) : 0;
$eta_sec = ( $rate > 0 && $processed < $target ) ? (int) ceil( ( $target - $processed ) / $rate ) : 0;
$pct = $target > 0 ? round( ( $processed / $target ) * 100, 1 ) : 0;
return array_merge(
$state,
array(
'pct' => $pct,
'rate_per_sec' => $rate,
'elapsed_sec' => $elapsed,
'eta_sec' => $eta_sec,
'test_post_count' => self::count_test_posts(),
)
);
}
/**
* Opportunistic pump. Triggered by status polling. Holds a transient lock
* so concurrent polls don't double-pump.
*
* @return void
*/
public static function pump_if_due(): void {
$state = self::get_state();
if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) {
return;
}
$last_pushed_at = (int) ( $state['last_pushed_at'] ?? $state['started_at'] ?? 0 );
if ( time() - $last_pushed_at < 1 ) {
return;
}
$lock_key = 'wpdo_post_stress_pump_lock';
if ( false !== get_transient( $lock_key ) ) {
return;
}
set_transient( $lock_key, 1, 30 );
if ( function_exists( 'set_time_limit' ) ) {
@set_time_limit( self::BATCH_DEADLINE_SEC + 10 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
}
try {
self::run_batch();
} finally {
delete_transient( $lock_key );
}
}
/**
* Cron entrypoint. Run a single batch then either reschedule or finalize.
*
* @return void
*/
public static function run_batch(): void {
$state = self::get_state();
if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) {
return;
}
if ( false !== get_transient( self::CANCEL_FLAG ) ) {
return;
}
$post_type = (string) ( $state['post_type'] ?? '' );
$target = (int) ( $state['target'] ?? 0 );
$processed = (int) ( $state['processed'] ?? 0 );
$batch_size = (int) ( $state['batch_size'] ?? self::DEFAULT_BATCH_SIZE );
$mode = (string) ( $state['mode'] ?? self::MODE_FAST );
$remaining = $target - $processed;
if ( $remaining <= 0 ) {
self::finalize( $state );
return;
}
$this_batch_size = min( $batch_size, $remaining );
$batch_started = microtime( true );
try {
if ( self::MODE_FAST === $mode ) {
$inserted = self::run_batch_fast( $post_type, $this_batch_size );
} else {
$inserted = self::run_batch_realistic( $post_type, $this_batch_size );
}
} catch ( \Throwable $e ) {
$state['errors'][] = array(
'time' => time(),
'message' => $e->getMessage(),
);
$state['status'] = 'failed';
$state['completed_at'] = time();
update_option( self::OPT_STATE, $state, false );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::error( 'stress_test', 'post_batch_failed', $e->getMessage() );
}
return;
}
$batch_elapsed = microtime( true ) - $batch_started;
// 重讀 state, cancel() 可能在 batch 執行中改了 status.
$latest = self::get_state();
if ( empty( $latest ) ) {
return;
}
$is_cancelled = ( 'cancelled' === ( $latest['status'] ?? '' ) ) || false !== get_transient( self::CANCEL_FLAG );
$latest['processed'] = ( (int) ( $latest['processed'] ?? 0 ) ) + $inserted;
$latest['batches_done'] = ( (int) ( $latest['batches_done'] ?? 0 ) ) + 1;
$latest['batches_log'][] = array(
'n' => $inserted,
'duration_ms' => (int) round( $batch_elapsed * 1000 ),
);
if ( count( $latest['batches_log'] ) > 200 ) {
$latest['batches_log'] = array_slice( $latest['batches_log'], -200 );
}
$latest['peak_memory'] = max( (int) ( $latest['peak_memory'] ?? 0 ), (int) memory_get_peak_usage( true ) );
$latest['last_pushed_at'] = time();
if ( $is_cancelled ) {
update_option( self::OPT_STATE, $latest, false );
return;
}
update_option( self::OPT_STATE, $latest, false );
if ( $latest['processed'] >= $target ) {
self::finalize( $latest );
return;
}
wp_schedule_single_event( time() + self::MIN_BATCH_DELAY_SEC, self::CRON_HOOK );
}
/**
* Run one fast-mode batch. Delegates to existing create() to share insert
* logic, then returns the count actually inserted.
*
* @param string $post_type Post type (already validated by start()).
* @param int $count Posts to insert this batch.
* @return int Inserted count.
*/
private static function run_batch_fast( string $post_type, int $count ): int {
$result = self::create( $post_type, $count );
return (int) ( $result['created'] ?? 0 );
}
/**
* Run one realistic-mode batch. Honors BATCH_DEADLINE_SEC and the cancel
* flag — checked between each post insert so cancel takes effect within
* one wp_insert_post() call.
*
* @param string $post_type Post type (already validated by start()).
* @param int $count Posts to insert this batch.
* @return int Inserted count (may be < $count if deadline / cancel hit).
*/
private static function run_batch_realistic( string $post_type, int $count ): int {
$deadline = microtime( true ) + self::BATCH_DEADLINE_SEC;
$inserted = 0;
$seed_map = self::seed_map();
if ( ! isset( $seed_map[ $post_type ] ) ) {
return 0;
}
for ( $i = 0; $i < $count; $i++ ) {
if ( microtime( true ) > $deadline ) {
break;
}
if ( false !== get_transient( self::CANCEL_FLAG ) ) {
break;
}
$title = self::TEST_POST_PREFIX . $post_type . '_' . wp_generate_password( 8, false );
$pid = wp_insert_post(
array(
'post_title' => $title,
'post_type' => $post_type,
'post_status' => 'publish',
)
);
if ( ! $pid || is_wp_error( $pid ) ) {
continue;
}
$pid = (int) $pid;
++$inserted;
foreach ( $seed_map[ $post_type ] as $meta_key => $value_spec ) {
$value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec;
update_post_meta( $pid, $meta_key, $value );
}
}
return $inserted;
}
/**
* Finalize a completed run: clear cron, switch status, run benchmark, save.
*
* @param array $state Current state (passed in to avoid re-reading).
* @return void
*/
private static function finalize( array $state ): void {
wp_clear_scheduled_hook( self::CRON_HOOK );
$state['status'] = 'benchmarking';
$state['completed_at'] = time();
update_option( self::OPT_STATE, $state, false );
$report = self::run_benchmark( $state );
$state['benchmark'] = $report;
$state['status'] = 'completed';
update_option( self::OPT_STATE, $state, false );
}
/**
* Run the benchmark report on the current dataset. Safe to call externally
* via the /post-stress-test/benchmark REST endpoint to re-measure without
* generating new fixtures.
*
* @param array|null $state Optional state snapshot; defaults to get_state().
* @return array
*/
public static function run_benchmark( ?array $state = null ): array {
$state = $state ?? self::get_state();
$post_type = (string) ( $state['post_type'] ?? '' );
return array(
'generated_at' => time(),
'post_type' => $post_type,
'write' => self::compute_write_metrics( $state ),
'db_sizes' => self::measure_db_sizes( $post_type ),
'query' => $post_type ? self::measure_query_performance( $post_type ) : array(),
);
}
/**
* Compute write-side throughput metrics from state.
*
* @param array $state Stress state snapshot.
* @return array
*/
private static function compute_write_metrics( array $state ): array {
$started = (int) ( $state['started_at'] ?? 0 );
$ended = (int) ( $state['completed_at'] ?? time() );
$processed = (int) ( $state['processed'] ?? 0 );
$elapsed = max( 1, $ended - $started );
$batches = $state['batches_log'] ?? array();
$durations = array_column( $batches, 'duration_ms' );
$min_ms = ! empty( $durations ) ? min( $durations ) : 0;
$max_ms = ! empty( $durations ) ? max( $durations ) : 0;
$avg_ms = ! empty( $durations ) ? (int) ( array_sum( $durations ) / count( $durations ) ) : 0;
return array(
'mode' => $state['mode'] ?? '',
'post_type' => $state['post_type'] ?? '',
'target' => (int) ( $state['target'] ?? 0 ),
'processed' => $processed,
'elapsed_sec' => $elapsed,
'rate_per_sec' => round( $processed / $elapsed, 2 ),
'batches_done' => (int) ( $state['batches_done'] ?? 0 ),
'batch_min_ms' => $min_ms,
'batch_max_ms' => $max_ms,
'batch_avg_ms' => $avg_ms,
'peak_memory_mb' => round( (int) ( $state['peak_memory'] ?? 0 ) / 1048576, 1 ),
);
}
/**
* Measure DB sizes for the post-side tables relevant to this run:
* wp_posts + wp_postmeta + the flat table that matches $post_type.
*
* @param string $post_type Post type from state.
* @return array
*/
private static function measure_db_sizes( string $post_type ): array {
global $wpdb;
$tables = array( $wpdb->posts, $wpdb->postmeta );
$flat = self::flat_table_for_post_type( $post_type );
if ( $flat && self::table_exists( $flat ) ) {
$tables[] = $flat;
}
if ( ! self::is_mysql() ) {
return array_map(
static fn( $t ) => array(
'table' => $t,
'rows' => self::table_row_count( $t ),
),
$tables
);
}
$placeholders = implode( ',', array_fill( 0, count( $tables ), '%s' ) );
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT TABLE_NAME AS t, TABLE_ROWS AS rows_count, DATA_LENGTH AS dl, INDEX_LENGTH AS il
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({$placeholders})",
...$tables
),
ARRAY_A
);
$out = array();
foreach ( (array) $rows as $r ) {
$dl = (int) $r['dl'];
$il = (int) $r['il'];
$total = $dl + $il;
$out[] = array(
'table' => $r['t'],
'rows' => (int) $r['rows_count'],
'data_mb' => round( $dl / 1048576, 2 ),
'index_mb' => round( $il / 1048576, 2 ),
'total_mb' => round( $total / 1048576, 2 ),
'avg_bytes' => $r['rows_count'] > 0 ? (int) ( $total / (int) $r['rows_count'] ) : 0,
);
}
return $out;
}
/**
* Measure 3 representative queries:
* - point lookup on the flat table (indexed key, e.g. _stock_status / hp_status)
* - range scan on the flat table (e.g. price >= X)
* - EAV-baseline: same range query expressed against wp_postmeta
*
* The ratio of (3) ÷ (2) is the speedup the stress fixture proves.
*
* @param string $post_type Post type from state.
* @return array
*/
private static function measure_query_performance( string $post_type ): array {
global $wpdb;
$flat = self::flat_table_for_post_type( $post_type );
if ( ! $flat || ! self::table_exists( $flat ) ) {
return array( 'note' => 'flat_table_missing' );
}
$probes = self::query_probes_for_post_type( $post_type, $flat, $wpdb->postmeta );
$out = array();
foreach ( $probes as $key => $sql ) {
$out[ $key ] = self::time_query( $sql );
}
return $out;
}
/**
* Build per-post_type representative SQL probes. Three probes per type:
* point / range / eav_baseline. Returned in the same key order so the JS
* report can render them without per-type knowledge.
*
* @param string $post_type Post type slug.
* @param string $flat Flat table for the post type.
* @param string $postmeta $wpdb->postmeta.
* @return array<string,string>
*/
private static function query_probes_for_post_type( string $post_type, string $flat, string $postmeta ): array {
switch ( $post_type ) {
case 'product':
return array(
'point_stock_status' => "SELECT post_id FROM `{$flat}` WHERE _stock_status = 'instock' LIMIT 100",
'range_price_above' => "SELECT post_id FROM `{$flat}` WHERE CAST(_price AS DECIMAL(20,2)) > 100 ORDER BY _price LIMIT 100",
'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = '_stock_status' AND m.meta_value = 'instock' LIMIT 100",
);
case 'hp_listing':
return array(
'point_status' => "SELECT post_id FROM `{$flat}` WHERE hp_status = 'publish' LIMIT 100",
'range_price_above' => "SELECT post_id FROM `{$flat}` WHERE CAST(hp_price AS DECIMAL(20,2)) > 100 ORDER BY hp_price LIMIT 100",
'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = 'hp_status' AND m.meta_value = 'publish' LIMIT 100",
);
case 'hp_request':
return array(
'point_status' => "SELECT post_id FROM `{$flat}` WHERE hp_status = 'publish' LIMIT 100",
'range_budget_above' => "SELECT post_id FROM `{$flat}` WHERE CAST(hp_budget AS DECIMAL(20,2)) > 100 ORDER BY hp_budget LIMIT 100",
'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = 'hp_status' AND m.meta_value = 'publish' LIMIT 100",
);
case 'hp_vendor':
return array(
'point_verified' => "SELECT post_id FROM `{$flat}` WHERE hp_verified = '1' LIMIT 100",
'range_rate_above' => "SELECT post_id FROM `{$flat}` WHERE CAST(hp_hourly_rate AS DECIMAL(20,2)) > 50 ORDER BY hp_hourly_rate LIMIT 100",
'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = 'hp_verified' AND m.meta_value = '1' LIMIT 100",
);
case 'attachment':
return array(
'point_alt_present' => "SELECT post_id FROM `{$flat}` WHERE _wp_attachment_image_alt = 'Stress test image' LIMIT 100",
'range_id_above' => "SELECT post_id FROM `{$flat}` WHERE post_id > 0 ORDER BY post_id DESC LIMIT 100",
'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = '_wp_attachment_image_alt' AND m.meta_value = 'Stress test image' LIMIT 100",
);
case 'nav_menu_item':
return array(
'point_type' => "SELECT post_id FROM `{$flat}` WHERE _menu_item_type = 'custom' LIMIT 100",
'range_id_above' => "SELECT post_id FROM `{$flat}` WHERE post_id > 0 ORDER BY post_id DESC LIMIT 100",
'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = '_menu_item_type' AND m.meta_value = 'custom' LIMIT 100",
);
case 'post':
default:
return array(
'point_thumbnail' => "SELECT post_id FROM `{$flat}` WHERE _thumbnail_id = '0' LIMIT 100",
'range_id_above' => "SELECT post_id FROM `{$flat}` WHERE post_id > 0 ORDER BY post_id DESC LIMIT 100",
'eav_baseline' => "SELECT m.post_id FROM `{$postmeta}` m WHERE m.meta_key = '_thumbnail_id' AND m.meta_value = '0' LIMIT 100",
);
}
}
/**
* Map post_type → its flat table. Returns empty string for unsupported types.
*
* @param string $post_type Post type slug.
* @return string Fully-qualified flat table name or '' if unknown.
*/
private static function flat_table_for_post_type( string $post_type ): string {
global $wpdb;
$prefix = $wpdb->prefix . 'wpdo_post_';
$map = array(
'product' => $prefix . 'wc_product',
'hp_listing' => $prefix . 'hp_listing_core',
'hp_request' => $prefix . 'hp_request_core',
'hp_vendor' => $prefix . 'hp_vendor_core',
'attachment' => $prefix . 'attachment',
'nav_menu_item' => $prefix . 'nav_menu_item',
'post' => $prefix . 'wp_core',
);
return $map[ $post_type ] ?? '';
}
/**
* Time a callable.
*
* @param callable $cb Callable to invoke once.
* @param int $n Logical operation count for QPS.
* @return array
*/
private static function time_calls( callable $cb, int $n ): array {
$start = microtime( true );
$cb();
$elapsed_ms = ( microtime( true ) - $start ) * 1000;
return array(
'n' => $n,
'total_ms' => round( $elapsed_ms, 2 ),
'avg_ms' => $n > 0 ? round( $elapsed_ms / $n, 3 ) : 0,
'qps' => $elapsed_ms > 0 ? round( $n / ( $elapsed_ms / 1000 ), 1 ) : 0,
);
}
/**
* Time a SQL query.
*
* @param string $sql Query to execute via $wpdb->get_results().
* @return array
*/
private static function time_query( string $sql ): array {
global $wpdb;
$start = microtime( true );
$wpdb->get_results( $sql );
$elapsed_ms = ( microtime( true ) - $start ) * 1000;
return array(
'duration_ms' => round( $elapsed_ms, 2 ),
);
}
/**
* Cheap row-count probe (used for SQLite fallback in measure_db_sizes()).
*
* @param string $table Fully-qualified table name.
* @return int
*/
private static function table_row_count( string $table ): int {
global $wpdb;
if ( ! self::table_exists( $table ) ) {
return 0;
}
return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
}
/**
* Memoized table-exists probe.
*
* @param string $table Fully-qualified table name.
* @return bool
*/
private static function table_exists( string $table ): bool {
global $wpdb;
static $cache = array();
if ( isset( $cache[ $table ] ) ) {
return $cache[ $table ];
}
$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
$cache[ $table ] = ( $found === $table );
return $cache[ $table ];
}
/**
* Detect MySQL vs SQLite (information_schema is unsupported on the latter).
*
* @return bool True when running against MySQL/MariaDB.
*/
private static function is_mysql(): bool {
return ! ( class_exists( 'WP_SQLite_DB' ) || class_exists( 'WP_SQLite_Translator' ) || class_exists( 'WP_SQLite_Driver' ) );
}
// ─────────────────────────────────────────────────────────────────────────
// Internals
// ─────────────────────────────────────────────────────────────────────────
/**
* Lazy-built map of post_type → meta_key → value (or value generator).
* Mirrors the v2.9.1 entity group key definitions, but seeds only a subset
* (511 keys) per post_type to keep fixture overhead reasonable.
*
* @return array<string,array<string,mixed>>
*/
private static function seed_map(): array {
if ( null !== self::$seed_map_cache ) {
return self::$seed_map_cache;
}
self::$seed_map_cache = array(
'product' => array(
'_price' => static fn( int $i ) => number_format( 10 + ( $i * 0.5 ), 2, '.', '' ),
'_regular_price' => static fn( int $i ) => number_format( 12 + ( $i * 0.5 ), 2, '.', '' ),
'_stock' => static fn( int $i ) => (string) ( ( $i % 100 ) + 1 ),
'_stock_status' => 'instock',
'_sku' => static fn( int $i ) => 'STRESS-SKU-' . $i,
),
'hp_listing' => array(
'hp_price' => static fn( int $i ) => number_format( 50 + $i, 2, '.', '' ),
'hp_status' => 'publish',
'hp_featured' => '0',
'hp_verified' => '1',
'hp_vendor' => '1',
'hp_view_count' => static fn( int $i ) => (string) $i,
'hp_expired_time' => static fn() => (string) ( time() + 30 * 86400 ),
),
'hp_request' => array(
'hp_status' => 'publish',
'hp_user' => '1',
'hp_budget' => static fn( int $i ) => number_format( 100 + $i * 10, 2, '.', '' ),
'hp_view_count' => '0',
'hp_expired_time' => static fn() => (string) ( time() + 14 * 86400 ),
),
'hp_vendor' => array(
'hp_user' => static fn( int $i ) => (string) ( $i + 1 ),
'hp_verified' => '1',
'hp_hourly_rate' => static fn( int $i ) => number_format( 50 + $i * 5, 2, '.', '' ),
'hp_rating_count' => static fn( int $i ) => (string) $i,
'hp_rating' => '4.5',
),
'attachment' => array(
'_wp_attached_file' => static fn( int $i ) => "stress/test-{$i}.jpg",
'_wp_attachment_image_alt' => 'Stress test image',
),
'nav_menu_item' => array(
'_menu_item_type' => 'custom',
'_menu_item_object_id' => '0',
'_menu_item_object' => 'custom',
'_menu_item_target' => '',
'_menu_item_url' => static fn( int $i ) => "https://example.com/stress-{$i}",
),
'post' => array(
'_thumbnail_id' => '0',
'_edit_last' => '1',
),
);
return self::$seed_map_cache;
}
/**
* Names of all wp_wpdo_post_* flat tables that cleanup should sweep.
*
* @return string[]
*/
private static function get_post_flat_tables(): array {
global $wpdb;
$prefix = $wpdb->prefix . 'wpdo_post_';
return array(
$prefix . 'wp_core',
$prefix . 'attachment',
$prefix . 'wc_product',
$prefix . 'hp_listing_core',
$prefix . 'hp_request_core',
$prefix . 'hp_vendor_core',
$prefix . 'nav_menu_item',
);
}
}