From b84252d7875cb1fdeb9b3998503b68832696e474 Mon Sep 17 00:00:00 2001 From: wpdev Date: Sat, 26 Sep 2026 18:56:45 +0800 Subject: [PATCH] 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 --- includes/engine/class-tmdo-hook-bus.php | 56 ++++++++++++++------ tests/integration/HookBusIntegrationTest.php | 24 +++++++++ tests/live/README.md | 3 +- tests/live/lib/fixtures.php | 56 +++++++++++++++++--- tests/live/suites/shadow_write.php | 27 +++++++--- 5 files changed, 134 insertions(+), 32 deletions(-) diff --git a/includes/engine/class-tmdo-hook-bus.php b/includes/engine/class-tmdo-hook-bus.php index 505822b..dbce53e 100644 --- a/includes/engine/class-tmdo-hook-bus.php +++ b/includes/engine/class-tmdo-hook-bus.php @@ -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 $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() ); + } + } + /** * 取得(或載入)完整列資料,並快取 */ diff --git a/tests/integration/HookBusIntegrationTest.php b/tests/integration/HookBusIntegrationTest.php index 4c8c1db..7704bf5 100644 --- a/tests/integration/HookBusIntegrationTest.php +++ b/tests/integration/HookBusIntegrationTest.php @@ -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( diff --git a/tests/live/README.md b/tests/live/README.md index 41668c3..6963076 100644 --- a/tests/live/README.md +++ b/tests/live/README.md @@ -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/`) diff --git a/tests/live/lib/fixtures.php b/tests/live/lib/fixtures.php index 6a31654..1c0fe4c 100644 --- a/tests/live/lib/fixtures.php +++ b/tests/live/lib/fixtures.php @@ -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). * @@ -109,14 +133,15 @@ final class TMDO_Live_Fixtures { /** * Raw flat-table value for a key; 'NO-ROW' when the entity has no row. * - * @param string $type Entity type. - * @param int $id Entity ID. - * @param string $key Meta key. + * @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. * - * @param string $type Entity type. - * @param string $key Meta key. + * 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(); diff --git a/tests/live/suites/shadow_write.php b/tests/live/suites/shadow_write.php index dc51062..4327a01 100644 --- a/tests/live/suites/shadow_write.php +++ b/tests/live/suites/shadow_write.php @@ -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' ); + } );