Changeset 62064
Legend:
- Unmodified
- Added
- Removed
-
trunk/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php
r61788 r62064 80 80 } 81 81 82 // Create an envelope and stamp each update to enable cursor-based filtering. 83 $envelope = array( 84 'timestamp' => $this->get_time_marker(), 85 'value' => $update, 86 ); 87 88 return (bool) add_post_meta( $post_id, wp_slash( self::SYNC_UPDATE_META_KEY ), wp_slash( $envelope ), false ); 89 } 90 91 /** 92 * Retrieves all sync updates for a given room. 93 * 94 * @since 7.0.0 95 * 96 * @param string $room Room identifier. 97 * @return array<int, array{ timestamp: int, value: mixed }> Sync updates. 98 */ 99 private function get_all_updates( string $room ): array { 100 $this->room_cursors[ $room ] = $this->get_time_marker() - 100; // Small buffer to ensure consistency. 101 102 $post_id = $this->get_storage_post_id( $room ); 103 if ( null === $post_id ) { 104 return array(); 105 } 106 107 $updates = get_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, false ); 108 109 if ( ! is_array( $updates ) ) { 110 $updates = array(); 111 } 112 113 // Filter out any updates that don't have the expected structure. 114 $updates = array_filter( 115 $updates, 116 static function ( $update ): bool { 117 return is_array( $update ) && isset( $update['timestamp'], $update['value'] ) && is_int( $update['timestamp'] ); 118 } 119 ); 120 121 $this->room_update_counts[ $room ] = count( $updates ); 122 123 return $updates; 82 $meta_id = add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $update, false ); 83 84 return (bool) $meta_id; 124 85 } 125 86 … … 171 132 * 172 133 * The cursor is set during get_updates_after_cursor() and represents the 173 * point in time just before the updates were retrieved, with a small buffer 174 * to ensure consistency. 134 * highest meta_id seen for the room's sync updates. 175 135 * 176 136 * @since 7.0.0 … … 237 197 238 198 /** 239 * Gets the current time in milliseconds as a comparable time marker.240 *241 * @since 7.0.0242 *243 * @return int Current time in milliseconds.244 */245 private function get_time_marker(): int {246 return (int) floor( microtime( true ) * 1000 );247 }248 249 /**250 199 * Gets the number of updates stored for a given room. 251 200 * … … 260 209 261 210 /** 262 * Retrieves sync updates from a room for a given client and cursor. Updates 263 * from the specified client should be excluded. 211 * Retrieves sync updates from a room after the given cursor. 264 212 * 265 213 * @since 7.0.0 266 214 * 267 215 * @param string $room Room identifier. 268 * @param int $cursor Return updates after this cursor .216 * @param int $cursor Return updates after this cursor (meta_id). 269 217 * @return array<int, mixed> Sync updates. 270 218 */ 271 219 public function get_updates_after_cursor( string $room, int $cursor ): array { 272 $all_updates = $this->get_all_updates( $room ); 273 $updates = array(); 274 275 foreach ( $all_updates as $update ) { 276 if ( $update['timestamp'] > $cursor ) { 277 $updates[] = $update; 278 } 279 } 280 281 // Sort by timestamp to ensure order. 282 usort( 283 $updates, 284 fn ( $a, $b ) => $a['timestamp'] <=> $b['timestamp'] 285 ); 286 287 return wp_list_pluck( $updates, 'value' ); 220 global $wpdb; 221 222 $post_id = $this->get_storage_post_id( $room ); 223 if ( null === $post_id ) { 224 $this->room_cursors[ $room ] = 0; 225 $this->room_update_counts[ $room ] = 0; 226 return array(); 227 } 228 229 // Capture the current room state first so the returned cursor is race-safe. 230 $stats = $wpdb->get_row( 231 $wpdb->prepare( 232 "SELECT COUNT(*) AS total_updates, COALESCE( MAX(meta_id), 0 ) AS max_meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s", 233 $post_id, 234 self::SYNC_UPDATE_META_KEY 235 ) 236 ); 237 238 $total_updates = $stats ? (int) $stats->total_updates : 0; 239 $max_meta_id = $stats ? (int) $stats->max_meta_id : 0; 240 241 $this->room_update_counts[ $room ] = $total_updates; 242 $this->room_cursors[ $room ] = $max_meta_id; 243 244 if ( $max_meta_id <= $cursor ) { 245 return array(); 246 } 247 248 $rows = $wpdb->get_results( 249 $wpdb->prepare( 250 "SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id > %d AND meta_id <= %d ORDER BY meta_id ASC", 251 $post_id, 252 self::SYNC_UPDATE_META_KEY, 253 $cursor, 254 $max_meta_id 255 ) 256 ); 257 258 if ( ! $rows ) { 259 return array(); 260 } 261 262 $updates = array(); 263 foreach ( $rows as $row ) { 264 $updates[] = maybe_unserialize( $row->meta_value ); 265 } 266 267 return $updates; 288 268 } 289 269 … … 294 274 * 295 275 * @param string $room Room identifier. 296 * @param int $cursor Remove updates with m arkers< this cursor.276 * @param int $cursor Remove updates with meta_id < this cursor. 297 277 * @return bool True on success, false on failure. 298 278 */ 299 279 public function remove_updates_before_cursor( string $room, int $cursor ): bool { 280 global $wpdb; 281 300 282 $post_id = $this->get_storage_post_id( $room ); 301 283 if ( null === $post_id ) { … … 303 285 } 304 286 305 $all_updates = $this->get_all_updates( $room ); 306 307 // Remove all updates for the room and re-store only those that are newer than the cursor. 308 if ( ! delete_post_meta( $post_id, wp_slash( self::SYNC_UPDATE_META_KEY ) ) ) { 287 $deleted_rows = $wpdb->query( 288 $wpdb->prepare( 289 "DELETE FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id < %d", 290 $post_id, 291 self::SYNC_UPDATE_META_KEY, 292 $cursor 293 ) 294 ); 295 296 if ( false === $deleted_rows ) { 309 297 return false; 310 298 } 311 299 312 // Re-store envelopes directly to avoid double-wrapping by add_update(). 313 $add_result = true; 314 foreach ( $all_updates as $envelope ) { 315 if ( $add_result && $envelope['timestamp'] >= $cursor ) { 316 $add_result = (bool) add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $envelope, false ); 317 } 318 } 319 320 return $add_result; 300 return true; 321 301 } 322 302 } -
trunk/tests/phpunit/tests/rest-api/rest-sync-server.php
r62058 r62064 335 335 $data = $response->get_data(); 336 336 $this->assertIsInt( $data['rooms'][0]['end_cursor'] ); 337 $this->assertGreaterThan ( 0, $data['rooms'][0]['end_cursor'] );337 $this->assertGreaterThanOrEqual( 0, $data['rooms'][0]['end_cursor'] ); 338 338 } 339 339 … … 564 564 $data = $response->get_data(); 565 565 $this->assertSame( 3, $data['rooms'][0]['total_updates'] ); 566 } 567 568 public function test_sync_cursor_does_not_skip_update_inserted_during_fetch_window() { 569 global $wpdb; 570 571 wp_set_current_user( self::$editor_id ); 572 573 $room = $this->get_post_room(); 574 $storage = new WP_Sync_Post_Meta_Storage(); 575 576 $seed_update = array( 577 'client_id' => 1, 578 'type' => 'update', 579 'data' => 'c2VlZA==', 580 ); 581 582 $this->assertTrue( $storage->add_update( $room, $seed_update ) ); 583 584 $initial_updates = $storage->get_updates_after_cursor( $room, 0 ); 585 $baseline_cursor = $storage->get_cursor( $room ); 586 587 $this->assertCount( 1, $initial_updates ); 588 $this->assertSame( $seed_update, $initial_updates[0] ); 589 $this->assertGreaterThan( 0, $baseline_cursor ); 590 591 $storage_posts = get_posts( 592 array( 593 'post_type' => WP_Sync_Post_Meta_Storage::POST_TYPE, 594 'posts_per_page' => 1, 595 'post_status' => 'publish', 596 'name' => md5( $room ), 597 'fields' => 'ids', 598 ) 599 ); 600 $storage_post_id = array_first( $storage_posts ); 601 602 $this->assertIsInt( $storage_post_id ); 603 604 $injected_update = array( 605 'client_id' => 9999, 606 'type' => 'update', 607 'data' => base64_encode( 'injected-during-fetch' ), 608 ); 609 610 $original_wpdb = $wpdb; 611 $proxy_wpdb = new class( $original_wpdb, $storage_post_id, $injected_update ) { 612 private $wpdb; 613 private $storage_post_id; 614 private $injected_update; 615 public $postmeta; 616 public $did_inject = false; 617 618 public function __construct( $wpdb, int $storage_post_id, array $injected_update ) { 619 $this->wpdb = $wpdb; 620 $this->storage_post_id = $storage_post_id; 621 $this->injected_update = $injected_update; 622 $this->postmeta = $wpdb->postmeta; 623 } 624 625 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Proxy forwards fully prepared core queries. 626 public function prepare( ...$args ) { 627 return $this->wpdb->prepare( ...$args ); 628 } 629 630 public function get_row( $query = null, $output = OBJECT, $y = 0 ) { 631 $result = $this->wpdb->get_row( $query, $output, $y ); 632 633 $this->maybe_inject_after_sync_query( $query ); 634 635 return $result; 636 } 637 638 public function get_var( $query = null, $x = 0, $y = 0 ) { 639 $result = $this->wpdb->get_var( $query, $x, $y ); 640 641 $this->maybe_inject_after_sync_query( $query ); 642 643 return $result; 644 } 645 646 public function get_results( $query = null, $output = OBJECT ) { 647 return $this->wpdb->get_results( $query, $output ); 648 } 649 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared 650 651 public function __call( $name, $arguments ) { 652 return $this->wpdb->$name( ...$arguments ); 653 } 654 655 public function __get( $name ) { 656 return $this->wpdb->$name; 657 } 658 659 public function __set( $name, $value ) { 660 $this->wpdb->$name = $value; 661 } 662 663 private function inject_update(): void { 664 if ( $this->did_inject ) { 665 return; 666 } 667 668 $this->did_inject = true; 669 670 add_post_meta( 671 $this->storage_post_id, 672 WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, 673 $this->injected_update, 674 false 675 ); 676 } 677 678 private function maybe_inject_after_sync_query( $query ): void { 679 if ( $this->did_inject || ! is_string( $query ) ) { 680 return; 681 } 682 683 $targets_postmeta = false !== strpos( $query, $this->postmeta ); 684 $targets_post_id = 1 === preg_match( '/\bpost_id\s*=\s*' . (int) $this->storage_post_id . '\b/', $query ); 685 $targets_meta_key = 1 === preg_match( 686 "/\bmeta_key\s*=\s*'" . preg_quote( WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, '/' ) . "'/", 687 $query 688 ); 689 690 if ( $targets_postmeta && $targets_post_id && $targets_meta_key ) { 691 $this->inject_update(); 692 } 693 } 694 }; 695 696 $wpdb = $proxy_wpdb; 697 try { 698 $race_updates = $storage->get_updates_after_cursor( $room, $baseline_cursor ); 699 $race_cursor = $storage->get_cursor( $room ); 700 } finally { 701 $wpdb = $original_wpdb; 702 } 703 704 $this->assertTrue( $proxy_wpdb->did_inject, 'Expected race-window update injection to occur.' ); 705 $this->assertEmpty( $race_updates ); 706 $this->assertSame( $baseline_cursor, $race_cursor ); 707 708 $follow_up_updates = $storage->get_updates_after_cursor( $room, $race_cursor ); 709 $follow_up_cursor = $storage->get_cursor( $room ); 710 711 $this->assertCount( 1, $follow_up_updates ); 712 $this->assertSame( $injected_update, $follow_up_updates[0] ); 713 $this->assertGreaterThan( $race_cursor, $follow_up_cursor ); 566 714 } 567 715 … … 707 855 } 708 856 857 public function test_sync_compaction_does_not_delete_update_inserted_during_delete() { 858 global $wpdb; 859 860 wp_set_current_user( self::$editor_id ); 861 862 $room = $this->get_post_room(); 863 $storage = new WP_Sync_Post_Meta_Storage(); 864 865 // Seed three updates so there's something to compact. 866 for ( $i = 1; $i <= 3; $i++ ) { 867 $this->assertTrue( 868 $storage->add_update( 869 $room, 870 array( 871 'client_id' => $i, 872 'type' => 'update', 873 'data' => base64_encode( "seed-$i" ), 874 ) 875 ) 876 ); 877 } 878 879 // Capture the cursor after all seeds are in place. 880 $storage->get_updates_after_cursor( $room, 0 ); 881 $compaction_cursor = $storage->get_cursor( $room ); 882 $this->assertGreaterThan( 0, $compaction_cursor ); 883 884 $storage_posts = get_posts( 885 array( 886 'post_type' => WP_Sync_Post_Meta_Storage::POST_TYPE, 887 'posts_per_page' => 1, 888 'post_status' => 'publish', 889 'name' => md5( $room ), 890 'fields' => 'ids', 891 ) 892 ); 893 $storage_post_id = array_first( $storage_posts ); 894 $this->assertIsInt( $storage_post_id ); 895 896 $concurrent_update = array( 897 'client_id' => 9999, 898 'type' => 'update', 899 'data' => base64_encode( 'arrived-during-compaction' ), 900 ); 901 902 $original_wpdb = $wpdb; 903 $proxy_wpdb = new class( $original_wpdb, $storage_post_id, $concurrent_update ) { 904 private $wpdb; 905 private $storage_post_id; 906 private $concurrent_update; 907 public $did_inject = false; 908 909 public function __construct( $wpdb, int $storage_post_id, array $concurrent_update ) { 910 $this->wpdb = $wpdb; 911 $this->storage_post_id = $storage_post_id; 912 $this->concurrent_update = $concurrent_update; 913 } 914 915 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Proxy forwards fully prepared core queries. 916 public function prepare( ...$args ) { 917 return $this->wpdb->prepare( ...$args ); 918 } 919 920 public function query( $query ) { 921 $result = $this->wpdb->query( $query ); 922 923 // After the DELETE executes, inject a concurrent update via 924 // raw SQL through the real $wpdb to avoid metadata cache 925 // interactions while the proxy is active. 926 if ( ! $this->did_inject 927 && is_string( $query ) 928 && 0 === strpos( $query, "DELETE FROM {$this->wpdb->postmeta}" ) 929 && false !== strpos( $query, "post_id = {$this->storage_post_id}" ) 930 ) { 931 $this->did_inject = true; 932 $this->wpdb->insert( 933 $this->wpdb->postmeta, 934 array( 935 'post_id' => $this->storage_post_id, 936 'meta_key' => WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY, 937 'meta_value' => maybe_serialize( $this->concurrent_update ), 938 ), 939 array( '%d', '%s', '%s' ) 940 ); 941 } 942 943 return $result; 944 } 945 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared 946 947 public function __call( $name, $arguments ) { 948 return $this->wpdb->$name( ...$arguments ); 949 } 950 951 public function __get( $name ) { 952 return $this->wpdb->$name; 953 } 954 955 public function __set( $name, $value ) { 956 $this->wpdb->$name = $value; 957 } 958 }; 959 960 // Run compaction through the proxy so the concurrent update 961 // is injected immediately after the DELETE executes. 962 $wpdb = $proxy_wpdb; 963 try { 964 $result = $storage->remove_updates_before_cursor( $room, $compaction_cursor ); 965 } finally { 966 $wpdb = $original_wpdb; 967 } 968 969 $this->assertTrue( $result ); 970 $this->assertTrue( $proxy_wpdb->did_inject, 'Expected concurrent update injection to occur.' ); 971 972 // The concurrent update must survive the compaction delete. 973 $updates = $storage->get_updates_after_cursor( $room, 0 ); 974 975 $update_data = wp_list_pluck( $updates, 'data' ); 976 $this->assertContains( 977 $concurrent_update['data'], 978 $update_data, 979 'Concurrent update should survive compaction.' 980 ); 981 } 982 709 983 /* 710 984 * Awareness tests.
Note: See TracChangeset
for help on using the changeset viewer.