Make WordPress Core

Opened 5 weeks ago

Last modified 4 weeks ago

#64863 new enhancement

Eliminate switch_to_blog() from get_blog_option(), update_blog_option(), WP_Site::get_details(), and get_blog_post() using $wpdb->get_blog_prefix()

Reported by: pers's profile PerS Owned by:
Milestone: Awaiting Review Priority: normal
Severity: normal Version:
Component: Networks and Sites Keywords: has-patch needs-testing has-unit-tests
Focuses: multisite, performance Cc:

Description

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:

// 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()).

/**
 * 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.

// 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.

// 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():

// 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:

// 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.

Change History (3)

This ticket was mentioned in PR #11257 on WordPress/wordpress-develop by @PerS.


5 weeks ago
#1

  • Keywords has-unit-tests added

### 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.

#2 @PerS
5 weeks ago

#59173 was marked as a duplicate.

@PerS commented on PR #11257:


4 weeks ago
#3

Note, the patch is better explained at https://core.trac.wordpress.org/ticket/64863

Note: See TracTickets for help on using tickets.