fix(2meet-data-optimizer): upgrade existing flat tables from WP-CLI or WP-Cron only

- a changed group definition no longer runs dbDelta on an existing table
  inside a web request (seconds to minutes on a large table, and every
  concurrent request tried it): the group is recorded in tmdo_schema_deferred
  and tmdo_ensure_group_indexes is scheduled, whose request upgrades it
- meanwhile the group stays native: Hook Bus field lookups return no field and
  the router treats it as drifting; afterwards a drift fix
  (tmdo_drift_resync) rebuilds the writes that skipped the flat table
- new tables are still created at once; aeav_only upgrades at once (the flat
  table is the only copy); doctor lists deferred groups
- Verify: SchemaUpgradeDeferralTest, Hook Bus and router tests, integration
  582, unit 701, phpcs, stan; dev36 live cycle in the 2.3 acceptance
- PR: PR-107
This commit is contained in:
2026-09-27 17:07:48 +08:00
parent 5968bcd95d
commit e18030b206
7 changed files with 213 additions and 4 deletions
+4
View File
@@ -142,6 +142,10 @@ class TMDO_CLI {
foreach ( $violations as $entity_type => $integrations ) { foreach ( $violations as $entity_type => $integrations ) {
WP_CLI::warning( " [GUARD] {$entity_type} is aeav_only while " . implode( ', ', $integrations ) . " reads native meta — write flat back with `wp tmdo bridge-restore-native {$entity_type} --demote`." ); WP_CLI::warning( " [GUARD] {$entity_type} is aeav_only while " . implode( ', ', $integrations ) . " reads native meta — write flat back with `wp tmdo bridge-restore-native {$entity_type} --demote`." );
} }
$deferred = array_keys( (array) get_option( TMDO_Schema_Manager::DEFERRED_OPTION, array() ) );
if ( $deferred ) {
WP_CLI::warning( ' [SCHEMA] table upgrades not done yet (these groups use native meta meanwhile): ' . implode( ', ', $deferred ) . ' — see the error log if this run did not finish them.' );
}
$pending = TMDO_Schema_Manager::pending_indexes(); $pending = TMDO_Schema_Manager::pending_indexes();
if ( $pending ) { if ( $pending ) {
WP_CLI::warning( ' [INDEX] declared indexes not added yet: ' . implode( ', ', $pending ) . ' — still missing after this run: another process is adding them, or the ALTER failed (see the `schema.index_failed` log).' ); WP_CLI::warning( ' [INDEX] declared indexes not added yet: ' . implode( ', ', $pending ) . ' — still missing after this run: another process is adding them, or the ALTER failed (see the `schema.index_failed` log).' );
+4 -3
View File
@@ -948,10 +948,11 @@ final class TMDO_Hook_Bus {
*/ */
private static function field_for( string $type, int $object_id, string $meta_key ): ?array { private static function field_for( string $type, int $object_id, string $meta_key ): ?array {
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key ); $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key );
if ( null === $field_def || 'post' !== $type ) { if ( null !== $field_def && 'post' === $type ) {
return $field_def; $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) );
} }
return TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) ); // A group whose table upgrade waits for WP-CLI / WP-Cron stays native.
return null !== $field_def && TMDO_Schema_Manager::is_deferred( $type, (string) $field_def['group'] ) ? null : $field_def;
} }
/** /**
+77 -1
View File
@@ -26,6 +26,9 @@ final class TMDO_Schema_Manager {
/** Single cron event that adds declared indexes a visitor request had to leave out. */ /** Single cron event that adds declared indexes a visitor request had to leave out. */
public const INDEXES_HOOK = 'tmdo_ensure_group_indexes'; public const INDEXES_HOOK = 'tmdo_ensure_group_indexes';
/** Option: "entity/group" → time an existing table's upgrade was left to WP-CLI / WP-Cron. */
public const DEFERRED_OPTION = 'tmdo_schema_deferred';
/** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */ /** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */
private static array $table_exists_cache = array(); private static array $table_exists_cache = array();
@@ -95,7 +98,12 @@ final class TMDO_Schema_Manager {
return false; return false;
} }
$table = self::get_table_name( $entity_type, $group_name ); $table = self::get_table_name( $entity_type, $group_name );
if ( self::upgrade_waits( $entity_type, $table ) ) {
self::defer_upgrade( "{$entity_type}/{$group_name}" );
return false;
}
$charset = $wpdb->get_charset_collate(); $charset = $wpdb->get_charset_collate();
$id_col = $adapter->get_entity_id_column(); $id_col = $adapter->get_entity_id_column();
@@ -167,6 +175,7 @@ final class TMDO_Schema_Manager {
// 記錄 Schema 版本與定義 // 記錄 Schema 版本與定義
self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions ); self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions );
self::end_deferral( $entity_type, $group_name );
/** /**
* Action: 表建立/升級完成 * Action: 表建立/升級完成
@@ -269,6 +278,73 @@ final class TMDO_Schema_Manager {
} }
} }
/**
* Whether the group's table upgrade is waiting for WP-CLI / WP-Cron; its
* keys then stay native (Hook Bus, query router).
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @return bool
*/
public static function is_deferred( string $entity_type, string $group_name ): bool {
$deferred = get_option( self::DEFERRED_OPTION, array() );
return is_array( $deferred ) && isset( $deferred[ "{$entity_type}/{$group_name}" ] );
}
/**
* Whether an existing table's upgrade waits for WP-CLI / WP-Cron: altering
* a large table takes seconds to minutes, as for indexes. Until then the
* group reads and writes native meta (is_deferred()). Not in aeav_only,
* where the flat table is the only copy: that upgrade runs at once.
*
* @param string $entity_type Entity type.
* @param string $table Flat table name.
* @return bool
*/
public static function upgrade_waits( string $entity_type, string $table ): bool {
return self::table_exists( $table )
&& ! self::ddl_allowed()
&& TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type );
}
/**
* Record a deferred table upgrade and have WP-Cron run it.
*
* @param string $id "entity/group".
* @return void
*/
private static function defer_upgrade( string $id ): void {
$deferred = get_option( self::DEFERRED_OPTION, array() );
$deferred = is_array( $deferred ) ? $deferred : array();
if ( ! isset( $deferred[ $id ] ) ) {
$deferred[ $id ] = time();
update_option( self::DEFERRED_OPTION, $deferred, true );
}
if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) {
wp_schedule_single_event( time(), self::INDEXES_HOOK ); // Its request's init runs the upgrade.
}
}
/**
* A deferred upgrade is done: writes made meanwhile skipped the flat table,
* so rebuild it from native (drift fix) a minute later.
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @return void
*/
private static function end_deferral( string $entity_type, string $group_name ): void {
if ( ! self::is_deferred( $entity_type, $group_name ) ) {
return;
}
$deferred = (array) get_option( self::DEFERRED_OPTION, array() );
unset( $deferred[ "{$entity_type}/{$group_name}" ] );
update_option( self::DEFERRED_OPTION, $deferred, true );
if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && ! wp_next_scheduled( TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) ) ) {
wp_schedule_single_event( time() + MINUTE_IN_SECONDS, TMDO_Drift_Check::RESYNC_HOOK, array( $entity_type ) );
}
}
/** /**
* Key of the MySQL lock held while indexes are added. Lock names are * Key of the MySQL lock held while indexes are added. Lock names are
* server-wide, so SQL hashes it with DATABASE() to keep sites apart. * server-wide, so SQL hashes it with DATABASE() to keep sites apart.
@@ -634,6 +634,9 @@ class TMDO_Post_Query_Router {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( 'post' ) ) { if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( 'post' ) ) {
return false; return false;
} }
if ( TMDO_Schema_Manager::is_deferred( 'post', $group ) ) {
return true; // Table upgrade waiting for WP-CLI / WP-Cron.
}
$status = get_option( TMDO_Drift_Check::OPTION, array() ); $status = get_option( TMDO_Drift_Check::OPTION, array() );
$last = is_array( $status ) ? ( $status['post'][ $group ] ?? null ) : null; $last = is_array( $status ) ? ( $status['post'][ $group ] ?? null ) : null;
return is_array( $last ) && (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0; return is_array( $last ) && (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0;
@@ -227,6 +227,29 @@ class HookBusIntegrationTest extends TestCase {
); );
} }
/**
* A group whose table upgrade waits for WP-CLI / WP-Cron (2.3 PR-107)
* stays native: its table may lack the new columns.
*/
public function test_a_group_waiting_for_its_table_upgrade_stays_native(): void {
global $wpdb;
self::set_user_mode( WPDO_Mode_Manager::MODE_DUAL_WRITE );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/core_profile' => time() ) );
try {
$this->with_usermeta(
function () use ( $wpdb ) {
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 4, 'meta_key' => 'first_name', 'meta_value' => 'Dora' ) );
WPDO_Hook_Bus::sync_from_native( 'user', 4, 'first_name', 'Dora', 'update' );
$this->assertNull( $this->read_flat_first_name( 4 ) );
}
);
} finally {
delete_option( TMDO_Schema_Manager::DEFERRED_OPTION );
}
}
/** /**
* delete_all passes object ID 0: the delete_ action records the touched * delete_all passes object ID 0: the delete_ action records the touched
* users from the meta IDs, the deleted_ action re-syncs each to NULL. * users from the meta IDs, the deleted_ action re-syncs each to NULL.
@@ -90,6 +90,17 @@ class PostQueryRouterFoundRowsTest extends TestCase {
return array( $router, $query, $request ); return array( $router, $query, $request );
} }
public function test_a_group_waiting_for_its_table_upgrade_is_not_routed(): void {
$mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$mode->setAccessible( true );
$mode->setValue( null, array( 'post' => 'shadow_read', 'user' => 'disabled', 'term' => 'disabled', 'comment' => 'disabled' ) );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'post/fr_core' => time() ) );
list( , $query ) = $this->routed();
$this->assertEmpty( $query->get( 'wpdo_post_clauses' ), 'kept native' );
}
public function test_routed_request_drops_sql_calc_found_rows(): void { public function test_routed_request_drops_sql_calc_found_rows(): void {
list( $router, $query, $request ) = $this->routed(); list( $router, $query, $request ) = $this->routed();
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* An existing flat table whose definition changed is upgraded from WP-CLI /
* WP-Cron only (2.3 PR-107); web requests record the group as deferred.
*/
class SchemaUpgradeDeferralTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_sd_core';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-mode-manager.php',
'includes/adapters/class-tmdo-adapter-user.php',
) as $file ) {
require_once $base . $file;
}
WPDO_Installer::install_v2_tables();
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, sd_a VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
$wpdb->insert( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core', 'schema_hash' => 'old', 'field_definitions' => '[]' ) );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_scheduled_single'] = array();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array(); // A web request.
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
self::set_user_mode( 'dual_write' );
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group( 'user', 'sd_core', array( array( 'key' => 'sd_a', 'type' => 'text' ), array( 'key' => 'sd_b', 'type' => 'integer' ) ) );
}
protected function tearDown(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
WPDO_Entity_Registry::init();
self::set_user_mode( null );
unset( $GLOBALS['_wp_scheduled_single'] );
}
private static function set_user_mode( ?string $mode ): void {
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$ref->setAccessible( true );
$ref->setValue( null, null === $mode ? null : array( 'post' => 'disabled', 'user' => $mode, 'term' => 'disabled', 'comment' => 'disabled' ) );
}
private function upgrade(): bool {
return TMDO_Schema_Manager::create_or_upgrade_table( 'user', 'sd_core', WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
}
public function test_a_web_request_leaves_the_upgrade_to_wp_cron_and_the_group_native(): void {
global $wpdb;
$this->assertFalse( $this->upgrade() );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
$this->assertContains( TMDO_Schema_Manager::INDEXES_HOOK, $GLOBALS['_wp_scheduled_single'] );
$this->assertSame( array( 'id', 'user_id', 'sd_a' ), $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::TABLE . '`' ), 'no ALTER' );
$this->assertSame( 'old', TMDO_Schema_Manager::get_stored_schema_hash( 'user', 'sd_core' ) );
}
public function test_only_existing_tables_outside_aeav_only_and_outside_cli_or_cron_wait(): void {
$this->assertTrue( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) );
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', 'wp_itest_wpdo_user_sd_missing' ), 'a new table is created at once' );
self::set_user_mode( 'aeav_only' );
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ), 'aeav_only: the flat table is the only copy' );
self::set_user_mode( 'dual_write' );
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); // WP-CLI / WP-Cron.
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) );
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
}
}