Skip to content

Eliminate switch_to_blog() from multisite option/post functions#11257

Open
soderlind wants to merge 10 commits intoWordPress:trunkfrom
soderlind:switch/free
Open

Eliminate switch_to_blog() from multisite option/post functions#11257
soderlind wants to merge 10 commits intoWordPress:trunkfrom
soderlind:switch/free

Conversation

@soderlind
Copy link

Motivation

switch_to_blog() mutates six globals and, on the fallback object-cache implementation, wipes the entire object cache. Several core functions use it internally even though they only need to read or write a single row in a per-site table. This patch replaces those internal switches with direct queries using $wpdb->get_blog_prefix( $blog_id ), which is a pure function — no side-effects, no globals written.


Files changed

1. ms-blogs.php

New function: _get_option_from_blog() (added before get_blog_option())

  • Internal helper that reads a single option from any site's wp_N_options table without switching blog context.
  • Fast path: delegates to get_option() when $blog_id === get_current_blog_id().
  • Checks wp_cache_get( $blog_id, 'blog-alloptions' ) and blog-notoptions before hitting the DB.
  • Falls back to a direct SELECT against $wpdb->get_blog_prefix( $blog_id ) . 'options'.
  • Caches misses in blog-notoptions.
  • Applies the existing blog_option_{$option} filter.

Refactored: get_blog_option()

- 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 );
+ return _get_option_from_blog( $id, $option, $default_value );

Refactored: add_blog_option()

- switch_to_blog( $id );
- $return = add_option( $option, $value );
- restore_current_blog();
- return $return;
+ $table  = $wpdb->get_blog_prefix( $id ) . 'options';
+ $exists = $wpdb->get_var( ... );      // check if option already exists
+ if ( $exists ) { return false; }
+ $result = $wpdb->insert( $table, ... );
+ wp_cache_delete( $id, 'blog-alloptions' );
+ wp_cache_delete( $id, 'blog-notoptions' );
+ return false !== $result;

Refactored: delete_blog_option()

- switch_to_blog( $id );
- $return = delete_option( $option );
- restore_current_blog();
- return $return;
+ $table  = $wpdb->get_blog_prefix( $id ) . 'options';
+ $result = $wpdb->delete( $table, [ 'option_name' => $option ] );
+ wp_cache_delete( $id, 'blog-alloptions' );
+ wp_cache_delete( $id, 'blog-notoptions' );
+ return false !== $result && $result > 0;

Refactored: update_blog_option()

- switch_to_blog( $id );
- $return = update_option( $option, $value );
- restore_current_blog();
- return $return;
+ $old_value = _get_option_from_blog( $id, $option );
+ if ( $serialized === maybe_serialize( $old_value ) ) { return false; }
+ // $wpdb->update() if exists, $wpdb->insert() if new
+ wp_cache_delete( $id, 'blog-alloptions' );
+ wp_cache_delete( $id, 'blog-notoptions' );
+ return false !== $result;

2. class-wp-site.php

Refactored: WP_Site::get_details() (private)

  if ( false === $details ) {
-     switch_to_blog( $this->blog_id );
+     $id = (int) $this->blog_id;
      $details = new stdClass();
      foreach ( get_object_vars( $this ) as $key => $value ) {
          $details->$key = $value;
      }
-     $details->blogname   = get_option( 'blogname' );
-     $details->siteurl    = get_option( 'siteurl' );
-     $details->post_count = get_option( 'post_count' );
-     $details->home       = get_option( 'home' );
-     restore_current_blog();
+     $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' );

3. ms-functions.php

Refactored: get_blog_post()

- function get_blog_post( $blog_id, $post_id ) {
-     switch_to_blog( $blog_id );
-     $post = get_post( $post_id );
-     restore_current_blog();
-     return $post;
- }
+ 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' );
+ }

4. multisite.php

3 new test methods added:

Test Covers
test_get_blog_option_does_not_switch_context() get_blog_option() on another site must not set $GLOBALS['switched'] to true
test_update_blog_option_does_not_switch_context() update_blog_option() on another site must not set $GLOBALS['switched'] to true
test_wp_site_get_blogname_without_switching() WP_Site::__get('blogname') returns correct value and doesn't set $GLOBALS['switched']

5. site.php

1 new test method added:

Test Covers
test_get_blog_post_does_not_switch_context() get_blog_post() returns correct WP_Post when fetching from another site

Design decisions

  • _get_option_from_blog() — prefixed with _ per WordPress convention for internal/private APIs.
  • Current-blog fast path preserved in every function — delegates to native get_option() / update_option() / add_option() / delete_option() / get_post() for full filter and cache compatibility.
  • Cache groups blog-alloptions and blog-notoptions match the existing WordPress core cache strategy.
  • Cache busting — all write functions (add_, update_, delete_) invalidate both cache groups.
  • update_blog_option() — compares serialized old/new values and returns false when unchanged, matching update_option() behavior.
  • get_blog_post() — returns null on failure (matches documented WP_Post|null return type).

Out of scope

  • get_blog_details() (deprecated function at line 249 still uses switch_to_blog)
  • wp_initialize_site() / wp_uninitialize_site() — deeper coupling to switched context
  • Third-party plugin code

Trac ticket: https://core.trac.wordpress.org/ticket/64863

Use of AI Tools

GitHub Copilot and Opus 4.6 have been used to review the changes.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

- Introduced _get_option_from_blog() to retrieve options for a specific site without switching context, improving performance and reducing potential side effects.
- Updated get_blog_option() to utilize the new function, maintaining functionality while avoiding unnecessary context switches.
- Modified get_blog_post() to fetch posts directly from the database without switching blogs, ensuring consistency and efficiency.
- Added tests to verify that get_blog_option() and get_blog_post() do not alter the global switched state, ensuring expected behavior in multisite environments.

See https://core.trac.wordpress.org/ticket/64863
@github-actions
Copy link

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props pers.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions
Copy link

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Updated the assertions in the Tests_Option_Multisite class to improve code consistency by removing unnecessary spaces around array keys in the checks for the $GLOBALS['switched'] variable. This change enhances readability and maintains coding standards across the test cases.
Replace switch_to_blog()/restore_current_blog() pairs in get_blog_option(),
add_blog_option(), delete_blog_option(), update_blog_option(),
WP_Site::get_details(), and get_blog_post() with direct DB queries using
$wpdb->get_blog_prefix($blog_id).

Add _get_option_from_blog() internal helper for non-switching option reads
with object-cache support (blog-alloptions/blog-notoptions groups).

Add regression tests verifying $GLOBALS['switched'] is not set after
cross-site option and post operations.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant