Make WordPress Core

Changeset 62064


Ignore:
Timestamp:
03/19/2026 01:13:06 PM (6 months ago)
Author:
ellatrix
Message:

Real-time collaboration: fix race condition in default polling provider.

See also: https://github.com/WordPress/wordpress-develop/pull/11067.
Developed in: https://github.com/WordPress/wordpress-develop/pull/11292.

Fixes #64887.
Props czarate, westonruter, mindctrl, peterwilsoncc, joefusco.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/collaboration/class-wp-sync-post-meta-storage.php

    r61788 r62064  
    8080                }
    8181
    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;
    12485        }
    12586
     
    171132         *
    172133         * 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.
    175135         *
    176136         * @since 7.0.0
     
    237197
    238198        /**
    239          * Gets the current time in milliseconds as a comparable time marker.
    240          *
    241          * @since 7.0.0
    242          *
    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         /**
    250199         * Gets the number of updates stored for a given room.
    251200         *
     
    260209
    261210        /**
    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.
    264212         *
    265213         * @since 7.0.0
    266214         *
    267215         * @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).
    269217         * @return array<int, mixed> Sync updates.
    270218         */
    271219        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;
    288268        }
    289269
     
    294274         *
    295275         * @param string $room   Room identifier.
    296          * @param int    $cursor Remove updates with markers < this cursor.
     276         * @param int    $cursor Remove updates with meta_id < this cursor.
    297277         * @return bool True on success, false on failure.
    298278         */
    299279        public function remove_updates_before_cursor( string $room, int $cursor ): bool {
     280                global $wpdb;
     281
    300282                $post_id = $this->get_storage_post_id( $room );
    301283                if ( null === $post_id ) {
     
    303285                }
    304286
    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 ) {
    309297                        return false;
    310298                }
    311299
    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;
    321301        }
    322302}
  • trunk/tests/phpunit/tests/rest-api/rest-sync-server.php

    r62058 r62064  
    335335                $data = $response->get_data();
    336336                $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'] );
    338338        }
    339339
     
    564564                $data = $response->get_data();
    565565                $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 );
    566714        }
    567715
     
    707855        }
    708856
     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
    709983        /*
    710984         * Awareness tests.
Note: See TracChangeset for help on using the changeset viewer.