feat(2meet-data-optimizer): group-scoped native write-back and group retirement

- TMDO_Native_Restorer::run() takes an optional group; the CLI gains
  `bridge-restore-native <entity> --group=<g>`.
- New retire(): once native meta holds every non-NULL value of a group's
  flat table (checked against the persisted field definitions), forget
  its registry_meta row and rename the table to <table>_retired_<Ymd>, so
  orphan restore no longer routes its keys to the flat table. Refused
  while a plugin still registers the group or any value differs.
  CLI: `--group=<g> --retire`.
- For retiring the user `sso` group (SSO AddOn deprecation).
- Verify: core unit 659 / integration 479 green, phpcs clean
- PR: PR-042
This commit is contained in:
2026-09-26 04:06:31 +08:00
parent c70dc99bf9
commit 44a188b7ee
3 changed files with 246 additions and 3 deletions
+23 -2
View File
@@ -170,18 +170,31 @@ class TMDO_CLI_V2 {
* [--demote]
* : After a complete run, switch the entity to dual_write (both copies kept in step).
*
* [--group=<group>]
* : Only this group.
*
* [--retire]
* : With --group, after the write-back: forget the group (registry_meta row)
* and rename its flat table to <table>_retired_<Ymd>. Refused while a plugin
* still registers the group or native meta differs from the flat table.
*
* ## EXAMPLES
*
* wp tmdo bridge-restore-native user --dry-run
* wp tmdo bridge-restore-native user --demote
* wp tmdo bridge-restore-native user --group=sso --retire
*/
public function bridge_restore_native( $args, $assoc_args ): void {
$entity = (string) ( $args[0] ?? '' );
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
WP_CLI::error( 'Usage: wp tmdo bridge-restore-native <post|user|term|comment> [--dry-run] [--batch=<n>] [--demote]' );
WP_CLI::error( 'Usage: wp tmdo bridge-restore-native <post|user|term|comment> [--dry-run] [--batch=<n>] [--demote] [--group=<group> [--retire]]' );
}
$dry_run = isset( $assoc_args['dry-run'] );
$report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ) );
$group = sanitize_key( (string) ( $assoc_args['group'] ?? '' ) );
if ( isset( $assoc_args['retire'] ) && '' === $group ) {
WP_CLI::error( '--retire needs --group=<group>.' );
}
$report = TMDO_Native_Restorer::run( $entity, $dry_run, (int) ( $assoc_args['batch'] ?? 500 ), isset( $assoc_args['demote'] ), $group );
foreach ( $report['groups'] as $group => $g ) {
WP_CLI::log( sprintf( '%s/%s: %d row(s)%s', $entity, $group, $g['rows'], $dry_run ? '' : sprintf( ', %d native value(s) written', $g['written'] ) ) );
@@ -190,6 +203,14 @@ class TMDO_CLI_V2 {
WP_CLI::success( '[dry-run] Nothing written.' );
return;
}
if ( isset( $assoc_args['retire'] ) ) {
$retired = TMDO_Native_Restorer::retire( $entity, $group );
if ( 'retired' !== $retired['status'] ) {
WP_CLI::error( sprintf( 'Not retired (%s%s). Written values stay in native meta.', $retired['status'], $retired['mismatches'] ? ", {$retired['mismatches']} value(s) differ" : '' ) );
}
WP_CLI::success( "{$entity}/{$group} retired; flat table kept as {$retired['archived_table']}." );
return;
}
WP_CLI::success( $report['demoted'] ? "Restored; {$entity} is now dual_write." : 'Restored. Demote the entity to dual_write (admin Entity Bridge tab) before its next write, or re-run with --demote.' );
}
@@ -20,6 +20,10 @@
* $demote the entity then moves to dual_write, which keeps both copies in
* step from then on.
*
* A single group can be written back on its own ($group) and then retired:
* retire() forgets its registry_meta row and keeps the flat table under a
* `_retired_<Ymd>` name, so orphan restore never routes its keys again.
*
* @package WP_Data_Optimizer
*/
@@ -47,9 +51,10 @@ final class TMDO_Native_Restorer {
* @param bool $dry_run Count only; write nothing.
* @param int $batch Rows per batch.
* @param bool $demote After a complete run, switch the entity to dual_write.
* @param string $group Only this group ('' = every group).
* @return array{groups: array<string, array{rows:int, written:int}>, demoted: bool}
*/
public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false ): array {
public static function run( string $entity_type, bool $dry_run = false, int $batch = 500, bool $demote = false, string $group = '' ): array {
global $wpdb;
$report = array(
@@ -73,6 +78,9 @@ final class TMDO_Native_Restorer {
$cursor = is_array( $cursor ) ? $cursor : array();
$tables = self::group_tables( $entity_type );
if ( '' !== $group ) {
$tables = array_intersect_key( $tables, array( $group => true ) );
}
foreach ( $tables as $group => $table ) {
$entry = array(
'rows' => 0,
@@ -121,6 +129,74 @@ final class TMDO_Native_Restorer {
return $report;
}
/**
* Forget a group that no plugin registers any more, once native meta holds
* every non-NULL value of its flat table (run() with $group first).
*
* @param string $entity_type Entity type.
* @param string $group Group name.
* @return array{status:string, mismatches:int, archived_table:string}
* status: retired | registered (a plugin still registers it) |
* missing (no registry_meta row or flat table) | mismatch (native differs).
*/
public static function retire( string $entity_type, string $group ): array {
global $wpdb;
$result = array(
'status' => 'missing',
'mismatches' => 0,
'archived_table' => '',
);
$restored = array_map( static fn( $r ) => $r['entity_type'] . '/' . $r['group'], TMDO_Entity_Registry::get_restored_groups() );
if ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) && ! in_array( "{$entity_type}/{$group}", $restored, true ) ) {
$result['status'] = 'registered';
return $result;
}
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
$meta_table = $wpdb->prefix . TMDO_TABLE_PREFIX . 'registry_meta';
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
$defs = $wpdb->get_var( $wpdb->prepare( "SELECT field_definitions FROM `{$meta_table}` WHERE entity_type = %s AND group_name = %s", $entity_type, $group ) );
$fields = json_decode( (string) $defs, true );
if ( ! $adapter || ! is_array( $fields ) || null === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) {
return $result;
}
$native_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
foreach ( (array) $wpdb->get_results( "SELECT * FROM `{$table}`", ARRAY_A ) as $row ) {
foreach ( $fields as $field ) {
$col = TMDO_Schema_Manager::sanitize_column_name( (string) ( $field['key'] ?? '' ) );
if ( '' === $col || ! isset( $row[ $col ] ) ) {
continue;
}
$want = (string) maybe_serialize( TMDO_Type_Caster::from_db( $row[ $col ], $field ) );
$native = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM `{$native_table}` WHERE `{$id_col}` = %d AND meta_key = %s", (int) $row[ $id_col ], $field['key'] ) );
if ( array( $want ) !== $native ) {
++$result['mismatches'];
}
}
}
if ( $result['mismatches'] ) {
$result['status'] = 'mismatch';
return $result;
}
$archived = $table . '_retired_' . gmdate( 'Ymd' );
$wpdb->query( "RENAME TABLE `{$table}` TO `{$archived}`" );
$wpdb->delete(
$meta_table,
array(
'entity_type' => $entity_type,
'group_name' => $group,
)
);
delete_transient( 'tmdo_orphan_native_keys' );
TMDO_Schema_Manager::flush_table_exists_cache();
$result['status'] = 'retired';
$result['archived_table'] = $archived;
return $result;
}
/**
* Registered groups of the entity whose flat table exists.
*
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Retiring one entity group: write its flat values back to native meta, then
* forget it so orphan restore never routes its keys to the flat table again.
*
* Fixture (user, aeav_only): group rt_a (token, ips) and rt_b (other).
* rt_a user 1: token 't1', ips '["1.1.1.1"]' user 2: token 't2', ips NULL
* rt_b user 1: other 'o1'
*/
class NativeRestorerRetireTest extends TestCase {
private const A = 'wp_itest_wpdo_user_rt_a';
private const B = 'wp_itest_wpdo_user_rt_b';
private const META = 'wp_itest_wpdo_registry_meta';
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/engine/class-tmdo-cache-orchestrator.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/migration/class-tmdo-native-restorer.php',
) as $file ) {
require_once $base . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT 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)) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::A . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, token VARCHAR(255) DEFAULT NULL, ips LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->query( 'CREATE TABLE `' . self::B . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, other VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->query( 'INSERT INTO `' . self::A . "` (user_id, token, ips) VALUES (1, 't1', '[\"1.1.1.1\"]'), (2, 't2', NULL)" );
$wpdb->insert( self::B, array( 'user_id' => 1, 'other' => 'o1' ) );
$wpdb->query( 'UPDATE `' . self::A . "` SET updated_at = '2020-01-01 00:00:00'" );
$wpdb->query( 'UPDATE `' . self::B . "` SET updated_at = '2020-01-01 00:00:00'" );
$GLOBALS['_wp_options'] = array( 'wpdo_bridge_modes' => array( 'user' => 'aeav_only' ) );
$GLOBALS['_wp_cache'] = array();
$GLOBALS['_wp_transients'] = array();
TMDO_Mode_Manager::reset_cache();
self::register( true );
$wpdb->delete( self::META, array( 'entity_type' => 'user', 'group_name' => 'rt_a' ) );
$wpdb->insert(
self::META,
array(
'entity_type' => 'user',
'group_name' => 'rt_a',
'schema_hash' => 'itest',
'field_definitions' => wp_json_encode( WPDO_Entity_Registry::get_group_fields( 'user', 'rt_a' ) ),
)
);
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
TMDO_Mode_Manager::reset_cache();
}
/** Fresh registry; rt_b always registered, rt_a only while its provider is active. */
private static function register( bool $with_a ): void {
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
if ( $with_a ) {
WPDO_Entity_Registry::register_group(
'user',
'rt_a',
array(
array( 'key' => 'token', 'type' => 'text' ),
array( 'key' => 'ips', 'type' => 'textarea' ),
)
);
}
WPDO_Entity_Registry::register_group( 'user', 'rt_b', array( array( 'key' => 'other', 'type' => 'text' ) ) );
}
private static function drop(): void {
global $wpdb;
foreach ( (array) $wpdb->get_col( "SHOW TABLES LIKE 'wp\\_itest\\_wpdo\\_user\\_rt\\_%'" ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->usermeta}`" );
$wpdb->delete( self::META, array( 'entity_type' => 'user', 'group_name' => 'rt_a' ) );
}
private function native( int $user_id, string $key ): array {
global $wpdb;
return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM `{$wpdb->usermeta}` WHERE user_id = %d AND meta_key = %s", $user_id, $key ) );
}
public function test_group_option_writes_back_only_that_group(): void {
$report = TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
$this->assertSame( array( 'rt_a' ), array_keys( $report['groups'] ) );
$this->assertSame( array( 't1' ), $this->native( 1, 'token' ) );
$this->assertSame( array( '["1.1.1.1"]' ), $this->native( 1, 'ips' ) );
$this->assertSame( array(), $this->native( 1, 'other' ), 'rt_b untouched' );
}
public function test_retire_refuses_a_group_that_is_still_registered(): void {
TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
$this->assertSame( 'registered', TMDO_Native_Restorer::retire( 'user', 'rt_a' )['status'] );
global $wpdb;
$this->assertSame( self::A, $wpdb->get_var( "SHOW TABLES LIKE '" . self::A . "'" ) );
}
public function test_retire_refuses_when_native_meta_differs_from_the_flat_table(): void {
self::register( false );
$result = TMDO_Native_Restorer::retire( 'user', 'rt_a' );
$this->assertSame( 'mismatch', $result['status'] );
$this->assertSame( 3, $result['mismatches'], 't1, ["1.1.1.1"], t2 are not native yet' );
}
public function test_retire_forgets_the_group_and_keeps_its_table_as_a_backup(): void {
global $wpdb;
TMDO_Native_Restorer::run( 'user', false, 500, false, 'rt_a' );
self::register( false );
$result = TMDO_Native_Restorer::retire( 'user', 'rt_a' );
$this->assertSame( 'retired', $result['status'] );
$this->assertMatchesRegularExpression( '/^' . self::A . '_retired_\d{8}$/', $result['archived_table'] );
$this->assertSame( $result['archived_table'], $wpdb->get_var( "SHOW TABLES LIKE '" . $result['archived_table'] . "'" ) );
$this->assertNull( $wpdb->get_var( "SHOW TABLES LIKE '" . self::A . "'" ) );
$this->assertSame( '0', $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::META . "` WHERE entity_type = 'user' AND group_name = 'rt_a'" ) );
$this->assertSame( 0, WPDO_Schema_Manager::restore_orphaned_groups(), 'orphan restore no longer routes the keys to the flat table' );
}
}