chore: initial snapshot of 2meet-data-optimizer v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
@@ -0,0 +1,880 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform stress tester: intentional raw meta SQL for baseline comparison
|
||||
/**
|
||||
* TMDO_Term_Stress_Tester — Async stress fixture generator for term entity (v2.13.0).
|
||||
*
|
||||
* Mirrors TMDO_Post_Stress_Tester (v2.11.4) for the term entity. Provides:
|
||||
*
|
||||
* - State machine: start / cancel / get_progress / pump_if_due / run_batch / finalize
|
||||
* - Fast mode: bulk INSERT to wp_terms + wp_term_taxonomy + wp_termmeta + flat
|
||||
* - Realistic mode: wp_insert_term + update_term_meta (Hook Bus auto-routes)
|
||||
* - Cron pump for nginx-safe long runs
|
||||
* - Per-batch wall-clock deadline (BATCH_DEADLINE_SEC)
|
||||
* - Benchmark on completion: write metrics + DB sizes + query performance
|
||||
*
|
||||
* Test terms use slug prefix `wpdo-stress-` for unambiguous identification —
|
||||
* cleanup() deletes only those + all matching meta + flat rows.
|
||||
*
|
||||
* 🔒 v2.13.x frozen contract: never touches user / post / comment entities or
|
||||
* their flat tables.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.13.0
|
||||
*/
|
||||
|
||||
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->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<string,mixed>|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( 'term_stress_test_batch_failed', array( 'message' => $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<string,mixed>
|
||||
*/
|
||||
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' ) );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user