Make WordPress Core

Changeset 62099


Ignore:
Timestamp:
03/24/2026 09:07:04 AM (4 days 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.