8fc1dc1f1c
- Seed a HivePress-shaped dataset three ways (native meta, entity tables, hot table) with flat tables built by TMDO's own schema code - Refuse any database not named *_bench before the bootstrap connects; bootstrap now honours a pre-defined ABSPATH for the wp-admin stub - Full scale seeds 9.5M postmeta rows in 7m25s; 0 value mismatches Verify: TMDO_TEST_DB_NAME=wp_wpdo_bench php tests/bench/seed.php --vendors=100000 --listings=1000000; unit 600 OK, integration 416 OK PR: PR-004
373 lines
14 KiB
PHP
373 lines
14 KiB
PHP
<?php
|
|
/**
|
|
* Scale-benchmark data seeder (dev only — `tests/` is excluded from release packages).
|
|
*
|
|
* Seeds a HivePress-shaped dataset into a dedicated `*_bench` database:
|
|
* vendors (users + hp_vendor posts) and listings (hp_listing posts), stored three
|
|
* ways so PR-005 can compare them at 100k vendors / 1M listings:
|
|
* 1. native — wp_posts / wp_postmeta / wp_users / wp_usermeta
|
|
* 2. entity — wpdo_post_hp_listing_core, wpdo_post_hp_vendor_core, wpdo_user_hp_user
|
|
* 3. hot — wpdo_hot_hp_listing (created by TMDO_Installer::create_hot_table())
|
|
*
|
|
* Values are derived from crc32( seed:index:key ), so every row is reproducible
|
|
* without sequential RNG state.
|
|
*
|
|
* @package TMDO\Tests\Bench
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
final class TMDO_Bench_Seeder {
|
|
|
|
public const MAX_ROWS = 2000000;
|
|
|
|
/** 2026-01-01 00:00:00 UTC. */
|
|
private const BASE_TS = 1767225600;
|
|
|
|
private const HOT_COLUMNS = array(
|
|
'hp_price' => 'decimal(12,2) NOT NULL DEFAULT 0',
|
|
'hp_featured' => 'tinyint(1) NOT NULL DEFAULT 0',
|
|
'hp_verified' => 'tinyint(1) NOT NULL DEFAULT 0',
|
|
'hp_vendor' => 'bigint(20) NOT NULL DEFAULT 0',
|
|
'hp_expired_time' => 'bigint(20) NOT NULL DEFAULT 0',
|
|
);
|
|
|
|
private ?mysqli $db;
|
|
private string $prefix;
|
|
private int $vendors;
|
|
private int $listings;
|
|
private int $seed;
|
|
private int $batch;
|
|
|
|
/** @var array<string, array{cols: string[], rows: string[]}> Pending multi-row INSERTs per table. */
|
|
private array $buffers = array();
|
|
|
|
/**
|
|
* Refuse to run against anything that is not a dedicated bench database.
|
|
*
|
|
* @throws InvalidArgumentException When the name does not end in `_bench`.
|
|
*/
|
|
public static function assert_bench_db( string $db_name ): void {
|
|
if ( ! preg_match( '/^[A-Za-z0-9_]+_bench$/', $db_name ) ) {
|
|
throw new InvalidArgumentException( "Refusing to seed '{$db_name}': database name must end in _bench." );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws InvalidArgumentException When a count is out of range.
|
|
*/
|
|
public static function assert_count( string $label, int $value ): void {
|
|
if ( $value < 1 || $value > self::MAX_ROWS ) {
|
|
throw new InvalidArgumentException( "--{$label} must be between 1 and " . self::MAX_ROWS . ", got {$value}." );
|
|
}
|
|
}
|
|
|
|
public function __construct( ?mysqli $db, string $prefix, int $vendors, int $listings, int $seed = 42, int $batch = 2000 ) {
|
|
$this->db = $db;
|
|
$this->prefix = $prefix;
|
|
$this->vendors = $vendors;
|
|
$this->listings = $listings;
|
|
$this->seed = $seed;
|
|
$this->batch = max( 1, $batch );
|
|
}
|
|
|
|
// ── Deterministic generators (pure — unit tested) ───────────────────────
|
|
|
|
private function det( int $i, string $key, int $mod ): int {
|
|
return crc32( "{$this->seed}:{$i}:{$key}" ) % $mod;
|
|
}
|
|
|
|
/** 1-based vendor index (1..vendors) that owns listing $i. */
|
|
public function listing_vendor( int $i ): int {
|
|
return $this->det( $i, 'vendor', $this->vendors ) + 1;
|
|
}
|
|
|
|
/** 90% publish, 5% draft, 3% pending, 2% private. */
|
|
public function listing_status( int $i ): string {
|
|
$r = $this->det( $i, 'status', 100 );
|
|
if ( $r < 90 ) {
|
|
return 'publish';
|
|
}
|
|
if ( $r < 95 ) {
|
|
return 'draft';
|
|
}
|
|
return $r < 98 ? 'pending' : 'private';
|
|
}
|
|
|
|
/**
|
|
* Meta a HivePress listing carries. Keys absent from the array are not stored natively.
|
|
*
|
|
* @return array<string, int|string>
|
|
*/
|
|
public function listing_meta( int $i, int $vendor_post_id ): array {
|
|
$featured = $this->det( $i, 'featured', 100 ) < 5 ? 1 : 0;
|
|
$meta = array(
|
|
'hp_price' => sprintf( '%d.00', 100 + $this->det( $i, 'price', 99901 ) ),
|
|
'hp_status' => $this->listing_status( $i ),
|
|
'hp_featured' => $featured,
|
|
'hp_verified' => $this->det( $i, 'verified', 100 ) < 30 ? 1 : 0,
|
|
'hp_vendor' => $vendor_post_id,
|
|
'hp_expired_time' => self::BASE_TS + $this->det( $i, 'expire', 365 ) * 86400,
|
|
'hp_view_count' => $this->det( $i, 'views', 5000 ),
|
|
'hp_rating' => sprintf( '%.2f', 1 + $this->det( $i, 'rating', 401 ) / 100 ),
|
|
'hp_rating_count' => $this->det( $i, 'rating_count', 200 ),
|
|
);
|
|
if ( $featured ) {
|
|
$meta['hp_featured_time'] = self::BASE_TS + $this->det( $i, 'featured_until', 90 ) * 86400;
|
|
}
|
|
return $meta;
|
|
}
|
|
|
|
/** @return array<string, int|string> */
|
|
public function vendor_meta( int $v ): array {
|
|
return array(
|
|
'hp_user' => $v,
|
|
'hp_verified' => $this->det( $v, 'v_verified', 100 ) < 40 ? 1 : 0,
|
|
'hp_hourly_rate' => sprintf( '%d.00', 20 + $this->det( $v, 'rate', 281 ) ),
|
|
'hp_rating' => sprintf( '%.2f', 1 + $this->det( $v, 'v_rating', 401 ) / 100 ),
|
|
'hp_rating_count' => $this->det( $v, 'v_rating_count', 500 ),
|
|
);
|
|
}
|
|
|
|
/** @return array<string, int|string> */
|
|
public function user_meta( int $v ): array {
|
|
return array(
|
|
'first_name' => 'Vendor',
|
|
'last_name' => (string) $v,
|
|
'nickname' => "vendor_{$v}",
|
|
'description' => "Bench vendor {$v}",
|
|
'hp_verified' => $this->det( $v, 'u_verified', 100 ) < 40 ? 1 : 0,
|
|
'hp_image' => (string) ( 100000 + $v ),
|
|
);
|
|
}
|
|
|
|
// ── Seeding ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Drop and rebuild every bench table, then load the dataset.
|
|
*
|
|
* @return array<string, int> Row counts per table.
|
|
*/
|
|
public function run(): array {
|
|
$this->exec( 'SET SESSION unique_checks = 0' );
|
|
$this->exec( 'SET SESSION foreign_key_checks = 0' );
|
|
|
|
$this->create_core_tables();
|
|
$this->create_flat_tables();
|
|
|
|
$this->seed_vendors();
|
|
$this->seed_listings();
|
|
|
|
$counts = array();
|
|
foreach ( array_keys( $this->table_names() ) as $table ) {
|
|
$counts[ $table ] = (int) $this->db->query( "SELECT COUNT(*) FROM `{$table}`" )->fetch_row()[0];
|
|
}
|
|
return $counts;
|
|
}
|
|
|
|
/** @return array<string, true> Every table this seeder owns. */
|
|
private function table_names(): array {
|
|
$p = $this->prefix;
|
|
return array_fill_keys(
|
|
array(
|
|
"{$p}users",
|
|
"{$p}usermeta",
|
|
"{$p}posts",
|
|
"{$p}postmeta",
|
|
"{$p}wpdo_user_hp_user",
|
|
"{$p}wpdo_post_hp_vendor_core",
|
|
"{$p}wpdo_post_hp_listing_core",
|
|
"{$p}wpdo_hot_hp_listing",
|
|
),
|
|
true
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Core tables with the columns and indexes WordPress 6.x uses for these queries.
|
|
*/
|
|
private function create_core_tables(): void {
|
|
$p = $this->prefix;
|
|
foreach ( array( 'users', 'usermeta', 'posts', 'postmeta' ) as $t ) {
|
|
$this->exec( "DROP TABLE IF EXISTS `{$p}{$t}`" );
|
|
}
|
|
$this->exec(
|
|
"CREATE TABLE `{$p}users` (
|
|
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
|
user_login varchar(60) NOT NULL DEFAULT '',
|
|
user_nicename varchar(50) NOT NULL DEFAULT '',
|
|
user_email varchar(100) NOT NULL DEFAULT '',
|
|
user_registered datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
|
|
display_name varchar(250) NOT NULL DEFAULT '',
|
|
PRIMARY KEY (ID),
|
|
KEY user_login_key (user_login),
|
|
KEY user_nicename (user_nicename),
|
|
KEY user_email (user_email)
|
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
|
|
);
|
|
$this->exec(
|
|
"CREATE TABLE `{$p}usermeta` (
|
|
umeta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
|
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
|
meta_key varchar(255) DEFAULT NULL,
|
|
meta_value longtext,
|
|
PRIMARY KEY (umeta_id),
|
|
KEY user_id (user_id),
|
|
KEY meta_key (meta_key(191))
|
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
|
|
);
|
|
$this->exec(
|
|
"CREATE TABLE `{$p}posts` (
|
|
ID bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
|
post_author bigint(20) unsigned NOT NULL DEFAULT 0,
|
|
post_date datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
|
|
post_title text NOT NULL,
|
|
post_status varchar(20) NOT NULL DEFAULT 'publish',
|
|
post_name varchar(200) NOT NULL DEFAULT '',
|
|
post_parent bigint(20) unsigned NOT NULL DEFAULT 0,
|
|
post_type varchar(20) NOT NULL DEFAULT 'post',
|
|
PRIMARY KEY (ID),
|
|
KEY post_name (post_name(191)),
|
|
KEY type_status_date (post_type,post_status,post_date,ID),
|
|
KEY post_parent (post_parent),
|
|
KEY post_author (post_author)
|
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
|
|
);
|
|
$this->exec(
|
|
"CREATE TABLE `{$p}postmeta` (
|
|
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
|
post_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
|
meta_key varchar(255) DEFAULT NULL,
|
|
meta_value longtext,
|
|
PRIMARY KEY (meta_id),
|
|
KEY post_id (post_id),
|
|
KEY meta_key (meta_key(191))
|
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Flat tables are created by TMDO's own code so the bench measures the real schema.
|
|
*/
|
|
private function create_flat_tables(): void {
|
|
TMDO_Entity_Registry::init();
|
|
TMDO_Entity_Registry::register_adapter( 'post', new TMDO_Adapter_Post() );
|
|
TMDO_Entity_Registry::register_adapter( 'user', new TMDO_Adapter_User() );
|
|
TMDO_Post_Fields::register_entity_fields();
|
|
TMDO_Member_Fields::register();
|
|
|
|
// Schema_Manager keeps a schema hash per group in wpdo_registry_meta and skips
|
|
// creation when it matches, so a rebuilt table needs its hash row cleared too.
|
|
TMDO_Installer::install_v2_tables();
|
|
$registry_meta = $this->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
|
|
|
|
foreach ( array( array( 'post', 'hp_listing_core' ), array( 'post', 'hp_vendor_core' ), array( 'user', 'hp_user' ) ) as [ $type, $group ] ) {
|
|
$this->exec( 'DROP TABLE IF EXISTS `' . TMDO_Schema_Manager::get_table_name( $type, $group ) . '`' );
|
|
$this->exec( "DELETE FROM `{$registry_meta}` WHERE entity_type = '{$type}' AND group_name = '{$group}'" );
|
|
if ( ! TMDO_Schema_Manager::create_or_upgrade_table( $type, $group, TMDO_Entity_Registry::get_group_fields( $type, $group ) ) ) {
|
|
throw new RuntimeException( "Failed to create entity table {$type}/{$group}." );
|
|
}
|
|
}
|
|
|
|
$this->exec( "DROP TABLE IF EXISTS `{$this->prefix}wpdo_hot_hp_listing`" );
|
|
TMDO_Installer::create_hot_table( 'hp_listing', self::HOT_COLUMNS );
|
|
}
|
|
|
|
private function seed_vendors(): void {
|
|
$p = $this->prefix;
|
|
for ( $v = 1; $v <= $this->vendors; $v++ ) {
|
|
$this->push( "{$p}users", array( 'ID', 'user_login', 'user_nicename', 'user_email', 'user_registered', 'display_name' ), array( $v, "vendor_{$v}", "vendor-{$v}", "vendor{$v}@bench.invalid", '2026-01-01 00:00:00', "Vendor {$v}" ) );
|
|
$user_meta = $this->user_meta( $v );
|
|
foreach ( $user_meta as $key => $value ) {
|
|
$this->push( "{$p}usermeta", array( 'user_id', 'meta_key', 'meta_value' ), array( $v, $key, $value ) );
|
|
}
|
|
$this->push_entity( 'user', 'hp_user', $v, $user_meta );
|
|
|
|
// Vendor post IDs are 1..vendors; listing post IDs follow.
|
|
$this->push( "{$p}posts", array( 'ID', 'post_author', 'post_date', 'post_title', 'post_status', 'post_name', 'post_parent', 'post_type' ), array( $v, $v, '2026-01-01 00:00:00', "Vendor {$v}", 'publish', "vendor-{$v}", 0, 'hp_vendor' ) );
|
|
$vendor_meta = $this->vendor_meta( $v );
|
|
foreach ( $vendor_meta as $key => $value ) {
|
|
$this->push( "{$p}postmeta", array( 'post_id', 'meta_key', 'meta_value' ), array( $v, $key, $value ) );
|
|
}
|
|
$this->push_entity( 'post', 'hp_vendor_core', $v, $vendor_meta );
|
|
}
|
|
$this->flush_all();
|
|
}
|
|
|
|
private function seed_listings(): void {
|
|
$p = $this->prefix;
|
|
for ( $i = 1; $i <= $this->listings; $i++ ) {
|
|
$post_id = $this->vendors + $i;
|
|
$vendor_id = $this->listing_vendor( $i );
|
|
$meta = $this->listing_meta( $i, $vendor_id );
|
|
$date = gmdate( 'Y-m-d H:i:s', self::BASE_TS + $this->det( $i, 'date', 365 * 86400 ) );
|
|
|
|
$this->push( "{$p}posts", array( 'ID', 'post_author', 'post_date', 'post_title', 'post_status', 'post_name', 'post_parent', 'post_type' ), array( $post_id, $vendor_id, $date, "Listing {$i}", $meta['hp_status'], "listing-{$i}", $vendor_id, 'hp_listing' ) );
|
|
foreach ( $meta as $key => $value ) {
|
|
$this->push( "{$p}postmeta", array( 'post_id', 'meta_key', 'meta_value' ), array( $post_id, $key, $value ) );
|
|
}
|
|
$this->push_entity( 'post', 'hp_listing_core', $post_id, $meta );
|
|
|
|
$hot = array( 'post_id' => $post_id );
|
|
foreach ( array_keys( self::HOT_COLUMNS ) as $col ) {
|
|
$hot[ $col ] = $meta[ $col ] ?? 0;
|
|
}
|
|
$this->push( "{$p}wpdo_hot_hp_listing", array_keys( $hot ), array_values( $hot ) );
|
|
}
|
|
$this->flush_all();
|
|
}
|
|
|
|
/**
|
|
* One entity-table row per entity; registry keys without a value are stored as NULL.
|
|
*
|
|
* @param array<string, int|string> $meta
|
|
*/
|
|
private function push_entity( string $type, string $group, int $entity_id, array $meta ): void {
|
|
$id_col = TMDO_Entity_Registry::get_adapter( $type )->get_entity_id_column();
|
|
$cols = array( $id_col );
|
|
$vals = array( $entity_id );
|
|
foreach ( TMDO_Entity_Registry::get_group_keys( $type, $group ) as $key ) {
|
|
$cols[] = TMDO_Schema_Manager::sanitize_column_name( $key );
|
|
$vals[] = $meta[ $key ] ?? null;
|
|
}
|
|
$this->push( TMDO_Schema_Manager::get_table_name( $type, $group ), $cols, $vals );
|
|
}
|
|
|
|
/**
|
|
* @param string[] $cols
|
|
* @param array<int|string|null> $vals
|
|
*/
|
|
private function push( string $table, array $cols, array $vals ): void {
|
|
$escaped = array_map(
|
|
fn( $v ) => null === $v ? 'NULL' : ( is_int( $v ) ? (string) $v : "'" . $this->db->real_escape_string( (string) $v ) . "'" ),
|
|
$vals
|
|
);
|
|
$this->buffers[ $table ]['cols'] = $cols;
|
|
$this->buffers[ $table ]['rows'][] = '(' . implode( ',', $escaped ) . ')';
|
|
if ( count( $this->buffers[ $table ]['rows'] ) >= $this->batch ) {
|
|
$this->flush( $table );
|
|
}
|
|
}
|
|
|
|
private function flush( string $table ): void {
|
|
if ( empty( $this->buffers[ $table ]['rows'] ) ) {
|
|
return;
|
|
}
|
|
$cols = '`' . implode( '`,`', $this->buffers[ $table ]['cols'] ) . '`';
|
|
$this->exec( "INSERT INTO `{$table}` ({$cols}) VALUES " . implode( ',', $this->buffers[ $table ]['rows'] ) );
|
|
$this->buffers[ $table ]['rows'] = array();
|
|
}
|
|
|
|
private function flush_all(): void {
|
|
foreach ( array_keys( $this->buffers ) as $table ) {
|
|
$this->flush( $table );
|
|
}
|
|
}
|
|
|
|
private function exec( string $sql ): void {
|
|
if ( ! $this->db->query( $sql ) ) {
|
|
throw new RuntimeException( 'Bench seed query failed: ' . $this->db->error . ' — ' . substr( $sql, 0, 200 ) );
|
|
}
|
|
}
|
|
}
|