diff --git a/cli/class-tmdo-cli.php b/cli/class-tmdo-cli.php index b44bd5b..304f173 100644 --- a/cli/class-tmdo-cli.php +++ b/cli/class-tmdo-cli.php @@ -142,6 +142,10 @@ class TMDO_CLI { 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`." ); } + $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(); 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).' ); diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php index 7b0d4ea..ce6be65 100644 --- a/includes/engine/class-tmdo-hook-bus.php +++ b/includes/engine/class-tmdo-hook-bus.php @@ -948,10 +948,11 @@ final class TMDO_Hook_Bus { */ private static function field_for( string $type, int $object_id, string $meta_key ): ?array { $field_def = TMDO_Entity_Registry::get_field( $type, $meta_key ); - if ( null === $field_def || 'post' !== $type ) { - return $field_def; + if ( null !== $field_def && 'post' === $type ) { + $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; } /** diff --git a/includes/engine/class-tmdo-schema-manager.php b/includes/engine/class-tmdo-schema-manager.php index 88ee68b..527c9da 100644 --- a/includes/engine/class-tmdo-schema-manager.php +++ b/includes/engine/class-tmdo-schema-manager.php @@ -26,6 +26,9 @@ final class TMDO_Schema_Manager { /** Single cron event that adds declared indexes a visitor request had to leave out. */ 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. */ private static array $table_exists_cache = array(); @@ -95,7 +98,12 @@ final class TMDO_Schema_Manager { 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(); $id_col = $adapter->get_entity_id_column(); @@ -167,6 +175,7 @@ final class TMDO_Schema_Manager { // 記錄 Schema 版本與定義 self::store_schema_metadata( $entity_type, $group_name, $schema_hash, $field_definitions ); + self::end_deferral( $entity_type, $group_name ); /** * 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 * server-wide, so SQL hashes it with DATABASE() to keep sites apart. diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index 161b6b4..acdb148 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -634,6 +634,9 @@ class TMDO_Post_Query_Router { if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( 'post' ) ) { 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() ); $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; diff --git a/tests/integration/HookBusIntegrationTest.php b/tests/integration/HookBusIntegrationTest.php index b4e4fec..5d60c23 100644 --- a/tests/integration/HookBusIntegrationTest.php +++ b/tests/integration/HookBusIntegrationTest.php @@ -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 * users from the meta IDs, the deleted_ action re-syncs each to NULL. diff --git a/tests/integration/PostQueryRouterFoundRowsTest.php b/tests/integration/PostQueryRouterFoundRowsTest.php index f66e4cc..a374e91 100644 --- a/tests/integration/PostQueryRouterFoundRowsTest.php +++ b/tests/integration/PostQueryRouterFoundRowsTest.php @@ -90,6 +90,17 @@ class PostQueryRouterFoundRowsTest extends TestCase { 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 { list( $router, $query, $request ) = $this->routed(); diff --git a/tests/integration/SchemaUpgradeDeferralTest.php b/tests/integration/SchemaUpgradeDeferralTest.php new file mode 100644 index 0000000..979bc02 --- /dev/null +++ b/tests/integration/SchemaUpgradeDeferralTest.php @@ -0,0 +1,91 @@ +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(); + } +}