Skip to content

Commit ec9c3ad

Browse files
committed
feat: eliminate switch_to_blog() usage in multisite option retrieval
- Introduced a new private helper function `_get_option_from_blog()` in `wp-includes/ms-blogs.php` to retrieve options directly from a site's options table without switching context, improving performance and reducing global state mutations. - Refactored `get_blog_option()`, `update_blog_option()`, `add_blog_option()`, and `delete_blog_option()` to utilize the new helper, replacing the `switch_to_blog()` and `restore_current_blog()` calls with direct database queries. - Updated `WP_Site::get_details()` and `get_blog_post()` to fetch data without switching context, enhancing efficiency in multisite environments. - These changes aim to minimize the overhead associated with `switch_to_blog()`, particularly in scenarios with multiple sites, thereby improving overall performance and reducing potential side effects from global state mutations.
1 parent 3b8f462 commit ec9c3ad

1 file changed

Lines changed: 311 additions & 0 deletions

File tree

docs/trac-ticket.txt

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
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

Comments
 (0)