Files
2meet-data-optimizer/includes/class-tmdo-user-stress-tester.php
T
wpdev f524ea3f16 build: 移植 PHPCS + PHPStan L6 品質 gate(PR-I 前半)
- phpcs.xml(自 A 移植):ruleset 改名、*/tools/* 例外換成 */back-compat/*、
  中文註解全域排除 Squiz.Commenting.InlineComment.InvalidEndChar、
  interface 別名檔排除 OneObjectStructurePerFile
- 檔頭正規化:16 個檔案的 declare(strict_types=1) 與前導 // 註解移到
  file docblock 之後,並移除 <?php 後多餘空行(phpcbf 另自動修 190 處)
- phpstan.neon + .phpstan/stubs.php(TMDO_ 與 WPDO_ 兩套常數)+
  重新產生的 phpstan-baseline.neon(710 errors,A 的 3877 行 baseline
  因前綴與路徑不同無法沿用)

現況:PHPCS 0 errors / 0 warnings、PHPStan L6 No errors、
unit 451 / integration 398 GREEN

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

1421 lines
49 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* TMDO_User_Stress_Tester — User entity 壓力測試 / Benchmark 工具
*
* 由 WP Cron 驅動的批次任務,自動建立大量測試使用者並填滿所有 user 相關 flat tables。
* 完成後自動產出 benchmark 報告(寫入速率 / DB 容量 / 查詢效能)。
*
* 兩種模式:
* - fast:直接 bulk INSERT 到 wp_users + flat tables(跳過 wp_insert_user 與 user_register hook
* 速率 ~5,000-10,000 users/秒,僅核心 caps meta 寫到 wp_usermeta
* - realistic:走 wp_insert_user + update_user_meta,讓 Hook Bus 自然攔截
* 速率 ~50-200 users/秒,完整測試生產寫入路徑
*
* @package WP_Data_Optimizer
* @since 2.6.7
*/
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
declare(strict_types=1);
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,Generic.CodeAnalysis.UnusedFunctionParameter,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.PHP.YodaConditions,WordPress.WP.AlternativeFunctions.rand_mt_rand,WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- 測試工具:mt_rand 用於杜撰測試資料;serialize() 為 WP capabilities 標準格式
defined( 'ABSPATH' ) || exit;
final class TMDO_User_Stress_Tester {
public const TEST_USER_PREFIX = 'test';
public const TEST_PASSWORD = 'PassWord2026!';
public const TEST_EMAIL_DOMAIN = 'stresstest.local';
public const DEFAULT_BATCH_SIZE = 500;
public const MAX_BATCH_SIZE = 2000;
public const MIN_BATCH_DELAY_SEC = 1;
public const OPT_STATE = 'wpdo_stress_test_state';
public const CRON_HOOK = 'wpdo_stress_test_batch';
public const MODE_FAST = 'fast';
public const MODE_REALISTIC = 'realistic';
/** Wall-clock budget per batch (sec). Bounded so polling stays responsive and far below nginx 60s timeout. */
public const BATCH_DEADLINE_SEC = 8;
/** Transient key for cancel/cleanup signalling — in-flight batches檢查此旗標即時中止. */
public const CANCEL_FLAG = 'wpdo_stress_cancel_flag';
private const FIRST_NAMES = array(
'Alice',
'Bob',
'Carol',
'David',
'Eve',
'Frank',
'Grace',
'Henry',
'Ivy',
'Jack',
'Kate',
'Leo',
'Mia',
'Nick',
'Olivia',
'Peter',
'Queen',
'Ryan',
'Sara',
'Tom',
'Uma',
'Vince',
'Wendy',
'Xander',
'Yuki',
'Zoe',
);
private const LAST_NAMES = array(
'Wang',
'Lin',
'Chen',
'Lee',
'Liu',
'Yang',
'Huang',
'Zhao',
'Wu',
'Zhou',
'Smith',
'Johnson',
'Brown',
'Davis',
'Miller',
'Wilson',
'Moore',
'Taylor',
'Anderson',
'Thomas',
);
private const MEMBERSHIP_LEVELS = array( 'bronze', 'silver', 'gold', 'platinum', 'diamond' );
private const TIER_SOURCES = array( 'manual', 'auto', 'imported', 'default' );
private const LOCALES = array( 'zh_TW', 'en_US', 'ja', 'zh_CN' );
private const SPECIALTIES_POOL = array( 'music', 'art', 'sports', 'tech', 'food', 'travel', 'reading', 'gaming', 'fitness', 'design' );
/**
* v2.8.2: data pools for the user entity groups added in v2.7.0.
* Keep stress fixtures aligned with TMDO_Member_Fields registration.
*/
private const SOCIAL_KEYS = array(
'facebook',
'twitter',
'instagram',
'youtube',
'tiktok',
'linkedin',
'vimeo',
'vkontakte',
'mastodon',
'medium',
'wordpress',
'odnoklassniki',
'pinterest',
'dribbble',
'github',
);
private const BILLING_KEYS = array(
'billing_first_name',
'billing_last_name',
'billing_company',
'billing_address_1',
'billing_address_2',
'billing_city',
'billing_state',
'billing_postcode',
'billing_country',
'billing_email',
'billing_phone',
);
private const SHIPPING_KEYS = array(
'shipping_first_name',
'shipping_last_name',
'shipping_company',
'shipping_address_1',
'shipping_address_2',
'shipping_city',
'shipping_state',
'shipping_postcode',
'shipping_country',
'shipping_phone',
);
private const COUNTRY_POOL = array( 'TW', 'US', 'JP', 'CN', 'HK', 'SG', 'MY' );
private const STATE_POOL = array( 'Taipei', 'New Taipei', 'Taichung', 'CA', 'NY', 'TX', 'Tokyo', 'Osaka', 'Beijing', 'Shanghai' );
private const CITY_POOL = array( 'Taipei', 'Taichung', 'Kaohsiung', 'San Francisco', 'New York', 'Tokyo', 'Osaka', 'Beijing', 'Hong Kong', 'Singapore' );
private const BIO_POOL = array(
'陶藝創作者,專注於生活陶藝與茶道器具。',
'皮革職人,堅持使用植鞣皮革與手縫工藝。',
'花藝設計師,融合東西方花藝美學。',
'金工師傅,融合日本金工技法與台灣原住民圖騰元素。',
'法式甜點師,堅持使用天然食材與進口原料。',
'Coffee aficionado, single-origin specialist.',
'Indie music producer & sound engineer.',
'Yoga instructor — Hatha, Vinyasa, Yin.',
'Software engineer at a Taipei startup.',
'Travel writer & photographer.',
);
// ─────────────────────────────────────────────────────────
// 對外入口
// ─────────────────────────────────────────────────────────
/**
* 啟動壓力測試。建立 state + 排程第一個 batch。
*/
public static function start( int $target_count, string $mode = self::MODE_FAST, int $batch_size = self::DEFAULT_BATCH_SIZE ): array {
if ( $target_count < 1 ) {
return array(
'ok' => false,
'error' => 'target_count must be >= 1',
);
}
if ( $target_count > 1000000 ) {
return array(
'ok' => false,
'error' => 'target_count too large (max 1,000,000)',
);
}
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( 'stress_', true ),
'status' => 'running',
'mode' => $mode,
'target' => $target_count,
'batch_size' => $batch_size,
'started_at' => time(),
'processed' => 0,
'last_user_id' => 0,
'batches_done' => 0,
'batches_log' => array(),
'errors' => array(),
'peak_memory' => 0,
'completed_at' => null,
'benchmark' => null,
);
update_option( self::OPT_STATE, $state, false );
// 清掉前次留下的 cancellation flag(避免新測試一啟動就被誤判為已取消)
delete_transient( self::CANCEL_FLAG );
wp_clear_scheduled_hook( self::CRON_HOOK );
wp_schedule_single_event( time(), self::CRON_HOOK );
// 注意:不在這裡同步執行 run_batch()。
// 若 batch_size 大或 mode=realisticrun_batch() 可能跑數十秒到數分鐘,
// PHP-FPM / nginx 會在 60s 時 504 Gateway Timeout。
// 第一個 batch 由前端 polling 進來時的 pump_if_due() 推進,每個 batch 有 wall-clock 限制。
return array(
'ok' => true,
'state' => $state,
);
}
/**
* 取消執行中的測試。
*
* 設 cancellation transient flagin-flight 的 run_batch() 與 run_batch_realistic()
* 會在每個 user 迭代之前檢查並提早 breakrun_batch() 結尾的 update_option 會
* 重讀 state 確認 status 仍是 'running' 才覆寫,避免 race condition 把 cancelled
* 重新覆蓋為 running。
*/
public static function cancel(): array {
$state = self::get_state();
if ( empty( $state ) ) {
return array(
'ok' => true,
'message' => 'no_active_job',
);
}
// 設 flag 給 in-flight batch 看到,提早 break
set_transient( self::CANCEL_FLAG, 1, 600 );
wp_clear_scheduled_hook( self::CRON_HOOK );
// 重讀 state(避免覆寫 in-flight batch 已寫入的 progress
$state = self::get_state();
$state['status'] = 'cancelled';
$state['completed_at'] = time();
update_option( self::OPT_STATE, $state, false );
return array(
'ok' => true,
'state' => $state,
);
}
/**
* 取得目前進度狀態。
*/
public static function get_state(): array {
$state = get_option( self::OPT_STATE, array() );
return is_array( $state ) ? $state : array();
}
/**
* 取得進度(含計算的速率與 ETA)。
*
* 副作用:若狀態為 running 且 wp-cron 沒按時觸發(dev 環境常見),主動同步推進一個 batch,
* 確保 admin polling 看得到進度,不依賴外部 cron worker。
*
* @param bool $pump 是否在偵測到延誤時主動推進。預設 trueREST status endpoint 用。
*/
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_user_count' => self::count_test_users(),
)
);
}
/**
* 檢查是否需要主動推進一個 batch。
*
* 觸發條件:
* - state.status === 'running'
* - 距離上次 batch 已超過 N 秒(避免 polling 太密集連續跑)
* - 沒有其他 request 在執行(用 transient lock 防併發)
*
* 之所以這樣設計:dev / 低流量環境的 wp-cron 可能不會準時跑(CLI 沒 web request、
* spawn_cron 是 async fire-and-forget)。讓 polling 自帶推進可確保進度條會動。
*/
public static function pump_if_due(): void {
$state = self::get_state();
if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) {
return;
}
// 至少間隔 1 秒推一次,避免極端密集 polling 把 DB 壓垮
$last_pushed_at = (int) ( $state['last_pushed_at'] ?? $state['started_at'] ?? 0 );
if ( time() - $last_pushed_at < 1 ) {
return;
}
// Transient lock 防多個 polling request 同時執行(30s TTL,確保即使崩潰也會自動釋放)
$lock_key = 'wpdo_stress_pump_lock';
if ( false !== get_transient( $lock_key ) ) {
return;
}
set_transient( $lock_key, 1, 30 );
// 確保 PHP 有足夠時間跑滿 batch deadline;不超過 nginx 60s timeout
if ( function_exists( 'set_time_limit' ) ) {
@set_time_limit( self::BATCH_DEADLINE_SEC + 10 );
}
try {
self::run_batch();
} finally {
delete_transient( $lock_key );
}
}
/**
* Cron 觸發點:執行一個批次。完成則啟動 benchmark;未完成 reschedule。
*/
public static function run_batch(): void {
$state = self::get_state();
if ( empty( $state ) || 'running' !== ( $state['status'] ?? '' ) ) {
return;
}
// 開頭就檢查 cancellation flag — 即使 cron 已排到,看到 flag 立即放棄
if ( false !== get_transient( self::CANCEL_FLAG ) ) {
return;
}
$target = (int) $state['target'];
$processed = (int) $state['processed'];
$batch_size = (int) $state['batch_size'];
$mode = (string) $state['mode'];
$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( $this_batch_size );
} else {
$inserted = self::run_batch_realistic( $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', 'user_batch_failed', $e->getMessage() );
}
return;
}
$batch_elapsed = microtime( true ) - $batch_started;
// 重讀 state — 中間可能被 cancel() 改成 'cancelled',不能用本地快照覆寫
$latest = self::get_state();
if ( empty( $latest ) ) {
return; // state 已被 cleanup() 刪光,不再寫入
}
$is_cancelled = ( 'cancelled' === ( $latest['status'] ?? '' ) ) || false !== get_transient( self::CANCEL_FLAG );
// 累加 progress 到「最新」state(保留 cancel 寫入的 status
$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();
// 若已被 cancel:保留 'cancelled' 狀態,僅累加 progress 給 UI 顯示「實際已寫入幾筆」
// 不重新排 cron、不 finalize benchmark
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 );
}
/**
* 清除所有 test_* 使用者及其 flat table 資料。
*
* 也設 cancellation flag,確保 in-flight batch 看到立即中止,避免 cleanup 後
* 又被新建一筆 user 進來。
*/
public static function cleanup(): array {
global $wpdb;
// 通知 in-flight batch 立即停(最壞情況等 ~3 秒讓當前 user 完成)
set_transient( self::CANCEL_FLAG, 1, 600 );
wp_clear_scheduled_hook( self::CRON_HOOK );
$user_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->users} WHERE user_login LIKE %s",
$wpdb->esc_like( self::TEST_USER_PREFIX ) . '%'
)
);
if ( empty( $user_ids ) ) {
return array(
'ok' => true,
'deleted' => 0,
);
}
$count = count( $user_ids );
$id_list = implode( ',', array_map( 'absint', $user_ids ) );
// 先刪 flat tables
$flat_tables = self::get_user_flat_tables();
foreach ( $flat_tables as $tbl ) {
$wpdb->query( "DELETE FROM `{$tbl}` WHERE user_id IN ({$id_list})" );
}
// 刪 usermeta + users
$wpdb->query( "DELETE FROM {$wpdb->usermeta} WHERE user_id IN ({$id_list})" );
$wpdb->query( "DELETE FROM {$wpdb->users} WHERE ID IN ({$id_list})" );
// 重置 state + cancel flag(後者確保下一次啟動不會被誤判為 cancelled)
delete_option( self::OPT_STATE );
delete_transient( self::CANCEL_FLAG );
// 清空 cache(每個 user 各自清,clean_user_cache 不接受 array
foreach ( $user_ids as $uid ) {
clean_user_cache( (int) $uid );
}
return array(
'ok' => true,
'deleted' => $count,
);
}
/**
* 取得目前 test_* 使用者數量。
*/
public static function count_test_users(): int {
global $wpdb;
return (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->users} WHERE user_login LIKE %s",
$wpdb->esc_like( self::TEST_USER_PREFIX ) . '%'
)
);
}
// ─────────────────────────────────────────────────────────
// 批次寫入:Fast Mode
// ─────────────────────────────────────────────────────────
private static function run_batch_fast( int $count ): int {
global $wpdb;
// 一次計算密碼 hash(重複用同一個 hash,若密碼相同 phpass 會比對通過嗎?
// 注意:phpass 每次 hash_password 會產生不同 salt,所以這裡用同一 hash 對所有 user 是 OK 的
// 因為 wp_check_password 只是用儲存的 hash 校驗輸入密碼,相同密碼+相同 hash 會通過
static $cached_hash = null;
if ( null === $cached_hash ) {
$cached_hash = wp_hash_password( self::TEST_PASSWORD );
}
$registered_at = current_time( 'mysql', true );
// 先取得目前最大 test 序號(避免衝突)
$next_seq = self::next_test_user_seq();
// 準備 wp_users bulk INSERT
$user_rows = array();
$user_data = array();
for ( $i = 0; $i < $count; $i++ ) {
$seq = $next_seq + $i;
$login = self::TEST_USER_PREFIX . $seq;
$email = $login . '@' . self::TEST_EMAIL_DOMAIN;
$first = self::FIRST_NAMES[ $seq % count( self::FIRST_NAMES ) ];
$last = self::LAST_NAMES[ ( $seq * 7 ) % count( self::LAST_NAMES ) ];
$display = $first . ' ' . $last . ' #' . $seq;
$user_rows[] = $wpdb->prepare(
'(%s,%s,%s,%s,%s,%s,%s,%d)',
$login,
$cached_hash,
$login,
$email,
'',
$registered_at,
$display,
0
);
$user_data[ $seq ] = array(
'login' => $login,
'email' => $email,
'first' => $first,
'last' => $last,
'display' => $display,
);
}
$sql = "INSERT INTO {$wpdb->users}
(user_login, user_pass, user_nicename, user_email, user_url, user_registered, display_name, user_status)
VALUES " . implode( ',', $user_rows );
$wpdb->query( $sql );
// 取出剛 INSERT 的 user IDs(用 user_login 對應)
$placeholders = implode( ',', array_fill( 0, count( $user_data ), '%s' ) );
$logins = array_map( fn( $d ) => $d['login'], $user_data );
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, user_login FROM {$wpdb->users} WHERE user_login IN ({$placeholders})",
...$logins
),
ARRAY_A
);
$login_to_id = array();
foreach ( (array) $rows as $row ) {
$login_to_id[ $row['user_login'] ] = (int) $row['ID'];
}
// 從 user_login 反查回 seq
$seq_to_id = array();
foreach ( $user_data as $seq => $data ) {
if ( isset( $login_to_id[ $data['login'] ] ) ) {
$seq_to_id[ $seq ] = $login_to_id[ $data['login'] ];
}
}
if ( empty( $seq_to_id ) ) {
return 0;
}
// usermeta:必填的 caps + nickname + first_name + last_name
self::insert_usermeta_bulk( $seq_to_id, $user_data );
// flat tables — existing v2.5.x groups
self::insert_flat_hot( $seq_to_id );
self::insert_flat_membership( $seq_to_id );
self::insert_flat_activity( $seq_to_id );
self::insert_flat_profile( $seq_to_id, $user_data );
self::insert_flat_sso( $seq_to_id );
self::insert_flat_cold( $seq_to_id );
self::insert_points_ledger( $seq_to_id );
// v2.7.0+ groups: legacy WP/WC/HP keys absorbed into entity bridge
self::insert_flat_core_profile( $seq_to_id, $user_data );
self::insert_flat_social( $seq_to_id );
self::insert_flat_commerce( $seq_to_id, $user_data );
self::insert_flat_hp_user( $seq_to_id );
return count( $seq_to_id );
}
private static function next_test_user_seq(): int {
global $wpdb;
$max = $wpdb->get_var(
$wpdb->prepare(
"SELECT MAX(CAST(SUBSTRING(user_login, %d) AS UNSIGNED)) FROM {$wpdb->users} WHERE user_login LIKE %s",
strlen( self::TEST_USER_PREFIX ) + 1,
$wpdb->esc_like( self::TEST_USER_PREFIX ) . '%'
)
);
return ( null === $max ) ? 1 : ( (int) $max ) + 1;
}
/**
* Bulk-insert the WP-core-required usermeta rows for a batch of test users.
*
* v2.8.3: drops `nickname` / `first_name` / `last_name` rows — those keys
* are managed by the v2.7.0 `core_profile` entity group and written directly
* into `wp_wpdo_user_core_profile` by `insert_flat_core_profile()`. Writing
* them to `wp_usermeta` here was a direct-SQL bypass of Hook Bus aeav_only
* short-circuit logic and inflated the wp_users:wp_usermeta ratio by 3 rows
* per test user (1:5 instead of the expected 1:2). Only `wp_capabilities`
* and `wp_user_level` are kept because WP core itself writes them on every
* `wp_insert_user()` and they are NOT registered as entity fields.
*
* @param array $seq_to_id Map seq → user_id from the bulk wp_users insert.
* @param array $user_data Per-seq generated user data (unused in v2.8.3).
*/
private static function insert_usermeta_bulk( array $seq_to_id, array $user_data ): void {
global $wpdb;
unset( $user_data ); // v2.8.3: name fields no longer written here.
$caps_value = serialize( array( 'subscriber' => true ) );
$rows = array();
foreach ( $seq_to_id as $uid ) {
$rows[] = $wpdb->prepare( '(%d,%s,%s)', $uid, $wpdb->prefix . 'capabilities', $caps_value );
$rows[] = $wpdb->prepare( '(%d,%s,%s)', $uid, $wpdb->prefix . 'user_level', '0' );
}
if ( ! empty( $rows ) ) {
$wpdb->query( "INSERT INTO {$wpdb->usermeta} (user_id, meta_key, meta_value) VALUES " . implode( ',', $rows ) );
}
}
private static function insert_flat_hot( array $seq_to_id ): void {
global $wpdb;
$tbl = "{$wpdb->prefix}wpdo_user_hot";
if ( ! self::table_exists( $tbl ) ) {
return;
}
$rows = array();
foreach ( $seq_to_id as $seq => $uid ) {
$money = mt_rand( 0, 1000000 ) / 100;
$orders = mt_rand( 0, 50 );
$last = time() - mt_rand( 0, 365 * DAY_IN_SECONDS );
$rows[] = $wpdb->prepare( '(%d,%f,%d,%d)', $uid, $money, $orders, $last );
}
if ( ! empty( $rows ) ) {
$wpdb->query( "INSERT INTO `{$tbl}` (user_id, _money_spent, _order_count, _last_order) VALUES " . implode( ',', $rows ) );
}
}
private static function insert_flat_membership( array $seq_to_id ): void {
global $wpdb;
$tbl = "{$wpdb->prefix}wpdo_user_membership";
if ( ! self::table_exists( $tbl ) ) {
return;
}
$rows = array();
foreach ( $seq_to_id as $seq => $uid ) {
$level = self::MEMBERSHIP_LEVELS[ $seq % count( self::MEMBERSHIP_LEVELS ) ];
$points = mt_rand( 0, 50000 );
$expires = gmdate( 'Y-m-d H:i:s', time() + mt_rand( 30, 730 ) * DAY_IN_SECONDS );
$activated = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 1, 365 ) * DAY_IN_SECONDS );
$source = self::TIER_SOURCES[ ( $seq * 3 ) % count( self::TIER_SOURCES ) ];
$label = ucfirst( $level ) . ' Tier';
$rows[] = $wpdb->prepare( '(%d,%s,%d,%s,%s,%s,%s)', $uid, $level, $points, $expires, $activated, $source, $label );
}
if ( ! empty( $rows ) ) {
$wpdb->query( "INSERT INTO `{$tbl}` (user_id, membership_level, points_balance, membership_expires_at, membership_activated_at, tier_source, custom_tier_label) VALUES " . implode( ',', $rows ) );
}
}
private static function insert_flat_activity( array $seq_to_id ): void {
global $wpdb;
$tbl = "{$wpdb->prefix}wpdo_user_activity";
if ( ! self::table_exists( $tbl ) ) {
return;
}
$rows = array();
foreach ( $seq_to_id as $seq => $uid ) {
$login_count = mt_rand( 1, 500 );
$last_active = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 30 * DAY_IN_SECONDS ) );
$last_login = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 7 * DAY_IN_SECONDS ) );
$last_order = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 90 * DAY_IN_SECONDS ) );
$sessions = mt_rand( 0, 200 );
$flags = mt_rand( 0, 7 );
$rows[] = $wpdb->prepare( '(%d,%d,%s,%s,%s,%d,%d)', $uid, $login_count, $last_active, $last_login, $last_order, $sessions, $flags );
}
if ( ! empty( $rows ) ) {
$wpdb->query( "INSERT INTO `{$tbl}` (user_id, login_count, last_active_at, last_login_at, last_order_at, session_count, account_flags) VALUES " . implode( ',', $rows ) );
}
}
private static function insert_flat_profile( array $seq_to_id, array $user_data ): void {
global $wpdb;
$tbl = "{$wpdb->prefix}wpdo_user_profile";
if ( ! self::table_exists( $tbl ) ) {
return;
}
$rows = array();
foreach ( $seq_to_id as $seq => $uid ) {
$d = $user_data[ $seq ];
$num_specs = ( $seq % 3 ) + 1;
$specs_array = array_slice( self::SPECIALTIES_POOL, $seq % 7, $num_specs );
$specialties = wp_json_encode( $specs_array );
$bio_url = 'https://example.com/' . $d['login'];
$avatar_url = 'https://example.com/avatars/' . $d['login'] . '.jpg';
$display_custom = $d['display'];
$locale = self::LOCALES[ $seq % count( self::LOCALES ) ];
$rows[] = $wpdb->prepare( '(%d,%s,%s,%s,%s,%s)', $uid, $specialties, $bio_url, $avatar_url, $display_custom, $locale );
}
if ( ! empty( $rows ) ) {
$wpdb->query( "INSERT INTO `{$tbl}` (user_id, specialties, bio_url, avatar_url, display_name_custom, locale) VALUES " . implode( ',', $rows ) );
}
}
private static function insert_flat_sso( array $seq_to_id ): void {
global $wpdb;
$tbl = "{$wpdb->prefix}wpdo_user_sso";
if ( ! self::table_exists( $tbl ) ) {
return;
}
$rows = array();
foreach ( $seq_to_id as $seq => $uid ) {
$hub_id = 'hub_' . wp_generate_password( 16, false );
$picture = 'https://example.com/sso/' . $uid . '.jpg';
$token_hash = hash( 'sha256', 'token_' . $uid );
$refresh_enc = 'enc:v1:' . base64_encode( random_bytes( 32 ) );
$expires = gmdate( 'Y-m-d H:i:s', time() + 3600 );
$last_login = gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 7 * DAY_IN_SECONDS ) );
$count = mt_rand( 1, 100 );
$rows[] = $wpdb->prepare( '(%d,%s,%s,%s,%s,%s,%s,%d)', $uid, $hub_id, $picture, $token_hash, $refresh_enc, $expires, $last_login, $count );
}
if ( ! empty( $rows ) ) {
$wpdb->query( "INSERT INTO `{$tbl}` (user_id, hub_global_user_id, picture_url, last_id_token_hash, refresh_token_enc, token_expires_at, sso_last_login_at, sso_login_count) VALUES " . implode( ',', $rows ) );
}
}
private static function insert_flat_cold( array $seq_to_id ): void {
global $wpdb;
$tbl = "{$wpdb->prefix}wpdo_user_cold";
if ( ! self::table_exists( $tbl ) ) {
return;
}
$rows = array();
foreach ( $seq_to_id as $seq => $uid ) {
$picture = 'https://cdn.example.com/' . $uid . '.png';
$tok = 'tok_legacy_' . wp_generate_password( 32, false );
$ref = 'ref_legacy_' . wp_generate_password( 32, false );
$rows[] = $wpdb->prepare( '(%d,%s,%s,%s)', $uid, $picture, $tok, $ref );
}
if ( ! empty( $rows ) ) {
$wpdb->query( "INSERT INTO `{$tbl}` (user_id, _tmso_picture_url, _tmso_last_id_token, _tmso_refresh_token) VALUES " . implode( ',', $rows ) );
}
}
/**
* v2.7.0 core_profile — nickname/first_name/last_name/description.
*
* @since 2.8.2
*/
private static function insert_flat_core_profile( array $seq_to_id, array $user_data ): void {
global $wpdb;
$tbl = "{$wpdb->prefix}wpdo_user_core_profile";
if ( ! self::table_exists( $tbl ) ) {
return;
}
$rows = array();
foreach ( $seq_to_id as $seq => $uid ) {
$d = $user_data[ $seq ];
$nickname = $d['login'];
$first = $d['first'];
$last = $d['last'];
$description = self::BIO_POOL[ $seq % count( self::BIO_POOL ) ] . ' #' . $seq;
$rows[] = $wpdb->prepare( '(%d,%s,%s,%s,%s)', $uid, $nickname, $first, $last, $description );
}
if ( ! empty( $rows ) ) {
$wpdb->query( "INSERT INTO `{$tbl}` (user_id, nickname, first_name, last_name, description) VALUES " . implode( ',', $rows ) );
}
}
/**
* v2.7.0 social — 15 social profile URLs (textarea per H2 fix).
*
* @since 2.8.2
*/
private static function insert_flat_social( array $seq_to_id ): void {
global $wpdb;
$tbl = "{$wpdb->prefix}wpdo_user_social";
if ( ! self::table_exists( $tbl ) ) {
return;
}
$cols = array_merge( array( 'user_id' ), self::SOCIAL_KEYS );
$col_sql = implode( ',', array_map( fn( $c ) => "`{$c}`", $cols ) );
$rows = array();
foreach ( $seq_to_id as $seq => $uid ) {
$values = array( $uid );
$formats = array( '%d' );
foreach ( self::SOCIAL_KEYS as $i => $key ) {
// Populate ~3 of the 15 social URLs per user — realistic for vendor profiles.
if ( ( $seq + $i ) % 5 === 0 ) {
$values[] = sprintf( 'https://%s.example.com/%s', $key, $uid );
$formats[] = '%s';
} else {
$values[] = null;
$formats[] = '%s';
}
}
$rows[] = $wpdb->prepare( '(' . implode( ',', $formats ) . ')', ...$values );
}
if ( ! empty( $rows ) ) {
$wpdb->query( "INSERT INTO `{$tbl}` ({$col_sql}) VALUES " . implode( ',', $rows ) );
}
}
/**
* v2.7.0 commerce — WooCommerce billing + shipping address fields.
*
* @since 2.8.2
*/
private static function insert_flat_commerce( array $seq_to_id, array $user_data ): void {
global $wpdb;
$tbl = "{$wpdb->prefix}wpdo_user_commerce";
if ( ! self::table_exists( $tbl ) ) {
return;
}
$all_keys = array_merge( self::BILLING_KEYS, self::SHIPPING_KEYS );
$cols = array_merge( array( 'user_id' ), $all_keys );
$col_sql = implode( ',', array_map( fn( $c ) => "`{$c}`", $cols ) );
$rows = array();
foreach ( $seq_to_id as $seq => $uid ) {
$d = $user_data[ $seq ];
$first = $d['first'];
$last = $d['last'];
$country = self::COUNTRY_POOL[ $seq % count( self::COUNTRY_POOL ) ];
$state = self::STATE_POOL[ $seq % count( self::STATE_POOL ) ];
$city = self::CITY_POOL[ $seq % count( self::CITY_POOL ) ];
$company = 'Test Co. #' . $seq;
$addr1 = sprintf( '%d %s St.', ( $seq * 13 ) % 9999 + 1, $city );
$addr2 = ( $seq % 3 === 0 ) ? sprintf( 'Apt %d', $seq % 100 ) : '';
$post = sprintf( '%05d', ( $seq * 7 ) % 99999 );
$email = $d['login'] . '@' . self::TEST_EMAIL_DOMAIN;
$phone = sprintf( '+886-%d-%07d', mt_rand( 2, 9 ), mt_rand( 1000000, 9999999 ) );
$values = array( $uid );
$formats = array( '%d' );
// billing block
$billing_values = array(
'billing_first_name' => $first,
'billing_last_name' => $last,
'billing_company' => $company,
'billing_address_1' => $addr1,
'billing_address_2' => $addr2,
'billing_city' => $city,
'billing_state' => $state,
'billing_postcode' => $post,
'billing_country' => $country,
'billing_email' => $email,
'billing_phone' => $phone,
);
foreach ( self::BILLING_KEYS as $k ) {
$values[] = $billing_values[ $k ];
$formats[] = '%s';
}
// shipping block — 60% of users have shipping = billing, rest different
$same_address = ( $seq % 5 ) < 3;
foreach ( self::SHIPPING_KEYS as $k ) {
$billing_equiv = str_replace( 'shipping_', 'billing_', $k );
if ( isset( $billing_values[ $billing_equiv ] ) && $same_address ) {
$values[] = $billing_values[ $billing_equiv ];
} else {
$values[] = $billing_values[ $billing_equiv ] ?? '';
}
$formats[] = '%s';
}
$rows[] = $wpdb->prepare( '(' . implode( ',', $formats ) . ')', ...$values );
}
if ( ! empty( $rows ) ) {
$wpdb->query( "INSERT INTO `{$tbl}` ({$col_sql}) VALUES " . implode( ',', $rows ) );
}
}
/**
* v2.7.0 hp_user — HivePress favorites (json array) + avatar attachment.
*
* @since 2.8.2
*/
private static function insert_flat_hp_user( array $seq_to_id ): void {
global $wpdb;
$tbl = "{$wpdb->prefix}wpdo_user_hp_user";
if ( ! self::table_exists( $tbl ) ) {
return;
}
$rows = array();
foreach ( $seq_to_id as $seq => $uid ) {
$num_favs = $seq % 5; // 0..4 favorites
$favs = array();
for ( $i = 0; $i < $num_favs; $i++ ) {
$favs[] = 1700 + ( ( $seq + $i ) % 60 ); // listing IDs 1700-1759
}
$favs_json = wp_json_encode( $favs );
$hp_image = ( $seq % 4 === 0 ) ? (string) ( 2000 + ( $seq % 100 ) ) : '';
$rows[] = $wpdb->prepare( '(%d,%s,%s)', $uid, $favs_json, $hp_image );
}
if ( ! empty( $rows ) ) {
$wpdb->query( "INSERT INTO `{$tbl}` (user_id, hp_favorited_listings, hp_image) VALUES " . implode( ',', $rows ) );
}
}
private static function insert_points_ledger( array $seq_to_id ): void {
global $wpdb;
$tbl = "{$wpdb->prefix}wpdo_user_points_ledger";
if ( ! self::table_exists( $tbl ) ) {
return;
}
$reasons = array( 'signup_bonus', 'order_reward', 'referral', 'promo', 'manual_adjust' );
$rows = array();
$created = current_time( 'mysql', true );
foreach ( $seq_to_id as $seq => $uid ) {
$num_entries = ( $seq % 3 ) + 1; // 1-3 筆
$balance = 0;
for ( $i = 0; $i < $num_entries; $i++ ) {
$delta = mt_rand( -200, 1000 );
$balance += $delta;
$reason = $reasons[ ( $seq + $i ) % count( $reasons ) ];
$rows[] = $wpdb->prepare(
'(%d,%d,%d,%s,%d,%s,%s)',
$uid,
$delta,
$balance,
$reason,
0,
'',
$created
);
}
}
if ( ! empty( $rows ) ) {
$wpdb->query( "INSERT INTO `{$tbl}` (user_id, delta, balance_after, reason, ref_id, ref_type, created_at) VALUES " . implode( ',', $rows ) );
}
}
// ─────────────────────────────────────────────────────────
// 批次寫入:Realistic Mode(走 wp_insert_user + update_user_meta
// ─────────────────────────────────────────────────────────
private static function run_batch_realistic( int $count ): int {
// Realistic mode:走 wp_insert_user + update_user_meta,每個 user 1-3 秒。
// 加 wall-clock deadline,避免 PHP-FPM / nginx 504 Gateway Timeout(預設 60 秒)。
$deadline = microtime( true ) + self::BATCH_DEADLINE_SEC;
$next_seq = self::next_test_user_seq();
$inserted = 0;
for ( $i = 0; $i < $count; $i++ ) {
if ( microtime( true ) > $deadline ) {
break; // 超時:把已完成的回傳,剩餘讓下一個 pump/cron 接手
}
// 使用者點 cancel:每個 user 開始前檢查 — realistic mode 每 user ~3s
// 最壞情況 cancel 後 3 秒內即可中止
if ( false !== get_transient( self::CANCEL_FLAG ) ) {
break;
}
$seq = $next_seq + $i;
$login = self::TEST_USER_PREFIX . $seq;
$email = $login . '@' . self::TEST_EMAIL_DOMAIN;
$first = self::FIRST_NAMES[ $seq % count( self::FIRST_NAMES ) ];
$last = self::LAST_NAMES[ ( $seq * 7 ) % count( self::LAST_NAMES ) ];
$uid = wp_insert_user(
array(
'user_login' => $login,
'user_pass' => self::TEST_PASSWORD,
'user_email' => $email,
'first_name' => $first,
'last_name' => $last,
'display_name' => $first . ' ' . $last . ' #' . $seq,
'role' => 'subscriber',
)
);
if ( is_wp_error( $uid ) ) {
continue;
}
++$inserted;
// 走 update_user_meta,讓 Hook Bus 自然攔截到 flat tables
$meta = self::generate_realistic_meta( $seq );
foreach ( $meta as $key => $value ) {
update_user_meta( $uid, $key, $value );
}
}
return $inserted;
}
private static function generate_realistic_meta( int $seq ): array {
$first = self::FIRST_NAMES[ $seq % count( self::FIRST_NAMES ) ];
$last = self::LAST_NAMES[ ( $seq * 7 ) % count( self::LAST_NAMES ) ];
$login = self::TEST_USER_PREFIX . $seq;
$country = self::COUNTRY_POOL[ $seq % count( self::COUNTRY_POOL ) ];
$city = self::CITY_POOL[ $seq % count( self::CITY_POOL ) ];
$num_favs = $seq % 5;
$favs = array();
for ( $i = 0; $i < $num_favs; $i++ ) {
$favs[] = 1700 + ( ( $seq + $i ) % 60 );
}
return array(
// hot
'_money_spent' => mt_rand( 0, 1000000 ) / 100,
'_order_count' => mt_rand( 0, 50 ),
'_last_order' => time() - mt_rand( 0, 365 * DAY_IN_SECONDS ),
// membership
'membership_level' => self::MEMBERSHIP_LEVELS[ $seq % count( self::MEMBERSHIP_LEVELS ) ],
'points_balance' => mt_rand( 0, 50000 ),
'membership_expires_at' => gmdate( 'Y-m-d H:i:s', time() + mt_rand( 30, 730 ) * DAY_IN_SECONDS ),
'membership_activated_at' => gmdate( 'Y-m-d H:i:s', time() - mt_rand( 1, 365 ) * DAY_IN_SECONDS ),
'tier_source' => self::TIER_SOURCES[ ( $seq * 3 ) % count( self::TIER_SOURCES ) ],
// activity
'login_count' => mt_rand( 1, 500 ),
'last_active_at' => gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 30 * DAY_IN_SECONDS ) ),
'last_login_at' => gmdate( 'Y-m-d H:i:s', time() - mt_rand( 0, 7 * DAY_IN_SECONDS ) ),
'session_count' => mt_rand( 0, 200 ),
// profile
'specialties' => array_slice( self::SPECIALTIES_POOL, $seq % 7, ( $seq % 3 ) + 1 ),
'bio_url' => 'https://example.com/test' . $seq,
'locale' => self::LOCALES[ $seq % count( self::LOCALES ) ],
// sso
'hub_global_user_id' => 'hub_' . wp_generate_password( 16, false ),
'sso_login_count' => mt_rand( 1, 100 ),
// v2.7.0 core_profile (note: nickname/first_name/last_name are also
// set via wp_insert_user, but description is unique to this group)
'description' => self::BIO_POOL[ $seq % count( self::BIO_POOL ) ] . ' #' . $seq,
// v2.7.0 social — populate 3 of 15 to mimic real vendor profile usage
'facebook' => sprintf( 'https://facebook.com/%s', $login ),
'instagram' => sprintf( 'https://instagram.com/%s', $login ),
'youtube' => sprintf( 'https://youtube.com/@%s', $login ),
// v2.7.0 commerce (subset; 5 representative billing fields)
'billing_first_name' => $first,
'billing_last_name' => $last,
'billing_email' => $login . '@' . self::TEST_EMAIL_DOMAIN,
'billing_country' => $country,
'billing_city' => $city,
// v2.7.0 hp_user
'hp_favorited_listings' => $favs,
'hp_image' => ( $seq % 4 === 0 ) ? (string) ( 2000 + ( $seq % 100 ) ) : '',
);
}
// ─────────────────────────────────────────────────────────
// Benchmark
// ─────────────────────────────────────────────────────────
private static function finalize( array $state ): void {
// 清掉殘留的 cron event(避免完成後仍有過期 event)
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 );
}
/**
* 執行 benchmark:寫入指標 + DB 容量 + 查詢效能。
*/
public static function run_benchmark( ?array $state = null ): array {
global $wpdb;
$state = $state ?? self::get_state();
// 1. 寫入指標
$write_metrics = self::compute_write_metrics( $state );
// 2. DB 容量
$db_sizes = self::measure_db_sizes();
// 3. 查詢效能
$query_perf = self::measure_query_performance();
return array(
'generated_at' => time(),
'write' => $write_metrics,
'db_sizes' => $db_sizes,
'query' => $query_perf,
);
}
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'] ?? '',
'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 ),
);
}
private static function measure_db_sizes(): array {
global $wpdb;
$tables = array_merge( array( $wpdb->users, $wpdb->usermeta ), self::get_user_flat_tables() );
$placeholders = implode( ',', array_fill( 0, count( $tables ), '%s' ) );
// MySQL only — SQLite 不支援 information_schema.TABLES,跳過
if ( ! self::is_mysql() ) {
return array_map(
fn( $t ) => array(
'table' => $t,
'rows' => self::table_row_count( $t ),
'size_mb' => null,
),
$tables
);
}
$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 ( $rows as $r ) {
$out[] = array(
'table' => $r['t'],
'rows' => (int) $r['rows_count'],
'data_mb' => round( $r['dl'] / 1048576, 2 ),
'index_mb' => round( $r['il'] / 1048576, 2 ),
'total_mb' => round( ( $r['dl'] + $r['il'] ) / 1048576, 2 ),
'avg_bytes' => $r['rows_count'] > 0 ? (int) ( ( $r['dl'] + $r['il'] ) / $r['rows_count'] ) : 0,
);
}
return $out;
}
private static function measure_query_performance(): array {
global $wpdb;
$sample_ids = $wpdb->get_col(
"SELECT ID FROM {$wpdb->users} WHERE user_login LIKE '" . esc_sql( self::TEST_USER_PREFIX ) . "%' ORDER BY ID DESC LIMIT 100"
);
$sample_ids = array_map( 'intval', $sample_ids );
if ( empty( $sample_ids ) ) {
return array( 'note' => 'no_test_users_found' );
}
$results = array();
// Q1: 讀單一 meta(透過 Hook Bus / get_user_meta 攔截路徑),走 flat table
$results['get_field_membership_level'] = self::time_calls(
function () use ( $sample_ids ) {
foreach ( $sample_ids as $id ) {
get_user_meta( $id, 'membership_level', true );
}
},
count( $sample_ids )
);
// Q2: 讀多個 meta key(模擬整 entity 讀取,覆蓋多個 group
$multi_keys = array(
'membership_level', // membership group
'points_balance', // membership
'login_count', // activity
'last_active_at', // activity
'_money_spent', // hot
'specialties', // profile
'locale', // profile
);
$results['get_entity_full'] = self::time_calls(
function () use ( $sample_ids, $multi_keys ) {
foreach ( $sample_ids as $id ) {
foreach ( $multi_keys as $key ) {
get_user_meta( $id, $key, true );
}
}
},
count( $sample_ids )
);
// Q3: 索引範圍查詢 — gold 等級且 points > 5000 的用戶總數
$tbl = $wpdb->prefix . 'wpdo_user_membership';
if ( self::table_exists( $tbl ) ) {
$results['range_gold_high_points'] = self::time_query(
"SELECT COUNT(*) FROM `{$tbl}` WHERE membership_level = 'gold' AND points_balance > 5000"
);
}
// Q4: 排序查詢 — last_active_at DESC LIMIT 100
$tbl = $wpdb->prefix . 'wpdo_user_activity';
if ( self::table_exists( $tbl ) ) {
$results['sort_recent_active_100'] = self::time_query(
"SELECT user_id, last_active_at FROM `{$tbl}` ORDER BY last_active_at DESC LIMIT 100"
);
}
// Q5: JOIN — top 100 most active gold members
$mtbl = $wpdb->prefix . 'wpdo_user_membership';
$atbl = $wpdb->prefix . 'wpdo_user_activity';
if ( self::table_exists( $mtbl ) && self::table_exists( $atbl ) ) {
$results['join_top_gold_active'] = self::time_query(
"SELECT m.user_id, m.points_balance, a.login_count
FROM `{$mtbl}` m
JOIN `{$atbl}` a ON a.user_id = m.user_id
WHERE m.membership_level = 'gold'
ORDER BY a.last_active_at DESC LIMIT 100"
);
}
// Q6 baseline:原生 EAV 等價查詢(usermeta 範圍查詢)
$results['eav_range_baseline'] = self::time_query(
"SELECT COUNT(*) FROM {$wpdb->usermeta} m1
JOIN {$wpdb->usermeta} m2 ON m1.user_id = m2.user_id
WHERE m1.meta_key = 'membership_level' AND m1.meta_value = 'gold'
AND m2.meta_key = 'points_balance' AND CAST(m2.meta_value AS UNSIGNED) > 5000"
);
// v2.8.2 — exercise v2.7.0 groups too:
// Q7: billing_email indexed lookup (commerce group, the only searchable
// commerce field; representative WC customer-search workload)
$ctbl = $wpdb->prefix . 'wpdo_user_commerce';
if ( self::table_exists( $ctbl ) ) {
$sample_email = self::TEST_USER_PREFIX . ( $sample_ids[0] ?? 1 ) . '@' . self::TEST_EMAIL_DOMAIN;
$results['commerce_email_lookup'] = self::time_query(
$wpdb->prepare(
"SELECT user_id FROM `{$ctbl}` WHERE billing_email = %s LIMIT 1",
$sample_email
)
);
}
// Q8: core_profile fulltext-ish search (display_name lookup pattern)
$cptbl = $wpdb->prefix . 'wpdo_user_core_profile';
if ( self::table_exists( $cptbl ) ) {
$results['core_profile_first_name_scan'] = self::time_query(
"SELECT user_id, first_name FROM `{$cptbl}` WHERE first_name = 'Alice' LIMIT 100"
);
}
// Q9: hp_user JSON read (favorites count distribution — N+1 pattern through Hook Bus)
$hpttbl = $wpdb->prefix . 'wpdo_user_hp_user';
if ( self::table_exists( $hpttbl ) ) {
$results['hp_favorites_full_scan'] = self::time_query(
"SELECT user_id, hp_favorited_listings FROM `{$hpttbl}` LIMIT 100"
);
}
// Q10: simulate a realistic "render a vendor profile" — read 1 user across
// ALL 9 groups via Hook Bus (=10 get_user_meta calls hitting flat tables)
if ( ! empty( $sample_ids ) ) {
$probe_keys = array(
'membership_level',
'points_balance', // membership
'last_active_at',
'login_count', // activity
'specialties',
'locale', // profile
'hub_global_user_id',
'sso_login_count', // sso
'nickname',
'first_name',
'last_name',
'description', // core_profile
'facebook',
'instagram',
'youtube', // social
'billing_email',
'billing_country',
'billing_first_name', // commerce
'hp_favorited_listings',
'hp_image', // hp_user
);
$results['hook_bus_full_profile_render'] = self::time_calls(
function () use ( $sample_ids, $probe_keys ) {
$first_id = $sample_ids[0];
foreach ( $probe_keys as $key ) {
get_user_meta( $first_id, $key, true );
}
},
count( $probe_keys )
);
}
return $results;
}
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,
);
}
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 ),
);
}
// ─────────────────────────────────────────────────────────
// 輔助
// ─────────────────────────────────────────────────────────
private static function get_user_flat_tables(): array {
global $wpdb;
return array_filter(
array_map(
fn( $name ) => $wpdb->prefix . 'wpdo_user_' . $name,
array(
// v2.5.x groups
'hot',
'cold',
'membership',
'activity',
'profile',
'sso',
'points_ledger',
// v2.7.0 groups
'core_profile',
'social',
'commerce',
'hp_user',
// v2.8.4 group
'admin_prefs',
)
),
array( __CLASS__, 'table_exists' )
);
}
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 ];
}
private static function table_row_count( string $table ): int {
global $wpdb;
return self::table_exists( $table ) ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ) : 0;
}
private static function is_mysql(): bool {
return ! ( class_exists( 'WP_SQLite_DB' ) || class_exists( 'WP_SQLite_Translator' ) || class_exists( 'WP_SQLite_Driver' ) );
}
}