fix(2meet-data-optimizer): close the gaps in deferred table upgrades found in the 2.3 review
- a deferred group whose stored definition matches again (reverted, or upgraded by another process) goes through end_deferral() instead of staying native for good (review F2) - the tmdo_ensure_group_indexes handler upgrades deferred tables itself: with ALTERNATE_WP_CRON the event runs after init in a visitor's request (F3) - a new group table created while the entity writes to flat stays native until the drift fix has filled it from native meta; an opted-in HivePress attribute no longer routes searches to an empty table (F6) - direct_read() returns native values for a deferred group (F10); the Hook Bus ignores deferral in aeav_only, as the router does - dbDelta failures: the definition is recorded only when every column exists (schema.upgrade_failed otherwise), so a failed ALTER is retried and the group is not released onto missing columns - test bootstrap: get_metadata() reads the real meta table - Verify: SchemaUpgradeDeferralTest (6), Hook Bus direct read, integration 587, unit 703, phpcs, stan - PR: PR-114 (2.3 acceptance correctness review)
This commit is contained in:
@@ -951,8 +951,20 @@ final class TMDO_Hook_Bus {
|
|||||||
if ( null !== $field_def && 'post' === $type ) {
|
if ( null !== $field_def && 'post' === $type ) {
|
||||||
$field_def = TMDO_Entity_Registry::get_field( $type, $meta_key, (string) get_post_type( $object_id ) );
|
$field_def = 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.
|
// 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;
|
// (not in aeav_only, where native is stale; the router agrees).
|
||||||
|
return null !== $field_def && self::deferred( $type, (string) $field_def['group'] ) ? null : $field_def;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a group is deferred and so read and written natively.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @param string $group Group name.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function deferred( string $type, string $group ): bool {
|
||||||
|
return TMDO_Schema_Manager::is_deferred( $type, $group ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $type );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -970,10 +982,13 @@ final class TMDO_Hook_Bus {
|
|||||||
// 回傳所有群組的所有欄位
|
// 回傳所有群組的所有欄位
|
||||||
$result = array();
|
$result = array();
|
||||||
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
|
||||||
$row = self::get_or_load_row( $type, $entity_id, $group );
|
$deferred = self::deferred( $type, $group );
|
||||||
|
$row = $deferred ? array() : self::get_or_load_row( $type, $entity_id, $group );
|
||||||
foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) {
|
foreach ( TMDO_Entity_Registry::get_group_fields( $type, $group ) as $field ) {
|
||||||
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
|
$col = TMDO_Schema_Manager::sanitize_column_name( $field['key'] );
|
||||||
$result[ $field['key'] ] = TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field );
|
$result[ $field['key'] ] = $deferred
|
||||||
|
? get_metadata( $type, $entity_id, $field['key'], true )
|
||||||
|
: TMDO_Type_Caster::from_db( $row[ $col ] ?? null, $field );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $result;
|
return $result;
|
||||||
@@ -981,7 +996,8 @@ final class TMDO_Hook_Bus {
|
|||||||
|
|
||||||
$field_def = self::field_for( $type, $entity_id, $key );
|
$field_def = self::field_for( $type, $entity_id, $key );
|
||||||
if ( ! $field_def ) {
|
if ( ! $field_def ) {
|
||||||
return null;
|
// A deferred group's value is the native one.
|
||||||
|
return null !== TMDO_Entity_Registry::get_field( $type, $key ) ? get_metadata( $type, $entity_id, $key, true ) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$row = self::get_or_load_row( $type, $entity_id, $field_def['group'] );
|
$row = self::get_or_load_row( $type, $entity_id, $field_def['group'] );
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ final class TMDO_Schema_Manager {
|
|||||||
$stored_hash = self::get_stored_schema_hash( $entity_type, $group_name );
|
$stored_hash = self::get_stored_schema_hash( $entity_type, $group_name );
|
||||||
|
|
||||||
if ( $stored_hash === $schema_hash ) {
|
if ( $stored_hash === $schema_hash ) {
|
||||||
|
// Upgraded meanwhile, or the definition went back to the stored one.
|
||||||
|
self::end_deferral( $entity_type, $group_name );
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +104,16 @@ final class TMDO_Schema_Manager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ( ! self::table_exists( $table ) ) {
|
if ( ! self::table_exists( $table ) ) {
|
||||||
return self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash );
|
if ( ! self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A new table starts empty while native meta may already hold the
|
||||||
|
// group's values: native until the drift fix has filled it in.
|
||||||
|
if ( TMDO_Mode_Manager::writes_to_flat( $entity_type ) && TMDO_Mode_Manager::MODE_AEAV_ONLY !== TMDO_Mode_Manager::get( $entity_type ) ) {
|
||||||
|
self::record_deferred( "{$entity_type}/{$group_name}" );
|
||||||
|
self::end_deferral( $entity_type, $group_name );
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// An existing table is altered by one process at a time (anonymous
|
// An existing table is altered by one process at a time (anonymous
|
||||||
@@ -134,6 +145,7 @@ final class TMDO_Schema_Manager {
|
|||||||
*/
|
*/
|
||||||
private static function write_table( string $entity_type, string $group_name, array $field_definitions, TMDO_Entity_Adapter_Interface $adapter, string $table, string $schema_hash ): bool {
|
private static function write_table( string $entity_type, string $group_name, array $field_definitions, TMDO_Entity_Adapter_Interface $adapter, string $table, string $schema_hash ): bool {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
$existed = self::table_exists( $table );
|
||||||
|
|
||||||
$charset = $wpdb->get_charset_collate();
|
$charset = $wpdb->get_charset_collate();
|
||||||
$id_col = $adapter->get_entity_id_column();
|
$id_col = $adapter->get_entity_id_column();
|
||||||
@@ -204,9 +216,27 @@ final class TMDO_Schema_Manager {
|
|||||||
// Bust the request-scoped table_exists cache so subsequent calls see the new table.
|
// Bust the request-scoped table_exists cache so subsequent calls see the new table.
|
||||||
self::$table_exists_cache[ $table ] = true;
|
self::$table_exists_cache[ $table ] = true;
|
||||||
|
|
||||||
|
// dbDelta reports no failure: record the definition only when every column is there.
|
||||||
|
$columns = (array) $wpdb->get_col( "SHOW COLUMNS FROM `{$table}`" );
|
||||||
|
$missing = array_diff( array_map( static fn( $field ) => self::sanitize_column_name( (string) $field['key'] ), $field_definitions ), $columns );
|
||||||
|
if ( $missing ) {
|
||||||
|
unset( self::$table_exists_cache[ $table ] );
|
||||||
|
TMDO_Logger::warning(
|
||||||
|
'schema.upgrade_failed',
|
||||||
|
array(
|
||||||
|
'table' => $table,
|
||||||
|
'missing' => array_values( $missing ),
|
||||||
|
'error' => $wpdb->last_error,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// 記錄 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 );
|
||||||
|
if ( $existed ) {
|
||||||
self::end_deferral( $entity_type, $group_name );
|
self::end_deferral( $entity_type, $group_name );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action: 表建立/升級完成
|
* Action: 表建立/升級完成
|
||||||
@@ -294,11 +324,20 @@ final class TMDO_Schema_Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* INDEXES_HOOK handler: add the indexes a visitor request could not.
|
* INDEXES_HOOK handler: the table upgrades and indexes a web request left.
|
||||||
|
* The cron request's init usually did them already; not with
|
||||||
|
* ALTERNATE_WP_CRON, where the event runs inside a visitor's request.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function ensure_pending_indexes(): void {
|
public static function ensure_pending_indexes(): void {
|
||||||
|
foreach ( array_keys( (array) get_option( self::DEFERRED_OPTION, array() ) ) as $id ) {
|
||||||
|
list( $entity_type, $group_name ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' );
|
||||||
|
$fields = TMDO_Entity_Registry::get_group_fields( $entity_type, $group_name );
|
||||||
|
if ( $fields ) {
|
||||||
|
self::create_or_upgrade_table( $entity_type, $group_name, $fields );
|
||||||
|
}
|
||||||
|
}
|
||||||
foreach ( self::pending_indexes() as $id ) {
|
foreach ( self::pending_indexes() as $id ) {
|
||||||
list( $entity_type, $group_name ) = explode( '/', $id, 2 );
|
list( $entity_type, $group_name ) = explode( '/', $id, 2 );
|
||||||
self::ensure_group_indexes( $entity_type, $group_name );
|
self::ensure_group_indexes( $entity_type, $group_name );
|
||||||
@@ -341,15 +380,25 @@ final class TMDO_Schema_Manager {
|
|||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
private static function defer_upgrade( string $id ): void {
|
private static function defer_upgrade( string $id ): void {
|
||||||
|
self::record_deferred( $id );
|
||||||
|
if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) {
|
||||||
|
wp_schedule_single_event( time(), self::INDEXES_HOOK ); // Its request's init runs the upgrade.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a group as deferred (native until resynced()).
|
||||||
|
*
|
||||||
|
* @param string $id "entity/group".
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function record_deferred( string $id ): void {
|
||||||
$deferred = get_option( self::DEFERRED_OPTION, array() );
|
$deferred = get_option( self::DEFERRED_OPTION, array() );
|
||||||
$deferred = is_array( $deferred ) ? $deferred : array();
|
$deferred = is_array( $deferred ) ? $deferred : array();
|
||||||
if ( ! isset( $deferred[ $id ] ) ) {
|
if ( ! isset( $deferred[ $id ] ) ) {
|
||||||
$deferred[ $id ] = time();
|
$deferred[ $id ] = time();
|
||||||
update_option( self::DEFERRED_OPTION, $deferred, true );
|
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.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ class HookBusIntegrationTest extends TestCase {
|
|||||||
WPDO_Hook_Bus::sync_from_native( 'user', 4, 'first_name', 'Dora', 'update' );
|
WPDO_Hook_Bus::sync_from_native( 'user', 4, 'first_name', 'Dora', 'update' );
|
||||||
|
|
||||||
$this->assertNull( $this->read_flat_first_name( 4 ) );
|
$this->assertNull( $this->read_flat_first_name( 4 ) );
|
||||||
|
$this->assertSame( 'Dora', WPDO_Hook_Bus::direct_read( 'user', 4, 'first_name' ), 'direct reads return the native value' );
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -139,4 +139,39 @@ class SchemaUpgradeDeferralTest extends TestCase {
|
|||||||
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_later' ) );
|
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_later' ) );
|
||||||
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'post', 'sd_core' ) );
|
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'post', 'sd_core' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The definition went back to the stored one (or another process upgraded
|
||||||
|
* the table) while the group was deferred (correctness review F2).
|
||||||
|
*/
|
||||||
|
public function test_a_deferred_group_whose_definition_matches_again_is_resynced_then_released(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
|
||||||
|
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
|
||||||
|
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) );
|
||||||
|
|
||||||
|
$this->assertTrue( $this->upgrade() );
|
||||||
|
|
||||||
|
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'dual_write: native until the drift fix' );
|
||||||
|
$this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] );
|
||||||
|
|
||||||
|
self::set_user_mode( 'disabled' );
|
||||||
|
$this->upgrade();
|
||||||
|
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'no flat writes: nothing to rebuild' );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cron event upgrades deferred groups itself: with ALTERNATE_WP_CRON
|
||||||
|
* it runs inside a visitor's request, after init (correctness review F3).
|
||||||
|
*/
|
||||||
|
public function test_the_cron_event_handles_deferred_groups(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
|
||||||
|
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
|
||||||
|
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) );
|
||||||
|
|
||||||
|
TMDO_Schema_Manager::ensure_pending_indexes();
|
||||||
|
|
||||||
|
$this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,6 +299,19 @@ if ( ! function_exists( 'do_action_ref_array' ) ) {
|
|||||||
if ( ! function_exists( 'is_admin' ) ) { function is_admin(): bool { return ! empty( $GLOBALS['_wp_is_admin'] ); } }
|
if ( ! function_exists( 'is_admin' ) ) { function is_admin(): bool { return ! empty( $GLOBALS['_wp_is_admin'] ); } }
|
||||||
if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ): bool { return false; } }
|
if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ): bool { return false; } }
|
||||||
if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } }
|
if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } }
|
||||||
|
if ( ! function_exists( 'get_metadata' ) ) {
|
||||||
|
// Like get_metadata(): the first stored value of a key (single), read from the real meta table.
|
||||||
|
function get_metadata( string $type, $object_id, string $key = '', bool $single = false ) {
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->{$type . 'meta'};
|
||||||
|
$id = 'user' === $type ? 'umeta_id' : 'meta_id';
|
||||||
|
$value = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE `{$type}_id` = %d AND meta_key = %s ORDER BY `{$id}` LIMIT 1", $object_id, $key ) );
|
||||||
|
if ( $single ) {
|
||||||
|
return null === $value ? '' : maybe_unserialize( $value );
|
||||||
|
}
|
||||||
|
return null === $value ? array() : array( maybe_unserialize( $value ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } }
|
if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } }
|
||||||
if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } }
|
if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } }
|
||||||
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { $GLOBALS['_wp_scheduled_single'][] = $hook; return true; } }
|
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { $GLOBALS['_wp_scheduled_single'][] = $hook; return true; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user