hardening 2.1: 2meet-data-optimizer 1.1.0 (search-index mode, staged rollout) #4
@@ -388,12 +388,20 @@ final class TMDO_Hook_Bus {
|
|||||||
// ── Mode-aware dispatch ──────────────────────────────
|
// ── Mode-aware dispatch ──────────────────────────────
|
||||||
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
|
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
|
||||||
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
|
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
|
||||||
// shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff
|
// shadow_read : 讀取仍走 EAV,抽樣與 flat 比對記錄 diff
|
||||||
// aeav_only : 讀取走 UAE flat,不讀 EAV
|
// aeav_only : 讀取走 UAE flat,不讀 EAV
|
||||||
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
|
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
|
||||||
return $check; // disabled / dual_write
|
return $check; // disabled / dual_write
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shadow_read serves native: update_metadata() reads the old value
|
||||||
|
// through this filter, and serving the just-upserted flat value made
|
||||||
|
// WP see old === new and skip the native write (dev36, 2026-09-26).
|
||||||
|
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
|
||||||
|
self::maybe_shadow_compare( $type, (int) $object_id, $meta_key, $field_def );
|
||||||
|
return $check;
|
||||||
|
}
|
||||||
|
|
||||||
$group = $field_def['group'];
|
$group = $field_def['group'];
|
||||||
$row = self::get_or_load_row( $type, (int) $object_id, $group );
|
$row = self::get_or_load_row( $type, (int) $object_id, $group );
|
||||||
|
|
||||||
@@ -401,22 +409,6 @@ final class TMDO_Hook_Bus {
|
|||||||
$has_value = is_array( $row ) && array_key_exists( $col, $row );
|
$has_value = is_array( $row ) && array_key_exists( $col, $row );
|
||||||
$value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
|
$value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
|
||||||
|
|
||||||
// Shadow-read:與 EAV 比對,記錄差異
|
|
||||||
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
|
|
||||||
try {
|
|
||||||
TMDO_Shadow_Diff_Logger::compare_and_log(
|
|
||||||
$type,
|
|
||||||
(int) $object_id,
|
|
||||||
$meta_key,
|
|
||||||
$value,
|
|
||||||
$field_def
|
|
||||||
);
|
|
||||||
} catch ( \Throwable $e ) {
|
|
||||||
// 比對失敗不該影響讀取
|
|
||||||
TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 值為空 → 回 WP 原生慣例
|
// 值為空 → 回 WP 原生慣例
|
||||||
if ( empty( $row ) || $value === null || $value === '' ) {
|
if ( empty( $row ) || $value === null || $value === '' ) {
|
||||||
return $single ? '' : array();
|
return $single ? '' : array();
|
||||||
@@ -426,6 +418,36 @@ final class TMDO_Hook_Bus {
|
|||||||
return $single ? array( $value ) : array( $value );
|
return $single ? array( $value ) : array( $value );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shadow_read: compare the flat value with native meta for a sample of reads.
|
||||||
|
*
|
||||||
|
* Each compare costs a flat row read and a native SQL read, so only a share
|
||||||
|
* of reads is checked (filter `tmdo_shadow_compare_sample_rate`, 0..1,
|
||||||
|
* default 0.1). The logger rate-limits repeated diffs itself.
|
||||||
|
*
|
||||||
|
* @param string $type Entity type.
|
||||||
|
* @param int $object_id Entity ID.
|
||||||
|
* @param string $meta_key Meta key.
|
||||||
|
* @param array<string, mixed> $field_def Field definition.
|
||||||
|
*/
|
||||||
|
private static function maybe_shadow_compare( string $type, int $object_id, string $meta_key, array $field_def ): void {
|
||||||
|
$rate = (float) apply_filters( 'tmdo_shadow_compare_sample_rate', 0.1, $type, $meta_key );
|
||||||
|
if ( $rate <= 0 || ( $rate < 1 && wp_rand( 1, 1000 ) > $rate * 1000 ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = self::get_or_load_row( $type, $object_id, $field_def['group'] );
|
||||||
|
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
|
||||||
|
$value = array_key_exists( $col, $row ) ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
TMDO_Shadow_Diff_Logger::compare_and_log( $type, $object_id, $meta_key, $value, $field_def );
|
||||||
|
} catch ( \Throwable $e ) {
|
||||||
|
// 比對失敗不該影響讀取
|
||||||
|
TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取得(或載入)完整列資料,並快取
|
* 取得(或載入)完整列資料,並快取
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -268,6 +268,30 @@ class HookBusIntegrationTest extends TestCase {
|
|||||||
$this->assertNull( $result, 'dual_write does not read from flat — must return $check' );
|
$this->assertNull( $result, 'dual_write does not read from flat — must return $check' );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shadow_read serves native meta: update_metadata() reads the old value
|
||||||
|
* through this filter, and serving the just-written flat value made WP
|
||||||
|
* skip the native write (dev36, 2026-09-26). Flat is only compared.
|
||||||
|
*/
|
||||||
|
public function test_get_serves_native_in_shadow_read_mode_even_with_a_flat_value(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 12, 'first_name' => 'Flat' ) );
|
||||||
|
self::set_user_mode( WPDO_Mode_Manager::MODE_SHADOW_READ );
|
||||||
|
|
||||||
|
foreach ( array( 0.0, 1.0 ) as $rate ) {
|
||||||
|
$GLOBALS['_wp_filter_callbacks']['tmdo_shadow_compare_sample_rate'] = array(
|
||||||
|
static function () use ( $rate ) {
|
||||||
|
return $rate;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = WPDO_Hook_Bus::intercept_get( null, 12, 'first_name', true, 'user' );
|
||||||
|
|
||||||
|
$this->assertNull( $result, "shadow_read must return \$check (sample rate {$rate})" );
|
||||||
|
}
|
||||||
|
unset( $GLOBALS['_wp_filter_callbacks']['tmdo_shadow_compare_sample_rate'] );
|
||||||
|
}
|
||||||
|
|
||||||
public function test_get_returns_empty_string_when_column_is_null(): void {
|
public function test_get_returns_empty_string_when_column_is_null(): void {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$wpdb->query(
|
$wpdb->query(
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ Exit code 0 when every test passed, 1 otherwise. Output per test:
|
|||||||
|
|
||||||
| Suite | Covers |
|
| Suite | Covers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `shadow_write` | writes in shadow_read reach native meta (xfail until PR-056) |
|
| `shadow_write` | writes in shadow_read reach native meta (user, term, HivePress listing) |
|
||||||
|
| `serialized` | array meta values are stored serialized in flat tables, never "Array" |
|
||||||
|
|
||||||
## Tools (`tools/`)
|
## Tools (`tools/`)
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,30 @@ final class TMDO_Live_Fixtures {
|
|||||||
return (int) $t['term_id'];
|
return (int) $t['term_id'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a throwaway post.
|
||||||
|
*
|
||||||
|
* @param string $post_type Post type.
|
||||||
|
*/
|
||||||
|
public static function post( string $post_type ): int {
|
||||||
|
if ( ! post_type_exists( $post_type ) ) {
|
||||||
|
throw new TMDO_Live_Skip( "post type {$post_type} not registered on this site" );
|
||||||
|
}
|
||||||
|
$id = wp_insert_post(
|
||||||
|
array(
|
||||||
|
'post_type' => $post_type,
|
||||||
|
'post_title' => 'tmdo-live-' . wp_generate_password( 6, false, false ),
|
||||||
|
'post_status' => 'draft',
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
if ( is_wp_error( $id ) ) {
|
||||||
|
throw new TMDO_Live_Failure( 'fixture post: ' . $id->get_error_message() );
|
||||||
|
}
|
||||||
|
self::$created[] = array( 'post', (int) $id );
|
||||||
|
return (int) $id;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Native meta values for a key, read with SQL (no filters, no cache).
|
* Native meta values for a key, read with SQL (no filters, no cache).
|
||||||
*
|
*
|
||||||
@@ -112,11 +136,12 @@ final class TMDO_Live_Fixtures {
|
|||||||
* @param string $type Entity type.
|
* @param string $type Entity type.
|
||||||
* @param int $id Entity ID.
|
* @param int $id Entity ID.
|
||||||
* @param string $key Meta key.
|
* @param string $key Meta key.
|
||||||
|
* @param string $post_type For posts: the post type whose group owns the key.
|
||||||
* @return string|null
|
* @return string|null
|
||||||
*/
|
*/
|
||||||
public static function flat( string $type, int $id, string $key ) {
|
public static function flat( string $type, int $id, string $key, string $post_type = '' ) {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$field = self::field( $type, $key );
|
$field = self::field( $type, $key, $post_type );
|
||||||
$table = TMDO_Schema_Manager::get_table_name( $type, $field['group'] );
|
$table = TMDO_Schema_Manager::get_table_name( $type, $field['group'] );
|
||||||
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
|
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column come from the registry.
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column come from the registry.
|
||||||
@@ -127,11 +152,24 @@ final class TMDO_Live_Fixtures {
|
|||||||
/**
|
/**
|
||||||
* Registry field for a key, or skip the test when the site does not register it.
|
* Registry field for a key, or skip the test when the site does not register it.
|
||||||
*
|
*
|
||||||
|
* A key can live in several post groups (hp_rating in listing and vendor
|
||||||
|
* groups); pass the post type to pick the group scoped to it.
|
||||||
|
*
|
||||||
* @param string $type Entity type.
|
* @param string $type Entity type.
|
||||||
* @param string $key Meta key.
|
* @param string $key Meta key.
|
||||||
|
* @param string $post_type For posts: the post type whose group owns the key.
|
||||||
*/
|
*/
|
||||||
public static function field( string $type, string $key ): array {
|
public static function field( string $type, string $key, string $post_type = '' ): array {
|
||||||
$field = TMDO_Entity_Registry::get_field( $type, $key );
|
$field = TMDO_Entity_Registry::get_field( $type, $key );
|
||||||
|
if ( '' !== $post_type ) {
|
||||||
|
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
|
||||||
|
if ( in_array( $post_type, TMDO_Entity_Registry::get_group_post_types( $type, $group ), true )
|
||||||
|
&& in_array( $key, TMDO_Entity_Registry::get_group_keys( $type, $group ), true ) ) {
|
||||||
|
$field = array( 'group' => $group ) + (array) $field;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if ( ! $field ) {
|
if ( ! $field ) {
|
||||||
throw new TMDO_Live_Skip( "{$type} key {$key} is not registered on this site" );
|
throw new TMDO_Live_Skip( "{$type} key {$key} is not registered on this site" );
|
||||||
}
|
}
|
||||||
@@ -151,6 +189,8 @@ final class TMDO_Live_Fixtures {
|
|||||||
wp_delete_user( $c[1] );
|
wp_delete_user( $c[1] );
|
||||||
} elseif ( 'term' === $c[0] ) {
|
} elseif ( 'term' === $c[0] ) {
|
||||||
wp_delete_term( $c[1], $c[2] );
|
wp_delete_term( $c[1], $c[2] );
|
||||||
|
} elseif ( 'post' === $c[0] ) {
|
||||||
|
wp_delete_post( $c[1], true );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self::$created = array();
|
self::$created = array();
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ TMDO_Live::test(
|
|||||||
|
|
||||||
tmdo_live_assert_true( false !== $ret, 'update_user_meta returned false' );
|
tmdo_live_assert_true( false !== $ret, 'update_user_meta returned false' );
|
||||||
tmdo_live_assert_same( array( 'After' ), TMDO_Live_Fixtures::native( 'user', $uid, 'first_name' ), 'native first_name' );
|
tmdo_live_assert_same( array( 'After' ), TMDO_Live_Fixtures::native( 'user', $uid, 'first_name' ), 'native first_name' );
|
||||||
},
|
}
|
||||||
array( 'xfail' => 'PR-056' )
|
|
||||||
);
|
);
|
||||||
|
|
||||||
TMDO_Live::test(
|
TMDO_Live::test(
|
||||||
@@ -59,8 +58,7 @@ TMDO_Live::test(
|
|||||||
wp_cache_delete( $uid, 'user_meta' );
|
wp_cache_delete( $uid, 'user_meta' );
|
||||||
|
|
||||||
tmdo_live_assert_same( 'After', get_user_meta( $uid, 'last_name', true ), 'last_name after demotion' );
|
tmdo_live_assert_same( 'After', get_user_meta( $uid, 'last_name', true ), 'last_name after demotion' );
|
||||||
},
|
}
|
||||||
array( 'xfail' => 'PR-056' )
|
|
||||||
);
|
);
|
||||||
|
|
||||||
TMDO_Live::test(
|
TMDO_Live::test(
|
||||||
@@ -76,6 +74,23 @@ TMDO_Live::test(
|
|||||||
|
|
||||||
tmdo_live_assert_true( false !== $ret, 'update_term_meta returned false' );
|
tmdo_live_assert_true( false !== $ret, 'update_term_meta returned false' );
|
||||||
tmdo_live_assert_same( array( 'heart' ), TMDO_Live_Fixtures::native( 'term', $tid, 'hp_icon' ), 'native hp_icon' );
|
tmdo_live_assert_same( array( 'heart' ), TMDO_Live_Fixtures::native( 'term', $tid, 'hp_icon' ), 'native hp_icon' );
|
||||||
},
|
}
|
||||||
array( 'xfail' => 'PR-056' )
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'shadow_read: HivePress listing hp_rating_count (new int key) reaches native',
|
||||||
|
static function () {
|
||||||
|
TMDO_Live_Fixtures::field( 'post', 'hp_rating_count', 'hp_listing' );
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( 'post' => 'dual_write' ) );
|
||||||
|
$lid = TMDO_Live_Fixtures::post( 'hp_listing' );
|
||||||
|
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
|
||||||
|
|
||||||
|
// HivePress writes the recomputed count as an int; the flat read cast it
|
||||||
|
// to the same int, so the old code skipped the native write.
|
||||||
|
$ret = update_post_meta( $lid, 'hp_rating_count', 1 );
|
||||||
|
|
||||||
|
tmdo_live_assert_true( false !== $ret, 'update_post_meta returned false' );
|
||||||
|
tmdo_live_assert_same( array( '1' ), TMDO_Live_Fixtures::native( 'post', $lid, 'hp_rating_count' ), 'native hp_rating_count' );
|
||||||
|
tmdo_live_assert_same( '1', TMDO_Live_Fixtures::flat( 'post', $lid, 'hp_rating_count', 'hp_listing' ), 'flat hp_rating_count' );
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user