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>|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 */ 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> */ 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', ); } }