fix(2meet-data-optimizer): serve native meta in shadow_read and compare flat values

- In shadow_read, TMDO_Hook_Bus::intercept_get() returned the flat value.
  update_metadata() reads the old value through that filter after the Hook
  Bus has upserted the new one, so WP saw old === new and never wrote native
  meta: every update in shadow_read was lost on demotion (dev36 2026-09-26).
  shadow_read now returns $check (native) and compares flat on a sample of
  reads (filter tmdo_shadow_compare_sample_rate, default 0.1). aeav_only is
  unchanged; the post query router still routes in shadow_read.
- Live suite shadow_write: xfail markers removed, HivePress listing case
  added (new int key hp_rating_count); fixtures gain post() and a
  post_type-scoped field()/flat().
- Verify: live shadow_write + serialized 8/8 PASS (old code: 4 FAIL);
  unit 685 OK, integration 484 OK, phpcs (phpcs.xml) 0, stan OK
- PR: PR-056
This commit is contained in:
2026-09-26 18:56:45 +08:00
parent 08f9e313e0
commit b84252d787
5 changed files with 134 additions and 32 deletions
+39 -17
View File
@@ -388,12 +388,20 @@ final class TMDO_Hook_Bus {
// ── Mode-aware dispatch ──────────────────────────────
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
// shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff
// shadow_read : 讀取仍走 EAV,抽樣與 flat 比對記錄 diff
// aeav_only : 讀取走 UAE flat,不讀 EAV
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
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'];
$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 );
$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 原生慣例
if ( empty( $row ) || $value === null || $value === '' ) {
return $single ? '' : array();
@@ -426,6 +418,36 @@ final class TMDO_Hook_Bus {
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' );
}
/**
* 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 {
global $wpdb;
$wpdb->query(
+2 -1
View File
@@ -36,7 +36,8 @@ Exit code 0 when every test passed, 1 otherwise. Output per test:
| 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/`)
+43 -3
View File
@@ -89,6 +89,30 @@ final class TMDO_Live_Fixtures {
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).
*
@@ -112,11 +136,12 @@ final class TMDO_Live_Fixtures {
* @param string $type Entity type.
* @param int $id Entity ID.
* @param string $key Meta key.
* @param string $post_type For posts: the post type whose group owns the key.
* @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;
$field = self::field( $type, $key );
$field = self::field( $type, $key, $post_type );
$table = TMDO_Schema_Manager::get_table_name( $type, $field['group'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
// 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.
*
* 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 $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 );
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 ) {
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] );
} elseif ( 'term' === $c[0] ) {
wp_delete_term( $c[1], $c[2] );
} elseif ( 'post' === $c[0] ) {
wp_delete_post( $c[1], true );
}
}
self::$created = array();
+21 -6
View File
@@ -41,8 +41,7 @@ TMDO_Live::test(
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' );
},
array( 'xfail' => 'PR-056' )
}
);
TMDO_Live::test(
@@ -59,8 +58,7 @@ TMDO_Live::test(
wp_cache_delete( $uid, 'user_meta' );
tmdo_live_assert_same( 'After', get_user_meta( $uid, 'last_name', true ), 'last_name after demotion' );
},
array( 'xfail' => 'PR-056' )
}
);
TMDO_Live::test(
@@ -76,6 +74,23 @@ TMDO_Live::test(
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' );
},
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' );
}
);