fix(2meet-data-optimizer): alter an existing flat table in one process at a time; keep a deferred group native until its drift fix

- security review H1: anonymous /wp-cron.php requests pass ddl_allowed(),
  and dbDelta on an existing table had no lock, so concurrent requests each
  ran the ALTER on a large table. The upgrade now runs under the same MySQL
  GET_LOCK as the index DDL (skipped when another process holds it) and
  re-checks the stored hash once locked; this covers aeav_only too. The table
  build moves unchanged to write_table(); lock()/unlock() are shared
- security review M1: after a deferred upgrade the group stays native until
  tmdo_drift_resync has rebuilt the rows written meanwhile
  (TMDO_Schema_Manager::resynced() from run_scheduled_fix()); with no flat
  writes (disabled) it is released at once
- Verify: SchemaUpgradeDeferralTest (lock held elsewhere, resync release),
  integration 585, unit 702, phpcs, stan
- PR: PR-111 (2.3 acceptance security review)
This commit is contained in:
2026-09-27 17:22:57 +08:00
parent 0aec4b9bb6
commit 29adb05567
4 changed files with 149 additions and 20 deletions
@@ -237,6 +237,7 @@ final class TMDO_Drift_Check {
public static function run_scheduled_fix( string $entity_type ): void {
try {
$result = self::fix( $entity_type );
TMDO_Schema_Manager::resynced( $entity_type ); // Groups upgraded while deferred use the flat table again.
TMDO_Logger::info( 'drift_resync', array( 'entity' => $entity_type ) + $result['fixed'] );
} catch ( InvalidArgumentException | RuntimeException $e ) {
TMDO_Logger::info(
+96 -19
View File
@@ -83,8 +83,6 @@ final class TMDO_Schema_Manager {
string $group_name,
array $field_definitions
): bool {
global $wpdb;
// Schema 版本比對:若未變動則跳過
$schema_hash = self::calculate_schema_hash( $field_definitions );
$stored_hash = self::get_stored_schema_hash( $entity_type, $group_name );
@@ -103,6 +101,39 @@ final class TMDO_Schema_Manager {
self::defer_upgrade( "{$entity_type}/{$group_name}" );
return false;
}
if ( ! self::table_exists( $table ) ) {
return self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash );
}
// An existing table is altered by one process at a time (anonymous
// /wp-cron.php requests also pass ddl_allowed()); a process that waited
// finds the upgrade done.
if ( ! self::lock() ) {
return false;
}
try {
if ( self::get_stored_schema_hash( $entity_type, $group_name ) === $schema_hash ) {
return true;
}
return self::write_table( $entity_type, $group_name, $field_definitions, $adapter, $table, $schema_hash );
} finally {
self::unlock();
}
}
/**
* Create or alter the table (dbDelta) and record its definition.
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
* @param array<int, array<string, mixed>> $field_definitions Normalized fields.
* @param TMDO_Entity_Adapter_Interface $adapter Entity adapter.
* @param string $table Table name.
* @param string $schema_hash Definition hash.
* @return 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;
$charset = $wpdb->get_charset_collate();
$id_col = $adapter->get_entity_id_column();
@@ -227,16 +258,7 @@ final class TMDO_Schema_Manager {
return;
}
// add_option() is INSERT ... ON DUPLICATE KEY UPDATE and would let two
// processes both "acquire" an option lock; GET_LOCK is atomic and is
// released by MySQL if this process dies.
$lock = self::index_lock_name();
if ( method_exists( $wpdb, 'send_reads_to_masters' ) ) {
// HyperDB / LudicrousDB would send the SELECT GET_LOCK to a replica,
// where it locks nothing against the ALTER on the primary.
$wpdb->send_reads_to_masters();
}
if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(MD5(CONCAT(DATABASE(), %s)), 0)', $lock ) ) ) {
if ( ! self::lock() ) {
return; // Another process is adding indexes.
}
try {
@@ -267,7 +289,7 @@ final class TMDO_Schema_Manager {
update_option( self::INDEXES_OPTION, $applied, true );
}
} finally {
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), %s)))', $lock ) );
self::unlock();
}
}
@@ -331,8 +353,10 @@ final class TMDO_Schema_Manager {
}
/**
* A deferred upgrade is done: writes made meanwhile skipped the flat table,
* so rebuild it from native (drift fix) a minute later.
* A deferred upgrade is done. Writes made meanwhile skipped the flat table,
* so the group stays native until a drift fix (tmdo_drift_resync, a minute
* later) has rebuilt it from native and calls resynced(). With no flat
* writes (disabled), nothing is behind.
*
* @param string $entity_type Entity type.
* @param string $group_name Group name.
@@ -342,14 +366,67 @@ final class TMDO_Schema_Manager {
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 ) ) ) {
if ( ! TMDO_Mode_Manager::writes_to_flat( $entity_type ) ) {
$deferred = (array) get_option( self::DEFERRED_OPTION, array() );
unset( $deferred[ "{$entity_type}/{$group_name}" ] );
update_option( self::DEFERRED_OPTION, $deferred, true );
return;
}
if ( ! 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 ) );
}
}
/**
* A drift fix of the entity succeeded: its deferred groups whose table is
* upgraded read and write the flat table again.
*
* @param string $entity_type Entity type.
* @return void
*/
public static function resynced( string $entity_type ): void {
$deferred = get_option( self::DEFERRED_OPTION, array() );
if ( ! is_array( $deferred ) || ! $deferred ) {
return;
}
foreach ( array_keys( $deferred ) as $id ) {
list( $type, $group ) = array_pad( explode( '/', (string) $id, 2 ), 2, '' );
if ( $type === $entity_type && self::get_stored_schema_hash( $type, $group ) === self::calculate_schema_hash( TMDO_Entity_Registry::get_group_fields( $type, $group ) ) ) {
unset( $deferred[ $id ] );
}
}
update_option( self::DEFERRED_OPTION, $deferred, true );
}
/**
* Take the schema lock without waiting.
*
* add_option() is INSERT ... ON DUPLICATE KEY UPDATE and would let two
* processes both "acquire" an option lock; GET_LOCK is atomic and is
* released by MySQL if this process dies. Re-entrant per connection.
*
* @return bool False when another process holds it.
*/
private static function lock(): bool {
global $wpdb;
if ( method_exists( $wpdb, 'send_reads_to_masters' ) ) {
// HyperDB / LudicrousDB would send the SELECT GET_LOCK to a replica,
// where it locks nothing against the ALTER on the primary.
$wpdb->send_reads_to_masters();
}
return '1' === (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(MD5(CONCAT(DATABASE(), %s)), 0)', self::index_lock_name() ) );
}
/**
* Release the schema lock.
*
* @return void
*/
private static function unlock(): void {
global $wpdb;
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), %s)))', self::index_lock_name() ) );
}
/**
* 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.