[wp-trac] [WordPress Trac] #64863: Eliminate switch_to_blog() from get_blog_option(), update_blog_option(), WP_Site::get_details(), and get_blog_post() using $wpdb->get_blog_prefix()

WordPress Trac noreply at wordpress.org
Sat Mar 14 22:37:58 UTC 2026


#64863: Eliminate switch_to_blog() from get_blog_option(), update_blog_option(),
WP_Site::get_details(), and get_blog_post() using $wpdb->get_blog_prefix()
------------------------------------+-------------------------------------
 Reporter:  PerS                    |      Owner:  (none)
     Type:  enhancement             |     Status:  new
 Priority:  normal                  |  Milestone:  Awaiting Review
Component:  Networks and Sites      |    Version:
 Severity:  normal                  |   Keywords:  has-patch needs-testing
  Focuses:  multisite, performance  |
------------------------------------+-------------------------------------
 == Description ==

 `switch_to_blog()` mutates six globals and, on the fallback object-cache
 implementation, **wipes the entire object cache** via `wp_cache_init()`
 inside `wp_cache_switch_to_blog_fallback()`. Every `switch_to_blog()` /
 `restore_current_blog()` pair is two full global-state mutations.

 Several core functions use `switch_to_blog()` internally even though they
 only need to read or write a single row in a per-site table. This ticket
 proposes replacing those internal switches with direct queries using
 `$wpdb->get_blog_prefix( $blog_id )`, which is a **pure function** — no
 side-effects, no globals written.

 === Globals mutated by switch_to_blog() ===

 ||= Global mutated =||= What changes =||
 || `$wpdb->blogid` / `$wpdb->prefix` || Table prefix rewritten (e.g. `wp_`
 → `wp_3_`) ||
 || `$wpdb->options`, `$wpdb->posts`, … || All per-blog table properties
 rewritten ||
 || `$GLOBALS['table_prefix']` || Mirrors the new prefix ||
 || `$GLOBALS['blog_id']` || Set to new blog ID ||
 || `$GLOBALS['_wp_switched_stack']` || Previous ID pushed ||
 || `$GLOBALS['switched']` || Set to `true` ||
 || `$wp_object_cache` || Full cache wipe on fallback ||

 === Real-world impact ===

 Any plugin or core code that iterates over sites and accesses `blogname`,
 `siteurl`, or other per-site options triggers `switch_to_blog()` for each
 site — either directly via `get_blog_option()` or indirectly via
 `WP_Site::get_details()` (called by `WP_Site::__get()` for magic
 properties like `blogname` and `siteurl`).

 For a network with 100 sites, a single loop fetching `blogname` and
 `siteurl` causes **~200 global-state mutations** (100 switch + 100
 restore).

 ----

 == The Key Insight ==

 `wpdb::get_blog_prefix( $blog_id )` already exists as a public method,
 accepts an explicit blog ID, and returns the correct table prefix
 **without touching any global state**:

 {{{#!php
 // From class-wpdb.php — pure computation, no side-effects:
 public function get_blog_prefix( $blog_id = null ) {
     if ( is_multisite() ) {
         if ( null === $blog_id ) {
             $blog_id = $this->blogid;
         }
         $blog_id = (int) $blog_id;
         if ( defined( 'MULTISITE' ) && ( 0 === $blog_id || 1 === $blog_id
 ) ) {
             return $this->base_prefix;
         } else {
             return $this->base_prefix . $blog_id . '_';
         }
     }
     return $this->base_prefix;
 }
 }}}

 Core already uses this pattern for non-options tables (e.g. `ms-
 functions.php` line 2003 queries `{prefix}posts` directly). The only
 reason `{prefix}options` is never queried this way is that `get_option()`
 has no table parameter — not because direct queries are prohibited.

 ----

 == Proposed Changes ==

 === 1. New private helper: `_get_option_from_blog()` ===

 Add to `wp-includes/ms-blogs.php`. Reads a single option from any site's
 `wp_N_options` table without switching. Handles object-cache correctly
 (same `alloptions` / `notoptions` groups as `get_option()`).

 {{{#!php
 /**
  * Retrieves an option value for a specific site without switching blog
 context.
  *
  * Uses the object cache (same 'options'/'notoptions' groups as
 get_option()),
  * falling back to a direct DB query with the site's table prefix.
  *
  * @since 7.x.0
  * @access private
  *
  * @param int    $blog_id  Site ID.
  * @param string $option   Option name.
  * @param mixed  $default  Default value if option not found.
  * @return mixed Option value or $default.
  */
 function _get_option_from_blog( int $blog_id, string $option, mixed
 $default = false ): mixed {
     global $wpdb;

     $blog_id = (int) $blog_id;

     // Fast path: current blog — delegate to get_option().
     if ( get_current_blog_id() === $blog_id ) {
         return get_option( $option, $default );
     }

     // Check object cache.
     $alloptions_cache = wp_cache_get( $blog_id, 'blog-alloptions' );
     if ( is_array( $alloptions_cache ) && array_key_exists( $option,
 $alloptions_cache ) ) {
         return $alloptions_cache[ $option ] ?? $default;
     }

     $notoptions = wp_cache_get( $blog_id, 'blog-notoptions' );
     if ( is_array( $notoptions ) && isset( $notoptions[ $option ] ) ) {
         return $default;
     }

     // Direct DB query using get_blog_prefix() — no switch.
     $table = $wpdb->get_blog_prefix( $blog_id ) . 'options';
     $row   = $wpdb->get_row(
         $wpdb->prepare(
             "SELECT option_value FROM `{$table}` WHERE option_name = %s
 LIMIT 1",
             $option
         )
     );

     if ( null === $row ) {
         $notoptions           = is_array( $notoptions ) ? $notoptions :
 [];
         $notoptions[ $option ] = true;
         wp_cache_set( $blog_id, $notoptions, 'blog-notoptions' );
         return $default;
     }

     $value = maybe_unserialize( $row->option_value );

     return apply_filters( "blog_option_{$option}", $value, $blog_id );
 }
 }}}

 === 2. Refactor `get_blog_option()` ===

 Replace the `switch_to_blog()` / `get_option()` / `restore_current_blog()`
 sequence with a single call to the new helper.

 {{{#!php
 // BEFORE (ms-blogs.php):
 function get_blog_option( $id, $option, $default_value = false ) {
     $id = (int) $id;
     if ( empty( $id ) ) { $id = get_current_blog_id(); }

     if ( get_current_blog_id() === $id ) {
         return get_option( $option, $default_value );
     }

     switch_to_blog( $id );
     $value = get_option( $option, $default_value );
     restore_current_blog();

     return apply_filters( "blog_option_{$option}", $value, $id );
 }

 // AFTER:
 function get_blog_option( $id, $option, $default_value = false ) {
     $id = (int) $id;
     if ( empty( $id ) ) { $id = get_current_blog_id(); }

     return _get_option_from_blog( $id, $option, $default_value );
 }
 }}}

 === 3. Refactor `update_blog_option()` ===

 Replace `switch_to_blog()` + `update_option()` + `restore_current_blog()`
 with a direct `$wpdb->update/insert` against `{prefix}options`.

 {{{#!php
 // AFTER:
 function update_blog_option( $id, $option, $value, $deprecated = null ) {
     global $wpdb;
     $id = (int) $id;

     if ( null !== $deprecated ) {
         _deprecated_argument( __FUNCTION__, '3.1.0' );
     }

     if ( get_current_blog_id() === $id ) {
         return update_option( $option, $value );
     }

     $table      = $wpdb->get_blog_prefix( $id ) . 'options';
     $serialized = maybe_serialize( $value );
     $exists     = $wpdb->get_var(
         $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE option_name
 = %s", $option )
     );

     if ( $exists ) {
         $result = $wpdb->update(
             $table,
             [ 'option_value' => $serialized ],
             [ 'option_name'  => $option ],
             [ '%s' ],
             [ '%s' ]
         );
     } else {
         $result = $wpdb->insert(
             $table,
             [ 'option_name' => $option, 'option_value' => $serialized,
 'autoload' => 'yes' ],
             [ '%s', '%s', '%s' ]
         );
     }

     // Bust per-blog option caches.
     wp_cache_delete( $id, 'blog-alloptions' );
     wp_cache_delete( $id, 'blog-notoptions' );

     return false !== $result;
 }
 }}}

 Apply the same pattern to `add_blog_option()` and `delete_blog_option()`.

 === 4. Refactor `WP_Site::get_details()` ===

 In `class-wp-site.php`, `get_details()` (private) fetches `blogname`,
 `siteurl`, `post_count`, and `home` via `switch_to_blog()`. Replace with
 four calls to `_get_option_from_blog()`:

 {{{#!php
 // AFTER:
 private function get_details() {
     $details = wp_cache_get( $this->blog_id, 'site-details' );

     if ( false === $details ) {
         $id      = (int) $this->blog_id;
         $details = new stdClass();
         foreach ( get_object_vars( $this ) as $key => $value ) {
             $details->$key = $value;
         }
         $details->blogname   = _get_option_from_blog( $id, 'blogname' );
         $details->siteurl    = _get_option_from_blog( $id, 'siteurl' );
         $details->post_count = _get_option_from_blog( $id, 'post_count', 0
 );
         $details->home       = _get_option_from_blog( $id, 'home' );

         wp_cache_set( $this->blog_id, $details, 'site-details' );
     }

     $details = apply_filters_deprecated( 'blog_details', [ $details ],
 '4.7.0', 'site_details' );
     $details = apply_filters( 'site_details', $details );

     return $details;
 }
 }}}

 === 5. Refactor `get_blog_post()` ===

 In `ms-functions.php`, replace `switch_to_blog()` + `get_post()` +
 `restore_current_blog()` with a direct query against `{prefix}posts`:

 {{{#!php
 // AFTER:
 function get_blog_post( $blog_id, $post_id ) {
     global $wpdb;

     $blog_id = (int) $blog_id;
     $post_id = (int) $post_id;

     if ( get_current_blog_id() === $blog_id ) {
         return get_post( $post_id );
     }

     $table = $wpdb->get_blog_prefix( $blog_id ) . 'posts';
     $post  = $wpdb->get_row(
         $wpdb->prepare( "SELECT * FROM `{$table}` WHERE ID = %d LIMIT 1",
 $post_id )
     );

     if ( ! $post ) {
         return null;
     }

     return sanitize_post( new WP_Post( $post ), 'raw' );
 }
 }}}

 ----

 == Object-Cache Considerations ==

 The new `_get_option_from_blog()` helper must:

  * Use the same cache group strategy as `get_option()` — `blog-alloptions`
 for autoloaded options, `blog-notoptions` for known-missing options.
  * Bust caches on write — `add_blog_option`, `update_blog_option`,
 `delete_blog_option` must invalidate the same keys.
  * Lazy-load `alloptions` — on first access for a given `$blog_id`, fetch
 all autoloaded options in one query and cache them in `blog-alloptions`,
 mirroring `wp_load_alloptions()`.

 ----

 == Out of Scope ==

 These functions have deeper coupling to the switched context and are out
 of scope for an initial patch:

 ||= Function =||= Reason =||
 || `wp_initialize_site()` || Runs dozens of core operations on a new
 site's tables ||
 || `wp_uninitialize_site()` || Drops tables, clears all site data ||
 || Third-party plugin code || Cannot be fixed in core ||

 ----

 == Files Changed ==

 ||= File =||= Change =||
 || `wp-includes/ms-blogs.php` || Add `_get_option_from_blog()` private
 helper ||
 || `wp-includes/ms-blogs.php` || Refactor `get_blog_option()` to use
 helper ||
 || `wp-includes/ms-blogs.php` || Refactor `update_blog_option()` — direct
 `$wpdb` query ||
 || `wp-includes/ms-blogs.php` || Refactor `add_blog_option()` — direct
 `$wpdb->insert` ||
 || `wp-includes/ms-blogs.php` || Refactor `delete_blog_option()` — direct
 `$wpdb->delete` ||
 || `wp-includes/class-wp-site.php` || Refactor `WP_Site::get_details()` to
 use `_get_option_from_blog()` ||
 || `wp-includes/ms-functions.php` || Refactor `get_blog_post()` — direct
 `$wpdb` query ||

 All changes exploit `$wpdb->get_blog_prefix( $blog_id )`, which is already
 public, purpose-built, and side-effect-free.

 ----

 == Testing Notes ==

  * Unit tests for `get_blog_option()`, `update_blog_option()`,
 `add_blog_option()`, `delete_blog_option()` — verify return values match
 current behaviour.
  * Unit test for `WP_Site::__get('blogname')` /
 `WP_Site::__get('siteurl')` — verify values match after refactor.
  * Verify object cache is populated and busted correctly (test with both
 fallback and persistent cache drop-ins).
  * Verify `$GLOBALS['switched']` is **not** set to `true` after
 `get_blog_option()` (regression test for the switch removal).
  * Performance benchmark: loop over N sites calling `get_blog_option( $id,
 'blogname' )` — measure wall time before/after.

-- 
Ticket URL: <https://core.trac.wordpress.org/ticket/64863>
WordPress Trac <https://core.trac.wordpress.org/>
WordPress publishing platform


More information about the wp-trac mailing list