Make WordPress Core

Changeset 62099


Ignore:
Timestamp:
03/24/2026 09:07:04 AM (5 months ago)
Author:
zieladam
Message:

Real-time collaboration: Use prepared queries instead of *_post_meta functions.

Replaces add_post_meta/update_post_meta with wpdb->insert/wpdb->update.

This prevents a real-time editing session from invalidating WP_Query and various other post caches every few seconds. RTC stores awareness and sync information in post meta with high frequency. However, every call the *_post_meta functions invalidated post caches.

This commit avoids this frequent invalidation by removing the direct *_post_meta calls in favor of $wpdb calls.

Props czarate, mukesh27, paulkevan.

Developed in https://github.com/WordPress/wordpress-develop/pull/11325.
See #64696.

Location:
trunk
Files:
2 added
2 edited

Legend:

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

    r62064 r62099  
    3131         * @var string
    3232         */
    33         const AWARENESS_META_KEY = 'wp_sync_awareness';
     33        const AWARENESS_META_KEY = 'wp_sync_awareness_state';
    3434
    3535        /**
     
    3939         * @var string
    4040         */
    41         const SYNC_UPDATE_META_KEY = 'wp_sync_update';
     41        const SYNC_UPDATE_META_KEY = 'wp_sync_update_data';
    4242
    4343        /**
     
    6969         *
    7070         * @since 7.0.0
     71         *
     72         * @global wpdb $wpdb WordPress database abstraction object.
    7173         *
    7274         * @param string $room   Room identifier.
     
    7577         */
    7678        public function add_update( string $room, $update ): bool {
     79                global $wpdb;
     80
    7781                $post_id = $this->get_storage_post_id( $room );
    7882                if ( null === $post_id ) {
     
    8084                }
    8185
    82                 $meta_id = add_post_meta( $post_id, self::SYNC_UPDATE_META_KEY, $update, false );
    83 
    84                 return (bool) $meta_id;
     86                // Use direct database operation to avoid cache invalidation performed by
     87                // post meta functions (`wp_cache_set_posts_last_changed()` and direct
     88                // `wp_cache_delete()` calls).
     89                return (bool) $wpdb->insert(
     90                        $wpdb->postmeta,
     91                        array(
     92                                'post_id'    => $post_id,
     93                                'meta_key'   => self::SYNC_UPDATE_META_KEY,
     94                                'meta_value' => wp_json_encode( $update ),
     95                        ),
     96                        array( '%d', '%s', '%s' )
     97                );
    8598        }
    8699
     
    89102         *
    90103         * @since 7.0.0
     104         *
     105         * @global wpdb $wpdb WordPress database abstraction object.
    91106         *
    92107         * @param string $room Room identifier.
     
    94109         */
    95110        public function get_awareness_state( string $room ): array {
    96                 $post_id = $this->get_storage_post_id( $room );
    97                 if ( null === $post_id ) {
    98                         return array();
    99                 }
    100 
    101                 $awareness = get_post_meta( $post_id, self::AWARENESS_META_KEY, true );
     111                global $wpdb;
     112
     113                $post_id = $this->get_storage_post_id( $room );
     114                if ( null === $post_id ) {
     115                        return array();
     116                }
     117
     118                // Use direct database operation to avoid updating the post meta cache.
     119                // ORDER BY meta_id DESC ensures the latest row wins if duplicates exist
     120                // from a past race condition in set_awareness_state().
     121                $meta_value = $wpdb->get_var(
     122                        $wpdb->prepare(
     123                                "SELECT meta_value FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s ORDER BY meta_id DESC LIMIT 1",
     124                                $post_id,
     125                                self::AWARENESS_META_KEY
     126                        )
     127                );
     128
     129                if ( null === $meta_value ) {
     130                        return array();
     131                }
     132
     133                $awareness = json_decode( $meta_value, true );
    102134
    103135                if ( ! is_array( $awareness ) ) {
     
    112144         *
    113145         * @since 7.0.0
     146         *
     147         * @global wpdb $wpdb WordPress database abstraction object.
    114148         *
    115149         * @param string            $room      Room identifier.
     
    118152         */
    119153        public function set_awareness_state( string $room, array $awareness ): bool {
     154                global $wpdb;
     155
    120156                $post_id = $this->get_storage_post_id( $room );
    121157                if ( null === $post_id ) {
     
    123159                }
    124160
    125                 // update_post_meta returns false if the value is the same as the existing value.
    126                 update_post_meta( $post_id, wp_slash( self::AWARENESS_META_KEY ), wp_slash( $awareness ) );
    127                 return true;
     161                // Use direct database operation to avoid cache invalidation performed by
     162                // post meta functions (`wp_cache_set_posts_last_changed()` and direct
     163                // `wp_cache_delete()` calls).
     164                //
     165                // If two concurrent requests both see no row and both INSERT, the
     166                // duplicate is harmless: get_awareness_state() reads the latest row
     167                // (ORDER BY meta_id DESC).
     168                $meta_id = $wpdb->get_var(
     169                        $wpdb->prepare(
     170                                "SELECT meta_id FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = %s ORDER BY meta_id DESC LIMIT 1",
     171                                $post_id,
     172                                self::AWARENESS_META_KEY
     173                        )
     174                );
     175
     176                if ( $meta_id ) {
     177                        return (bool) $wpdb->update(
     178                                $wpdb->postmeta,
     179                                array( 'meta_value' => wp_json_encode( $awareness ) ),
     180                                array( 'meta_id' => $meta_id ),
     181                                array( '%s' ),
     182                                array( '%d' )
     183                        );
     184                }
     185
     186                return (bool) $wpdb->insert(
     187                        $wpdb->postmeta,
     188                        array(
     189                                'post_id'    => $post_id,
     190                                'meta_key'   => self::AWARENESS_META_KEY,
     191                                'meta_value' => wp_json_encode( $awareness ),
     192                        ),
     193                        array( '%d', '%s', '%s' )
     194                );
    128195        }
    129196
     
    169236                                'name'           => $room_hash,
    170237                                'fields'         => 'ids',
     238                                'orderby'        => 'ID',
     239                                'order'          => 'ASC',
    171240                        )
    172241                );
     
    213282         * @since 7.0.0
    214283         *
     284         * @global wpdb $wpdb WordPress database abstraction object.
     285         *
    215286         * @param string $room   Room identifier.
    216287         * @param int    $cursor Return updates after this cursor (meta_id).
     
    262333                $updates = array();
    263334                foreach ( $rows as $row ) {
    264                         $updates[] = maybe_unserialize( $row->meta_value );
     335                        $decoded = json_decode( $row->meta_value, true );
     336                        if ( null !== $decoded ) {
     337                                $updates[] = $decoded;
     338                        }
    265339                }
    266340
     
    272346         *
    273347         * @since 7.0.0
     348         *
     349         * @global wpdb $wpdb WordPress database abstraction object.
    274350         *
    275351         * @param string $room   Room identifier.
  • trunk/tests/phpunit/tests/rest-api/rest-sync-server.php

    r62064 r62099  
    566566        }
    567567
    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 );
    714         }
    715 
    716568        /*
    717569         * Compaction tests.
     
    855707        }
    856708
    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 
    983709        /*
    984710         * Awareness tests.
Note: See TracChangeset for help on using the changeset viewer.