|
| 1 | +Eliminate switch_to_blog() from get_blog_option(), update_blog_option(), WP_Site::get_details(), and get_blog_post() using $wpdb->get_blog_prefix() |
| 2 | + |
| 3 | +---- |
| 4 | + |
| 5 | +== Description == |
| 6 | + |
| 7 | +`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. |
| 8 | + |
| 9 | +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. |
| 10 | + |
| 11 | +=== Globals mutated by switch_to_blog() === |
| 12 | + |
| 13 | +||= Global mutated =||= What changes =|| |
| 14 | +|| `$wpdb->blogid` / `$wpdb->prefix` || Table prefix rewritten (e.g. `wp_` → `wp_3_`) || |
| 15 | +|| `$wpdb->options`, `$wpdb->posts`, … || All per-blog table properties rewritten || |
| 16 | +|| `$GLOBALS['table_prefix']` || Mirrors the new prefix || |
| 17 | +|| `$GLOBALS['blog_id']` || Set to new blog ID || |
| 18 | +|| `$GLOBALS['_wp_switched_stack']` || Previous ID pushed || |
| 19 | +|| `$GLOBALS['switched']` || Set to `true` || |
| 20 | +|| `$wp_object_cache` || Full cache wipe on fallback || |
| 21 | + |
| 22 | +=== Real-world impact === |
| 23 | + |
| 24 | +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`). |
| 25 | + |
| 26 | +For a network with 100 sites, a single loop fetching `blogname` and `siteurl` causes **~200 global-state mutations** (100 switch + 100 restore). |
| 27 | + |
| 28 | +---- |
| 29 | + |
| 30 | +== The Key Insight == |
| 31 | + |
| 32 | +`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**: |
| 33 | + |
| 34 | +{{{#!php |
| 35 | +// From class-wpdb.php — pure computation, no side-effects: |
| 36 | +public function get_blog_prefix( $blog_id = null ) { |
| 37 | + if ( is_multisite() ) { |
| 38 | + if ( null === $blog_id ) { |
| 39 | + $blog_id = $this->blogid; |
| 40 | + } |
| 41 | + $blog_id = (int) $blog_id; |
| 42 | + if ( defined( 'MULTISITE' ) && ( 0 === $blog_id || 1 === $blog_id ) ) { |
| 43 | + return $this->base_prefix; |
| 44 | + } else { |
| 45 | + return $this->base_prefix . $blog_id . '_'; |
| 46 | + } |
| 47 | + } |
| 48 | + return $this->base_prefix; |
| 49 | +} |
| 50 | +}}} |
| 51 | + |
| 52 | +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. |
| 53 | + |
| 54 | +---- |
| 55 | + |
| 56 | +== Proposed Changes == |
| 57 | + |
| 58 | +=== 1. New private helper: `_get_option_from_blog()` === |
| 59 | + |
| 60 | +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()`). |
| 61 | + |
| 62 | +{{{#!php |
| 63 | +/** |
| 64 | + * Retrieves an option value for a specific site without switching blog context. |
| 65 | + * |
| 66 | + * Uses the object cache (same 'options'/'notoptions' groups as get_option()), |
| 67 | + * falling back to a direct DB query with the site's table prefix. |
| 68 | + * |
| 69 | + * @since 7.x.0 |
| 70 | + * @access private |
| 71 | + * |
| 72 | + * @param int $blog_id Site ID. |
| 73 | + * @param string $option Option name. |
| 74 | + * @param mixed $default Default value if option not found. |
| 75 | + * @return mixed Option value or $default. |
| 76 | + */ |
| 77 | +function _get_option_from_blog( int $blog_id, string $option, mixed $default = false ): mixed { |
| 78 | + global $wpdb; |
| 79 | + |
| 80 | + $blog_id = (int) $blog_id; |
| 81 | + |
| 82 | + // Fast path: current blog — delegate to get_option(). |
| 83 | + if ( get_current_blog_id() === $blog_id ) { |
| 84 | + return get_option( $option, $default ); |
| 85 | + } |
| 86 | + |
| 87 | + // Check object cache. |
| 88 | + $alloptions_cache = wp_cache_get( $blog_id, 'blog-alloptions' ); |
| 89 | + if ( is_array( $alloptions_cache ) && array_key_exists( $option, $alloptions_cache ) ) { |
| 90 | + return $alloptions_cache[ $option ] ?? $default; |
| 91 | + } |
| 92 | + |
| 93 | + $notoptions = wp_cache_get( $blog_id, 'blog-notoptions' ); |
| 94 | + if ( is_array( $notoptions ) && isset( $notoptions[ $option ] ) ) { |
| 95 | + return $default; |
| 96 | + } |
| 97 | + |
| 98 | + // Direct DB query using get_blog_prefix() — no switch. |
| 99 | + $table = $wpdb->get_blog_prefix( $blog_id ) . 'options'; |
| 100 | + $row = $wpdb->get_row( |
| 101 | + $wpdb->prepare( |
| 102 | + "SELECT option_value FROM `{$table}` WHERE option_name = %s LIMIT 1", |
| 103 | + $option |
| 104 | + ) |
| 105 | + ); |
| 106 | + |
| 107 | + if ( null === $row ) { |
| 108 | + $notoptions = is_array( $notoptions ) ? $notoptions : []; |
| 109 | + $notoptions[ $option ] = true; |
| 110 | + wp_cache_set( $blog_id, $notoptions, 'blog-notoptions' ); |
| 111 | + return $default; |
| 112 | + } |
| 113 | + |
| 114 | + $value = maybe_unserialize( $row->option_value ); |
| 115 | + |
| 116 | + return apply_filters( "blog_option_{$option}", $value, $blog_id ); |
| 117 | +} |
| 118 | +}}} |
| 119 | + |
| 120 | +=== 2. Refactor `get_blog_option()` === |
| 121 | + |
| 122 | +Replace the `switch_to_blog()` / `get_option()` / `restore_current_blog()` sequence with a single call to the new helper. |
| 123 | + |
| 124 | +{{{#!php |
| 125 | +// BEFORE (ms-blogs.php): |
| 126 | +function get_blog_option( $id, $option, $default_value = false ) { |
| 127 | + $id = (int) $id; |
| 128 | + if ( empty( $id ) ) { $id = get_current_blog_id(); } |
| 129 | + |
| 130 | + if ( get_current_blog_id() === $id ) { |
| 131 | + return get_option( $option, $default_value ); |
| 132 | + } |
| 133 | + |
| 134 | + switch_to_blog( $id ); |
| 135 | + $value = get_option( $option, $default_value ); |
| 136 | + restore_current_blog(); |
| 137 | + |
| 138 | + return apply_filters( "blog_option_{$option}", $value, $id ); |
| 139 | +} |
| 140 | + |
| 141 | +// AFTER: |
| 142 | +function get_blog_option( $id, $option, $default_value = false ) { |
| 143 | + $id = (int) $id; |
| 144 | + if ( empty( $id ) ) { $id = get_current_blog_id(); } |
| 145 | + |
| 146 | + return _get_option_from_blog( $id, $option, $default_value ); |
| 147 | +} |
| 148 | +}}} |
| 149 | + |
| 150 | +=== 3. Refactor `update_blog_option()` === |
| 151 | + |
| 152 | +Replace `switch_to_blog()` + `update_option()` + `restore_current_blog()` with a direct `$wpdb->update/insert` against `{prefix}options`. |
| 153 | + |
| 154 | +{{{#!php |
| 155 | +// AFTER: |
| 156 | +function update_blog_option( $id, $option, $value, $deprecated = null ) { |
| 157 | + global $wpdb; |
| 158 | + $id = (int) $id; |
| 159 | + |
| 160 | + if ( null !== $deprecated ) { |
| 161 | + _deprecated_argument( __FUNCTION__, '3.1.0' ); |
| 162 | + } |
| 163 | + |
| 164 | + if ( get_current_blog_id() === $id ) { |
| 165 | + return update_option( $option, $value ); |
| 166 | + } |
| 167 | + |
| 168 | + $table = $wpdb->get_blog_prefix( $id ) . 'options'; |
| 169 | + $serialized = maybe_serialize( $value ); |
| 170 | + $exists = $wpdb->get_var( |
| 171 | + $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE option_name = %s", $option ) |
| 172 | + ); |
| 173 | + |
| 174 | + if ( $exists ) { |
| 175 | + $result = $wpdb->update( |
| 176 | + $table, |
| 177 | + [ 'option_value' => $serialized ], |
| 178 | + [ 'option_name' => $option ], |
| 179 | + [ '%s' ], |
| 180 | + [ '%s' ] |
| 181 | + ); |
| 182 | + } else { |
| 183 | + $result = $wpdb->insert( |
| 184 | + $table, |
| 185 | + [ 'option_name' => $option, 'option_value' => $serialized, 'autoload' => 'yes' ], |
| 186 | + [ '%s', '%s', '%s' ] |
| 187 | + ); |
| 188 | + } |
| 189 | + |
| 190 | + // Bust per-blog option caches. |
| 191 | + wp_cache_delete( $id, 'blog-alloptions' ); |
| 192 | + wp_cache_delete( $id, 'blog-notoptions' ); |
| 193 | + |
| 194 | + return false !== $result; |
| 195 | +} |
| 196 | +}}} |
| 197 | + |
| 198 | +Apply the same pattern to `add_blog_option()` and `delete_blog_option()`. |
| 199 | + |
| 200 | +=== 4. Refactor `WP_Site::get_details()` === |
| 201 | + |
| 202 | +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()`: |
| 203 | + |
| 204 | +{{{#!php |
| 205 | +// AFTER: |
| 206 | +private function get_details() { |
| 207 | + $details = wp_cache_get( $this->blog_id, 'site-details' ); |
| 208 | + |
| 209 | + if ( false === $details ) { |
| 210 | + $id = (int) $this->blog_id; |
| 211 | + $details = new stdClass(); |
| 212 | + foreach ( get_object_vars( $this ) as $key => $value ) { |
| 213 | + $details->$key = $value; |
| 214 | + } |
| 215 | + $details->blogname = _get_option_from_blog( $id, 'blogname' ); |
| 216 | + $details->siteurl = _get_option_from_blog( $id, 'siteurl' ); |
| 217 | + $details->post_count = _get_option_from_blog( $id, 'post_count', 0 ); |
| 218 | + $details->home = _get_option_from_blog( $id, 'home' ); |
| 219 | + |
| 220 | + wp_cache_set( $this->blog_id, $details, 'site-details' ); |
| 221 | + } |
| 222 | + |
| 223 | + $details = apply_filters_deprecated( 'blog_details', [ $details ], '4.7.0', 'site_details' ); |
| 224 | + $details = apply_filters( 'site_details', $details ); |
| 225 | + |
| 226 | + return $details; |
| 227 | +} |
| 228 | +}}} |
| 229 | + |
| 230 | +=== 5. Refactor `get_blog_post()` === |
| 231 | + |
| 232 | +In `ms-functions.php`, replace `switch_to_blog()` + `get_post()` + `restore_current_blog()` with a direct query against `{prefix}posts`: |
| 233 | + |
| 234 | +{{{#!php |
| 235 | +// AFTER: |
| 236 | +function get_blog_post( $blog_id, $post_id ) { |
| 237 | + global $wpdb; |
| 238 | + |
| 239 | + $blog_id = (int) $blog_id; |
| 240 | + $post_id = (int) $post_id; |
| 241 | + |
| 242 | + if ( get_current_blog_id() === $blog_id ) { |
| 243 | + return get_post( $post_id ); |
| 244 | + } |
| 245 | + |
| 246 | + $table = $wpdb->get_blog_prefix( $blog_id ) . 'posts'; |
| 247 | + $post = $wpdb->get_row( |
| 248 | + $wpdb->prepare( "SELECT * FROM `{$table}` WHERE ID = %d LIMIT 1", $post_id ) |
| 249 | + ); |
| 250 | + |
| 251 | + if ( ! $post ) { |
| 252 | + return null; |
| 253 | + } |
| 254 | + |
| 255 | + return sanitize_post( new WP_Post( $post ), 'raw' ); |
| 256 | +} |
| 257 | +}}} |
| 258 | + |
| 259 | +---- |
| 260 | + |
| 261 | +== Object-Cache Considerations == |
| 262 | + |
| 263 | +The new `_get_option_from_blog()` helper must: |
| 264 | + |
| 265 | + * Use the same cache group strategy as `get_option()` — `blog-alloptions` for autoloaded options, `blog-notoptions` for known-missing options. |
| 266 | + * Bust caches on write — `add_blog_option`, `update_blog_option`, `delete_blog_option` must invalidate the same keys. |
| 267 | + * 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()`. |
| 268 | + |
| 269 | +---- |
| 270 | + |
| 271 | +== Out of Scope == |
| 272 | + |
| 273 | +These functions have deeper coupling to the switched context and are out of scope for an initial patch: |
| 274 | + |
| 275 | +||= Function =||= Reason =|| |
| 276 | +|| `wp_initialize_site()` || Runs dozens of core operations on a new site's tables || |
| 277 | +|| `wp_uninitialize_site()` || Drops tables, clears all site data || |
| 278 | +|| Third-party plugin code || Cannot be fixed in core || |
| 279 | + |
| 280 | +---- |
| 281 | + |
| 282 | +== Files Changed == |
| 283 | + |
| 284 | +||= File =||= Change =|| |
| 285 | +|| `wp-includes/ms-blogs.php` || Add `_get_option_from_blog()` private helper || |
| 286 | +|| `wp-includes/ms-blogs.php` || Refactor `get_blog_option()` to use helper || |
| 287 | +|| `wp-includes/ms-blogs.php` || Refactor `update_blog_option()` — direct `$wpdb` query || |
| 288 | +|| `wp-includes/ms-blogs.php` || Refactor `add_blog_option()` — direct `$wpdb->insert` || |
| 289 | +|| `wp-includes/ms-blogs.php` || Refactor `delete_blog_option()` — direct `$wpdb->delete` || |
| 290 | +|| `wp-includes/class-wp-site.php` || Refactor `WP_Site::get_details()` to use `_get_option_from_blog()` || |
| 291 | +|| `wp-includes/ms-functions.php` || Refactor `get_blog_post()` — direct `$wpdb` query || |
| 292 | + |
| 293 | +All changes exploit `$wpdb->get_blog_prefix( $blog_id )`, which is already public, purpose-built, and side-effect-free. |
| 294 | + |
| 295 | +---- |
| 296 | + |
| 297 | +== Testing Notes == |
| 298 | + |
| 299 | + * Unit tests for `get_blog_option()`, `update_blog_option()`, `add_blog_option()`, `delete_blog_option()` — verify return values match current behaviour. |
| 300 | + * Unit test for `WP_Site::__get('blogname')` / `WP_Site::__get('siteurl')` — verify values match after refactor. |
| 301 | + * Verify object cache is populated and busted correctly (test with both fallback and persistent cache drop-ins). |
| 302 | + * Verify `$GLOBALS['switched']` is **not** set to `true` after `get_blog_option()` (regression test for the switch removal). |
| 303 | + * Performance benchmark: loop over N sites calling `get_blog_option( $id, 'blogname' )` — measure wall time before/after. |
| 304 | + |
| 305 | +---- |
| 306 | + |
| 307 | +Component: Multisite[[BR]] |
| 308 | +Focuses: performance[[BR]] |
| 309 | +Type: enhancement[[BR]] |
| 310 | +Version: trunk[[BR]] |
| 311 | +Keywords: has-patch needs-testing |
0 commit comments