terms / wp_term_taxonomy / wp_termmeta are WP-managed; meta_key strings are static class constants; user-controlled values use prepare() placeholders. /** * Async bulk fixture generator for term entity stress tests. */ final class TMDO_Term_Stress_Tester { /** Slug prefix for stress test terms — used for cleanup matching. */ public const TEST_TERM_PREFIX = 'wpdo-stress-'; /** Hard cap to prevent runaway calls. */ public const MAX_COUNT = 100000; // State machine constants (mirror post v2.11.4). public const OPT_STATE = 'wpdo_term_stress_test_state'; public const CRON_HOOK = 'wpdo_term_stress_test_batch'; public const CANCEL_FLAG = 'wpdo_term_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-key seed map (single hp_taxonomy group). All keys go to * `wp_wpdo_term_hp_taxonomy` flat table when term mode is dual_write+. * * @var array|null */ private static ?array $seed_map_cache = null; // ───────────────────────────────────────────────────────────────────────── // Public API — sync helpers (also used internally by state machine batches) // ───────────────────────────────────────────────────────────────────────── /** * Bulk-create N stress test terms in the given taxonomy via direct SQL * (Fast mode — bypasses WP filter chain). Use for fixture loading where * Hook Bus routing is not the goal. * * @param string $taxonomy WP taxonomy slug (e.g., listing_category, category). * @param int $count Number of terms to insert. Capped at MAX_COUNT. * @return array{created:int,taxonomy:string,first_id:int|null,last_id:int|null} * @throws InvalidArgumentException When inputs invalid. */ public static function create( string $taxonomy, int $count ): array { self::validate_inputs( $taxonomy, $count ); global $wpdb; $first_id = null; $last_id = null; $created = 0; $seed_map = self::seed_map(); for ( $i = 0; $i < $count; $i++ ) { $suffix = wp_generate_password( 8, false ); $name = 'WPDO Stress ' . $taxonomy . ' ' . $suffix; $slug = self::TEST_TERM_PREFIX . sanitize_title( $taxonomy . '-' . $suffix ); $ok = $wpdb->insert( $wpdb->terms, array( 'name' => $name, 'slug' => $slug, 'term_group' => 0, ) ); if ( ! $ok ) { continue; } $term_id = (int) $wpdb->insert_id; $ok2 = $wpdb->insert( $wpdb->prefix . 'term_taxonomy', array( 'term_id' => $term_id, 'taxonomy' => $taxonomy, 'description' => '', 'parent' => 0, 'count' => 0, ) ); if ( ! $ok2 ) { $wpdb->delete( $wpdb->terms, array( 'term_id' => $term_id ) ); continue; } if ( null === $first_id ) { $first_id = $term_id; } $last_id = $term_id; ++$created; // Direct INSERT to wp_termmeta (bypassing Hook Bus). For aeav_only // mode validation, use create_realistic() instead. foreach ( $seed_map as $meta_key => $value_spec ) { $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec; $wpdb->insert( $wpdb->termmeta, array( 'term_id' => $term_id, 'meta_key' => $meta_key, 'meta_value' => (string) $value, ) ); } } return array( 'created' => $created, 'taxonomy' => $taxonomy, 'first_id' => $first_id, 'last_id' => $last_id, ); } /** * Realistic-mode counterpart — uses wp_insert_term() + update_term_meta() * so Hook Bus / entity registry / mode_manager all engage on the write * path. Use this for validating mode=dual_write+ behavior. * * @param string $taxonomy WP taxonomy slug. * @param int $count Number of terms to insert. * @return array{created:int,taxonomy:string,mode:string,first_id:int|null,last_id:int|null} * @throws InvalidArgumentException When inputs invalid. */ public static function create_realistic( string $taxonomy, int $count ): array { self::validate_inputs( $taxonomy, $count ); $first_id = null; $last_id = null; $created = 0; $seed_map = self::seed_map(); for ( $i = 0; $i < $count; $i++ ) { $suffix = wp_generate_password( 8, false ); $name = 'WPDO Stress ' . $taxonomy . ' ' . $suffix; $slug = self::TEST_TERM_PREFIX . sanitize_title( $taxonomy . '-' . $suffix ); $result = wp_insert_term( $name, $taxonomy, array( 'slug' => $slug ) ); if ( is_wp_error( $result ) ) { continue; } $term_id = (int) ( $result['term_id'] ?? 0 ); if ( 0 === $term_id ) { continue; } if ( null === $first_id ) { $first_id = $term_id; } $last_id = $term_id; ++$created; foreach ( $seed_map as $meta_key => $value_spec ) { $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec; update_term_meta( $term_id, $meta_key, $value ); } } return array( 'created' => $created, 'taxonomy' => $taxonomy, 'mode' => 'realistic', 'first_id' => $first_id, 'last_id' => $last_id, ); } /** * Count terms whose slug begins with TEST_TERM_PREFIX. * * @return int */ public static function count_test_terms(): int { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->terms} WHERE slug LIKE %s", $wpdb->esc_like( self::TEST_TERM_PREFIX ) . '%' ) ); } /** * Delete every stress-test term + its term_taxonomy + termmeta rows + flat * table rows in wpdo_term_*. * * @return array{deleted_terms:int,deleted_meta:int,deleted_flat:int} */ public static function cleanup(): array { global $wpdb; // Cancel any in-flight job + clear pump lock. set_transient( self::CANCEL_FLAG, 1, 600 ); wp_clear_scheduled_hook( self::CRON_HOOK ); $term_ids = $wpdb->get_col( $wpdb->prepare( "SELECT term_id FROM {$wpdb->terms} WHERE slug LIKE %s", $wpdb->esc_like( self::TEST_TERM_PREFIX ) . '%' ) ); if ( empty( $term_ids ) ) { return array( 'deleted_terms' => 0, 'deleted_meta' => 0, 'deleted_flat' => 0, ); } $id_list = implode( ',', array_map( 'absint', $term_ids ) ); $flat_deleted = 0; // Cascade flat tables first (best-effort — tables may not exist yet). foreach ( self::get_term_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 term_id IN ({$id_list})" ); $flat_deleted += $rows; } // Delete termmeta. $meta_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->termmeta} WHERE term_id IN ({$id_list})" ); // Delete term_taxonomy. $wpdb->query( "DELETE FROM {$wpdb->prefix}term_taxonomy WHERE term_id IN ({$id_list})" ); // Delete terms. $term_deleted = (int) $wpdb->query( "DELETE FROM {$wpdb->terms} WHERE term_id IN ({$id_list})" ); // Reset state. delete_option( self::OPT_STATE ); delete_transient( self::CANCEL_FLAG ); // Clean WP term caches per term_id (clean_term_cache works on arrays). clean_term_cache( array_map( 'absint', $term_ids ) ); return array( 'deleted_terms' => $term_deleted, 'deleted_meta' => $meta_deleted, 'deleted_flat' => $flat_deleted, ); } // ───────────────────────────────────────────────────────────────────────── // State machine // ───────────────────────────────────────────────────────────────────────── /** * Start an async stress run. Persists state, schedules the first batch * via wp_schedule_single_event(). First batch is pushed by cron or by * pump_if_due() on next polling request. * * @param string $taxonomy Taxonomy slug. * @param int $target Total terms to create. * @param string $mode MODE_FAST | MODE_REALISTIC. * @param int $batch_size Per-batch insert count. * @return array{ok:bool,error?:string,state?:array} */ public static function start( string $taxonomy, int $target, string $mode = self::MODE_FAST, int $batch_size = self::DEFAULT_BATCH_SIZE ): array { if ( '' === $taxonomy || ! taxonomy_exists( $taxonomy ) ) { return array( 'ok' => false, 'error' => 'unknown_taxonomy: ' . $taxonomy, ); } 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( 'tstress_', true ), 'status' => 'running', 'mode' => $mode, 'taxonomy' => $taxonomy, '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 ); 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. * * @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. * * @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. Opportunistically pumps * if cron is overdue. * * @param bool $pump When true, pump_if_due() runs. * @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_term_count' => self::count_test_terms(), ) ); } /** * Opportunistic batch pump (transient-locked). * * @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_term_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. Runs one batch, reschedules or finalizes. * * @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; } $taxonomy = (string) ( $state['taxonomy'] ?? '' ); $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( $taxonomy, $this_batch_size ); } else { $inserted = self::run_batch_realistic( $taxonomy, $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', 'term_batch_failed', $e->getMessage() ); } return; } $batch_elapsed = microtime( true ) - $batch_started; // Re-read state — cancel() may have modified status mid-batch. $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. * * @param string $taxonomy Taxonomy slug. * @param int $count Batch size. * @return int Inserted count. */ private static function run_batch_fast( string $taxonomy, int $count ): int { $result = self::create( $taxonomy, $count ); return (int) ( $result['created'] ?? 0 ); } /** * Run one realistic-mode batch with deadline + cancel check per term. * * @param string $taxonomy Taxonomy slug. * @param int $count Batch size. * @return int Inserted count. */ private static function run_batch_realistic( string $taxonomy, int $count ): int { $deadline = microtime( true ) + self::BATCH_DEADLINE_SEC; $inserted = 0; $seed_map = self::seed_map(); for ( $i = 0; $i < $count; $i++ ) { if ( microtime( true ) > $deadline ) { break; } if ( false !== get_transient( self::CANCEL_FLAG ) ) { break; } $suffix = wp_generate_password( 8, false ); $name = 'WPDO Stress ' . $taxonomy . ' ' . $suffix; $slug = self::TEST_TERM_PREFIX . sanitize_title( $taxonomy . '-' . $suffix ); $result = wp_insert_term( $name, $taxonomy, array( 'slug' => $slug ) ); if ( is_wp_error( $result ) ) { continue; } $term_id = (int) ( $result['term_id'] ?? 0 ); if ( 0 === $term_id ) { continue; } ++$inserted; foreach ( $seed_map as $meta_key => $value_spec ) { $value = is_callable( $value_spec ) ? $value_spec( $i ) : $value_spec; update_term_meta( $term_id, $meta_key, $value ); } } return $inserted; } /** * Finalize: clear cron, run benchmark, persist completed state. * * @param array $state Pre-finalize state. * @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 benchmark on the current dataset. * * @param array|null $state Optional state snapshot. * @return array */ public static function run_benchmark( ?array $state = null ): array { $state = $state ?? self::get_state(); $taxonomy = (string) ( $state['taxonomy'] ?? '' ); return array( 'generated_at' => time(), 'taxonomy' => $taxonomy, 'write' => self::compute_write_metrics( $state ), 'db_sizes' => self::measure_db_sizes(), 'query' => self::measure_query_performance( $taxonomy ), ); } /** * Write throughput metrics from state. * * @param array $state 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'] ?? '', 'taxonomy' => $state['taxonomy'] ?? '', '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 term-related tables. * * @return array */ private static function measure_db_sizes(): array { global $wpdb; $tables = array( $wpdb->terms, $wpdb->prefix . 'term_taxonomy', $wpdb->termmeta, ); foreach ( self::get_term_flat_tables() as $tbl ) { if ( self::table_exists( $tbl ) ) { $tables[] = $tbl; } } 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 for term entity: * - point lookup on hp_default flat * - range scan on hp_sort_order flat * - EAV baseline on wp_termmeta * * @param string $taxonomy Taxonomy slug. * @return array */ private static function measure_query_performance( string $taxonomy ): array { global $wpdb; $flat = $wpdb->prefix . 'wpdo_term_hp_taxonomy'; if ( ! self::table_exists( $flat ) ) { return array( 'note' => 'flat_table_missing' ); } return array( 'point_default' => self::time_query( "SELECT term_id FROM `{$flat}` WHERE hp_default = '1' LIMIT 100" ), 'range_sort_top' => self::time_query( "SELECT term_id FROM `{$flat}` WHERE hp_sort_order > 0 ORDER BY hp_sort_order DESC LIMIT 100" ), 'eav_baseline' => self::time_query( "SELECT term_id FROM {$wpdb->termmeta} WHERE meta_key = 'hp_default' AND meta_value = '1' LIMIT 100" ), ); } // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── /** * Validate inputs for create() / create_realistic(). * * @param string $taxonomy Taxonomy slug. * @param int $count Count to insert. * @return void * @throws InvalidArgumentException When invalid. */ private static function validate_inputs( string $taxonomy, int $count ): void { if ( '' === $taxonomy ) { throw new InvalidArgumentException( 'Taxonomy required.' ); } if ( $count <= 0 ) { throw new InvalidArgumentException( 'Count must be > 0.' ); } if ( $count > self::MAX_COUNT ) { $msg = 'Count exceeds MAX_COUNT (' . self::MAX_COUNT . ').'; throw new InvalidArgumentException( $msg ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } } /** * Lazy-built seed map for hp_taxonomy group. * * @return array */ private static function seed_map(): array { if ( null !== self::$seed_map_cache ) { return self::$seed_map_cache; } self::$seed_map_cache = array( 'hp_sort_order' => static fn( int $i ) => (string) ( ( $i % 100 ) + 1 ), 'hp_default' => static fn( int $i ) => 0 === $i % 50 ? '1' : '0', 'hp_icon' => static fn( int $i ) => 'fa-icon-' . ( $i % 10 ), ); return self::$seed_map_cache; } /** * Names of all wp_wpdo_term_* flat tables that cleanup should sweep. * * @return string[] */ private static function get_term_flat_tables(): array { global $wpdb; $prefix = $wpdb->prefix . 'wpdo_term_'; return array( $prefix . 'hp_taxonomy', $prefix . 'misc', ); } /** * Memoized table-exists probe. * * @param string $table 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 ]; } /** * Cheap row count helper (SQLite fallback). * * @param string $table 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}`" ); } /** * Time a SQL query. * * @param string $sql Query. * @return array{duration_ms:float} */ 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 ) ); } /** * Detect MySQL vs SQLite. * * @return bool */ private static function is_mysql(): bool { return ! ( class_exists( 'WP_SQLite_DB' ) || class_exists( 'WP_SQLite_Translator' ) || class_exists( 'WP_SQLite_Driver' ) ); } }