Make WordPress Core

Changeset 62064


Ignore:
Timestamp:
03/19/2026 01:13:06 PM (9 days 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.