f524ea3f16
- 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
1047 lines
35 KiB
PHP
1047 lines
35 KiB
PHP
<?php
|
||
/**
|
||
* 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
|
||
*/
|
||
|
||
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
|
||
|
||
declare(strict_types=1);
|
||
|
||
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
|
||
* (5–11 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',
|
||
);
|
||
}
|
||
}
|