PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
← All changes | includes/object-cache.php +708 -18 1.2.01.3.2 View file →
@@ -35,30 +35,429 @@
35 35 /**
36 36 * Resolve a single config value from constants with sane defaults.
37 37 */
38 38 function xspeed_oc_config( $key, $default ) {
39 + // Keep this map in lockstep with the `constants` declared in
40 + // ObjectCacheModule::settings_schema(). The drop-in loads before
41 + // WordPress, so it cannot call Settings_Manager and the list genuinely
42 + // exists twice; ObjectCacheConstantParityTest asserts the two agree
43 + // field-for-field, because one rule in two files is how the last
44 + // ownership bug survived three review rounds. (#398)
45 + // The backend decides which host/port field to read, so it has to be
46 + // resolved the same way everything else is: constant first, then the
47 + // sidecar. Reading only the constant made a sidecar-configured
48 + // Memcached site read Redis's host and port.
49 + if ( defined( 'XSPEED_OC_BACKEND' ) ) {
50 + $backend = (string) constant( 'XSPEED_OC_BACKEND' );
51 + } else {
52 + $sc = xspeed_oc_sidecar();
53 + $backend = isset( $sc['backend'] ) ? (string) $sc['backend'] : '';
54 + }
55 + $is_memcached = ( 'memcached' === $backend );
56 +
39 57 $map = array(
40 58 'backend' => array( 'XSPEED_OC_BACKEND' ),
41 - 'host' => array( 'XSPEED_OC_HOST', 'WP_REDIS_HOST' ),
42 - 'port' => array( 'XSPEED_OC_PORT', 'WP_REDIS_PORT' ),
43 - 'user' => array( 'XSPEED_OC_USER', 'WP_REDIS_USER' ),
59 + // WP_REDIS_* are Redis's own conventions, so they only answer when
60 + // Redis is the backend. Consulting them on Memcached made a
61 + // Memcached site connect to the Redis host and port -- the same
62 + // cross-backend bleed that one shared XSPEED_OC_HOST/PORT pair
63 + // caused in the panel. (#398)
64 + // XSPEED_OC_HOST/PORT trail the Memcached names for BACKWARD
65 + // COMPATIBILITY: installs configured before the split have the
66 + // generic pair in their block, and dropping it would move them to
67 + // 127.0.0.1 on upgrade -- breaking a working cache. New writes use
68 + // XSPEED_OC_MC_*, so the fallback fades out on the next save.
69 + 'host' => $is_memcached
70 + ? array( 'XSPEED_OC_MC_HOST', 'XSPEED_OC_HOST' )
71 + : array( 'XSPEED_OC_HOST', 'WP_REDIS_HOST' ),
72 + 'port' => $is_memcached
73 + ? array( 'XSPEED_OC_MC_PORT', 'XSPEED_OC_PORT' )
74 + : array( 'XSPEED_OC_PORT', 'WP_REDIS_PORT' ),
75 + // WP_REDIS_PASSWORD trails the user names on purpose: in its array
76 + // form it carries the ACL username too, so a site that defines only
77 + // the password pair still authenticates as the right user.
78 + 'user' => array( 'XSPEED_OC_USER', 'WP_REDIS_USER', 'WP_REDIS_PASSWORD' ),
44 79 'password' => array( 'XSPEED_OC_PASSWORD', 'WP_REDIS_PASSWORD' ),
45 80 'database' => array( 'XSPEED_OC_DATABASE', 'WP_REDIS_DATABASE' ),
46 81 'timeout' => array( 'XSPEED_OC_TIMEOUT', 'WP_REDIS_TIMEOUT' ),
47 - 'salt' => array( 'XSPEED_OC_SALT', 'WP_CACHE_KEY_SALT' ),
82 + 'salt' => array( 'XSPEED_OC_SALT', 'WP_REDIS_PREFIX', 'WP_CACHE_KEY_SALT' ),
48 83 'persist' => array( 'XSPEED_OC_PERSISTENT', 'WP_REDIS_PERSISTENT' ),
49 84 );
85 + /*
86 + * This drop-in's short keys mapped to the schema field names the
87 + * sidecar stores. Redis and Memcached each name their own host/port
88 + * field, so switching backend cannot make one read the other's value.
89 + */
90 + $sidecar_map = array(
91 + 'backend' => 'backend',
92 + 'host' => $is_memcached ? 'memcached_host' : 'redis_host',
93 + 'port' => $is_memcached ? 'memcached_port' : 'redis_port',
94 + 'user' => 'redis_user',
95 + 'password' => 'redis_password',
96 + 'database' => 'redis_database',
97 + 'timeout' => 'connection_timeout',
98 + 'salt' => 'key_prefix',
99 + 'persist' => 'persistent',
100 + );
101 +
102 + /*
103 + * $memcached_servers is Memcached's convention the way WP_REDIS_* is
104 + * Redis's -- W3TC and the Memcached Object Cache drop-in both read it,
105 + * and hosts write it. Settings_Manager resolves it for the panel, so
106 + * without it here the panel and Test connection reported the host's
107 + * server while the drop-in quietly used 127.0.0.1 and cached nothing:
108 + * the two-truths split this whole change exists to close, on the one
109 + * backend where the convention IS a global. Ranked below our own
110 + * constants, matching the schema's constants-then-global order. (#398)
111 + */
112 + if ( $is_memcached && ( 'host' === $key || 'port' === $key ) ) {
113 + $ours = 'host' === $key
114 + ? array( 'XSPEED_OC_MC_HOST', 'XSPEED_OC_HOST' )
115 + : array( 'XSPEED_OC_MC_PORT', 'XSPEED_OC_PORT' );
116 + $pinned = false;
117 + foreach ( $ours as $const ) {
118 + if ( defined( $const ) ) {
119 + $pinned = true;
120 + break;
121 + }
122 + }
123 + if ( ! $pinned && isset( $GLOBALS['memcached_servers'] ) ) {
124 + $pair = xspeed_oc_first_memcached_server( $GLOBALS['memcached_servers'] );
125 + $slot = 'host' === $key ? 0 : 1;
126 + if ( null !== $pair && null !== $pair[ $slot ] ) {
127 + return $pair[ $slot ];
128 + }
129 + }
130 + }
131 +
50 132 if ( isset( $map[ $key ] ) ) {
133 + /*
134 + * A host's define outranks one we wrote, whatever order this map
135 + * lists them in -- the same rule Settings_Manager applies for the
136 + * panel. Ours only mirrors the option row, so preferring it meant a
137 + * rotated host credential was ignored until someone saved. Two
138 + * passes rather than a reorder, because the map's order is still
139 + * right for every other case (ours before the convention). (#398)
140 + */
141 + /*
142 + * Only a name that is not ours to begin with can be promoted. Our
143 + * OWN legacy aliases must never be: XSPEED_OC_HOST/PORT trail the
144 + * Memcached names for backward compatibility and, on a site that
145 + * ran Redis first, hold the REDIS host. Promoting one because it
146 + * happened to sit outside the current fence pointed a Memcached
147 + * site at the Redis server -- while the panel, which gates those
148 + * aliases on the backend (`constants_when`), still showed the right
149 + * value. That is the panel/runtime split this change exists to
150 + * close, reopened from the other side.
151 + *
152 + * WP_CACHE_KEY_SALT is NEVER promoted (#430). It is not a host's
153 + * namespace declaration the way WP_REDIS_* is -- it is WordPress's
154 + * OWN cache-uniqueness salt, present on almost every install and
155 + * usually a random value. On xCloud the provisioner writes the
156 + * correct namespace as XSPEED_OC_SALT AND WordPress carries its
157 + * own random WP_CACHE_KEY_SALT beside it; promoting the latter over
158 + * ours pointed every write outside the ACL namespace (NOPERM) while
159 + * released code -- which had no promotion pass -- worked. So for the
160 + * salt, our own define always wins over WP_CACHE_KEY_SALT; a genuine
161 + * WP_REDIS_PREFIX still ranks by position like any other convention.
162 + */
163 + $ours = xspeed_oc_our_constants();
164 + $sorted = array();
51 165 foreach ( $map[ $key ] as $const ) {
52 - if ( defined( $const ) ) {
53 - return constant( $const );
166 + if ( 0 === strpos( $const, 'XSPEED_OC_' ) ) {
167 + continue;
54 168 }
169 + if ( 'WP_CACHE_KEY_SALT' === $const ) {
170 + continue;
171 + }
172 + if ( defined( $const ) && ! in_array( $const, $ours, true ) ) {
173 + $sorted[] = $const;
174 + }
55 175 }
176 + foreach ( $map[ $key ] as $const ) {
177 + if ( ! in_array( $const, $sorted, true ) ) {
178 + $sorted[] = $const;
179 + }
180 + }
181 +
182 + foreach ( $sorted as $const ) {
183 + if ( ! defined( $const ) ) {
184 + continue;
185 + }
186 + $value = constant( $const );
187 + // WP_REDIS_PASSWORD only answers for `user` in its array form,
188 + // which carries the ACL username. As a plain string it is just
189 + // a password: skip it, or we would authenticate with the
190 + // password as the username.
191 + if ( 'user' === $key && 'WP_REDIS_PASSWORD' === $const && ! is_array( $value ) ) {
192 + continue;
193 + }
194 + return xspeed_oc_credential_part( $key, $value );
195 + }
56 196 }
197 +
198 + /*
199 + * No constant answered. On a host where wp-config.php is not writable
200 + * the panel stores the configuration in a sidecar beside this drop-in
201 + * instead, so consult it before falling back to the built-in default --
202 + * otherwise every setting the user saved there would be ignored at
203 + * runtime while the panel showed it as active. (#398)
204 + */
205 + $sidecar = xspeed_oc_sidecar();
206 + $field = isset( $sidecar_map[ $key ] ) ? $sidecar_map[ $key ] : null;
207 + if ( null !== $field && array_key_exists( $field, $sidecar ) ) {
208 + return xspeed_oc_credential_part( $key, $sidecar[ $field ] );
209 + }
210 +
57 211 return $default;
58 212 }
59 213 }
60 214
215 +if ( ! function_exists( 'xspeed_oc_first_memcached_server' ) ) {
216 + /**
217 + * Host and port of the first server in a `$memcached_servers` global.
218 + *
219 + * Two shapes are in circulation and hosts write both:
220 + *
221 + * array( array( 'host', 11211 ) ) // W3TC pair form
222 + * array( 'default' => array( 'host:11211' ) ) // Memcached Object Cache
223 + *
224 + * Supporting only the first left the second reading the whole "host:port"
225 + * string as the hostname -- or missing entirely, since its bucket is keyed
226 + * `default` rather than 0. Kept in one function because Settings_Manager
227 + * has to answer identically or the panel and the runtime disagree, which is
228 + * the bug this whole change closes. (#398)
229 + *
230 + * @param mixed $servers The global's value, unvalidated.
231 + * @return array{0:?string,1:?int}|null Host and port, either possibly null.
232 + */
233 + function xspeed_oc_first_memcached_server( $servers ) {
234 + if ( ! is_array( $servers ) || array() === $servers ) {
235 + return null;
236 + }
237 +
238 + // Either the 0th bucket or, for the keyed form, whichever comes first.
239 + $bucket = array_key_exists( 0, $servers ) ? $servers[0] : reset( $servers );
240 +
241 + /*
242 + * A bucket is EITHER a [host, port] pair or a list of server entries.
243 + * Telling them apart by shape, not by nesting depth: descending into
244 + * array( 'mc.example', 11211 ) yields the host string and drops the
245 + * port on the floor, which is the commonest form there is.
246 + */
247 + $entry = $bucket;
248 + if ( is_array( $bucket ) && isset( $bucket[0] ) && is_array( $bucket[0] ) ) {
249 + $entry = $bucket[0];
250 + }
251 +
252 + // Pair form: [ host, port ].
253 + if ( is_array( $entry ) ) {
254 + $host = isset( $entry[0] ) && ! is_array( $entry[0] ) ? (string) $entry[0] : null;
255 + $port = isset( $entry[1] ) && ! is_array( $entry[1] ) ? (int) $entry[1] : null;
256 + // A single-element list, array( 'host:port' ), is the keyed form's
257 + // bucket rather than a pair -- fall through to the string parser.
258 + if ( null !== $host && null === $port && is_string( $entry[0] ) && false !== strpos( $entry[0], ':' ) ) {
259 + $entry = $entry[0];
260 + } else {
261 + return ( null === $host && null === $port ) ? null : array( $host, $port );
262 + }
263 + }
264 +
265 + if ( ! is_string( $entry ) || '' === $entry ) {
266 + return null;
267 + }
268 +
269 + // "host:port", or a bare host. A unix socket path has no port and can
270 + // contain no colon we should split on, so only split the LAST one and
271 + // only when what follows is numeric.
272 + $at = strrpos( $entry, ':' );
273 + if ( false !== $at && ctype_digit( substr( $entry, $at + 1 ) ) ) {
274 + return array( substr( $entry, 0, $at ), (int) substr( $entry, $at + 1 ) );
275 + }
276 + return array( $entry, null );
277 + }
278 +}
279 +
280 +if ( ! function_exists( 'xspeed_oc_our_constants' ) ) {
281 + /**
282 + * Constant names defined inside OUR fenced block in wp-config.php.
283 + *
284 + * Ownership is decided by WHERE a define sits, exactly as
285 + * Object_Cache::our_constants() decides it for the admin half. The drop-in
286 + * needs the same answer for the same reason the panel does: a define we
287 + * wrote is only a mirror of the option row, so a HOST define has to outrank
288 + * it. Without this the drop-in kept connecting to our stale snapshot after
289 + * a host rotated its credentials, while the panel -- which does apply the
290 + * rule -- showed the new one. Panel and runtime disagreeing is the whole
291 + * bug this change exists to remove. (#398)
292 + *
293 + * @return string[]
294 + */
295 + function xspeed_oc_our_constants() {
296 + static $names = null;
297 + if ( null !== $names ) {
298 + return $names;
299 + }
300 + $names = array();
301 +
302 + // ABSPATH is defined by wp-load.php before the drop-in is included.
303 + $path = defined( 'ABSPATH' ) ? ABSPATH . 'wp-config.php' : '';
304 + if ( '' === $path || ! is_readable( $path ) ) {
305 + // One level up is the standard "wp-config outside the root" layout.
306 + $alt = defined( 'ABSPATH' ) ? dirname( ABSPATH ) . '/wp-config.php' : '';
307 + $path = ( '' !== $alt && is_readable( $alt ) ) ? $alt : '';
308 + }
309 + if ( '' === $path ) {
310 + return $names;
311 + }
312 +
313 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- runs before WordPress; WP_Filesystem does not exist yet.
314 + $config = (string) @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable wp-config just means "we own nothing".
315 + if ( '' === $config ) {
316 + return $names;
317 + }
318 + if ( ! preg_match( '/\/\* BEGIN xSpeed Object Cache \*\/(.*?)\/\* END xSpeed Object Cache \*\//s', $config, $m ) ) {
319 + return $names;
320 + }
321 + if ( preg_match_all( "/define\(\s*'([A-Z0-9_]+)'/", $m[1], $found ) ) {
322 + $names = $found[1];
323 + }
324 + return $names;
325 + }
326 +}
327 +
328 +if ( ! function_exists( 'xspeed_oc_sidecar' ) ) {
329 + /**
330 + * Configuration written beside this drop-in when wp-config.php is
331 + * read-only. Returns an empty array when there is none.
332 + *
333 + * This file IS wp-content/object-cache.php, so the sidecar sits in the
334 + * same directory -- no constant needed to locate it, which matters because
335 + * WP_CONTENT_DIR is not guaranteed to be defined this early.
336 + *
337 + * @return array<string,mixed>
338 + */
339 + function xspeed_oc_sidecar() {
340 + static $data = null;
341 + if ( null !== $data ) {
342 + return $data;
343 + }
344 + $data = array();
345 + $path = __DIR__ . '/xspeed-object-cache.php';
346 + if ( ! is_readable( $path ) ) {
347 + return $data;
348 + }
349 +
350 + /*
351 + * This runs before WordPress, so a parse error here is a white screen
352 + * on every request rather than a degraded cache. The writer renames
353 + * into place atomically, but a file truncated by something else -- a
354 + * failed deploy, a partial restore -- must not take the site down, so
355 + * the include is guarded and any failure degrades to "no sidecar".
356 + */
357 + try {
358 + $loaded = @include $path; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a broken sidecar must not white-screen the site.
359 + if ( is_array( $loaded ) ) {
360 + $data = $loaded;
361 + }
362 + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- degrade to no sidecar.
363 + $data = array();
364 + }
365 + return $data;
366 + }
367 +}
368 +
369 +if ( ! function_exists( 'xspeed_oc_salt' ) ) {
370 + /**
371 + * Resolve the salt that namespaces this site's keys.
372 + *
373 + * Normally the salt constant is written into wp-config.php on enable. When
374 + * wp-config is NOT writable (some managed hosts) the drop-in still gets
375 + * installed, so without a fallback every key would come out as
376 + * `:{blog}:{group}:{key}` — identical on every install. Two sites sharing a
377 + * Redis/Memcached server would then read each other's blog-details /
378 + * blog-lookup entries and the second site would redirect to the first.
379 + *
380 + * Derived the same way Object_Cache::derive_salt() does, so a site keeps
381 + * the same namespace whether the constant is present or not.
382 + *
383 + * @return string Non-empty salt.
384 + */
385 + function xspeed_oc_salt() {
386 + $salt = (string) xspeed_oc_config( 'salt', '' );
387 + if ( '' !== $salt ) {
388 + return $salt;
389 + }
390 +
391 + global $table_prefix;
392 +
393 + $url = '';
394 + if ( defined( 'WP_HOME' ) ) {
395 + $url = (string) WP_HOME;
396 + } elseif ( defined( 'WP_SITEURL' ) ) {
397 + $url = (string) WP_SITEURL;
398 + }
399 +
400 + // WP_HOME / WP_SITEURL are OPTIONAL and absent from a stock
401 + // wp-config.php, so the URL is usually empty here — DB_NAME alone
402 + // would then be identical for two sites sharing one database and the
403 + // collision this salt exists to prevent would come straight back.
404 + // $table_prefix separates them: it is assigned in wp-config.php itself,
405 + // so it is already a global by the time the drop-in loads (well before
406 + // $wpdb exists). ABSPATH is added whenever no URL was available, since
407 + // two installs on one database necessarily live in different
408 + // directories.
409 + $parts = array(
410 + $url,
411 + defined( 'DB_NAME' ) ? (string) DB_NAME : '',
412 + isset( $table_prefix ) ? (string) $table_prefix : '',
413 + );
414 + if ( '' === $url ) {
415 + $parts[] = defined( 'ABSPATH' ) ? (string) ABSPATH : '';
416 + }
417 +
418 + $seed = implode( '|', $parts );
419 + if ( '' === trim( $seed, '|' ) ) {
420 + $seed = 'xspeed';
421 + }
422 +
423 + return 'xs' . substr( md5( $seed ), 0, 12 );
424 + }
425 +}
426 +
427 +if ( ! function_exists( 'xspeed_oc_credential_part' ) ) {
428 + /**
429 + * Unpack the array form of a Redis credential constant.
430 + *
431 + * Managed hosts that provision Redis ACL users (xCloud, Cloudways) ship
432 + * the pair in one define:
433 + *
434 + * define( 'WP_REDIS_PASSWORD', array( 'acl_user', 's3cret' ) );
435 + *
436 + * Casting that to string yields "Array" and a notice, so the site would
437 + * authenticate with garbage. Split it here, at the single point both
438 + * `user` and `password` resolve through, rather than in each caller.
439 + *
440 + * @param string $key Config key being resolved.
441 + * @param mixed $value Raw constant value.
442 + * @return mixed
443 + */
444 + function xspeed_oc_credential_part( $key, $value ) {
445 + if ( ! is_array( $value ) || ( 'user' !== $key && 'password' !== $key ) ) {
446 + return $value;
447 + }
448 + // Scalars only. A nested value would stringify to "Array" and emit a
449 + // warning on EVERY request from the drop-in -- before headers are sent,
450 + // on the code path whose whole design rule is never to break the site.
451 + $parts = array_values( array_filter( $value, 'is_scalar' ) );
452 + if ( 'user' === $key ) {
453 + // A one-element array is a password with no ACL user.
454 + return count( $parts ) > 1 ? (string) $parts[0] : '';
455 + }
456 + return (string) ( count( $parts ) > 1 ? $parts[1] : ( $parts[0] ?? '' ) );
457 + }
458 +}
459 +
61 460 // -----------------------------------------------------------------------------
62 461 // WordPress object-cache API surface. Thin wrappers over the global instance.
63 462 // -----------------------------------------------------------------------------
64 463 if ( ! function_exists( 'wp_cache_init' ) ) {
@@ -193,8 +592,26 @@
193 592
194 593 /** @var string Key salt / prefix. */
195 594 private $salt = '';
196 595
596 + /** Option holding the Memcached generation floor (see generation_floor()). */
597 + const GENERATION_OPTION = 'xspeed_oc_generation';
598 +
599 + /**
600 + * @var int|null This site's namespace generation, resolved lazily once
601 + * per request. Advancing it is how Memcached flushes only this site's
602 + * keys — the daemon offers no way to enumerate or scope a real flush.
603 + * Null until first read; 1 means the original, unsuffixed key shape.
604 + */
605 + private $generation = null;
606 +
607 + /**
608 + * @var int|null Lowest generation this site may use, mirrored in the
609 + * database so an LRU eviction of the cached counter cannot rewind the
610 + * namespace. Null until first read.
611 + */
612 + private $generation_floor = null;
613 +
197 614 /** @var int Current blog id (multisite prefixing). */
198 615 private $blog_prefix = 0;
199 616
200 617 /** @var bool */
@@ -214,9 +631,9 @@
214 631
215 632 public function __construct() {
216 633 $this->multisite = function_exists( 'is_multisite' ) && is_multisite();
217 634 $this->blog_prefix = $this->multisite ? (int) get_current_blog_id() : 0;
218 - $this->salt = (string) xspeed_oc_config( 'salt', '' );
635 + $this->salt = xspeed_oc_salt();
219 636 $this->backend = strtolower( (string) xspeed_oc_config( 'backend', 'redis' ) );
220 637
221 638 // Non-persistent groups. We deliberately DO persist `options`
222 639 // (incl. the autoloaded `alloptions` blob), `comment`, and
@@ -471,9 +888,14 @@
471 888
472 889 private function full_key( $key, $group ) {
473 890 $group = $this->group( $group );
474 891 $prefix = isset( $this->global_groups[ $group ] ) ? 0 : $this->blog_prefix;
475 - return $this->salt . ':' . $prefix . ':' . $group . ':' . $key;
892 + $gen = $this->generation();
893 + // Generation 1 keeps the historical key shape, so Redis (which
894 + // flushes by pattern and never advances the generation) is
895 + // byte-identical to before and existing entries stay readable.
896 + $ns = 1 === $gen ? $this->salt : $this->salt . '.g' . $gen;
897 + return $ns . ':' . $prefix . ':' . $group . ':' . $key;
476 898 }
477 899
478 900 private function is_persistent_group( $group ) {
479 901 return $this->persistent && ! isset( $this->non_persistent_groups[ $this->group( $group ) ] );
@@ -719,27 +1141,293 @@
719 1141
720 1142 private function backend_flush() {
721 1143 if ( 'redis' === $this->backend ) {
722 1144 // Scope the flush to THIS site's namespace (salt:*) instead of
723 - // FLUSHDB, which would wipe the entire Redis database — including
724 - // other sites / apps sharing the same DB index. Falls back to
725 - // FLUSHDB only when no salt is configured (single-tenant) so
726 - // behavior is unchanged on a dedicated Redis. (FBS-83119)
1145 + // FLUSHDB, which would wipe the entire Redis database —
1146 + // including other sites / apps sharing the same DB index.
1147 + // (FBS-83119)
1148 + //
1149 + // There is deliberately NO empty-salt fallback to FLUSHDB.
1150 + // xspeed_oc_salt() always returns a non-empty value, but a
1151 + // drop-in left over from an older version can still be the
1152 + // object loaded for the request that runs the upgrade
1153 + // migration — and that is exactly when a global flush would
1154 + // destroy a neighbouring site's cache. An unsalted pattern is
1155 + // scoped to nothing, so we bail rather than widen the blast
1156 + // radius.
727 1157 if ( '' === $this->salt ) {
728 - return $this->conn->flushDB();
1158 + return false;
729 1159 }
730 - return $this->delete_redis_pattern( $this->salt . ':*' ) >= 0;
1160 + return $this->delete_redis_pattern( $this->escape_glob( $this->salt ) . ':*' ) >= 0;
731 1161 }
732 - return 'builtin-memcached' === $this->client
733 - ? $this->conn->flush_all()
734 - : $this->conn->flush();
1162 +
1163 + // Memcached has no key enumeration, so flush_all() / flush() are
1164 + // unavoidably SERVER-WIDE — they wipe every other site and app on
1165 + // the same daemon. Bump this site's namespace generation instead:
1166 + // every key is built through it (see full_key()), so incrementing
1167 + // it orphans this site's entries and leaves everyone else's alone.
1168 + // The orphans expire on their own under Memcached's LRU.
1169 + return $this->bump_generation();
735 1170 }
736 1171
737 1172 /**
1173 + * Advance this site's namespace generation, invalidating every key
1174 + * built from it. Used as the Memcached flush primitive.
1175 + *
1176 + * @return bool
1177 + */
1178 + private function bump_generation() {
1179 + $key = $this->generation_key();
1180 + $new = null;
1181 +
1182 + try {
1183 + $new = 'builtin-memcached' === $this->client
1184 + ? $this->conn->incr( $key, 1 )
1185 + : $this->conn->increment( $key, 1 );
1186 + } catch ( \Throwable $e ) {
1187 + $new = false;
1188 + }
1189 +
1190 + // increment() fails when the counter does not exist — either it was
1191 + // never seeded, or the daemon evicted it under LRU. Either way,
1192 + // resuming from the CURRENT generation is what matters: seeding
1193 + // back to a low number could land on a generation this site used
1194 + // before and resurrect the keys this flush is meant to clear.
1195 + // Jumping forward from the generation we resolved for this request
1196 + // keeps the namespace monotonic across an eviction.
1197 + if ( false === $new || null === $new ) {
1198 + $next = $this->generation() + 1;
1199 + try {
1200 + // Store as a string: the bundled Memcached_Client types
1201 + // this parameter `string`, and Memcached stores scalars as
1202 + // strings regardless. (This file has no strict_types — it
1203 + // must load standalone — so an int would be coerced rather
1204 + // than rejected, but passing the right type keeps the two
1205 + // clients behaving identically.)
1206 + $this->conn->set( $key, (string) $next, 0 );
1207 + $new = $next;
1208 + } catch ( \Throwable $e ) {
1209 + return false;
1210 + }
1211 + }
1212 +
1213 + $this->generation = (int) $new;
1214 + $this->persist_generation_floor( $this->generation );
1215 + return true;
1216 + }
1217 +
1218 + /**
1219 + * The counter key holding this site's namespace generation. Salted, so
1220 + * each site owns its own counter on a shared daemon.
1221 + *
1222 + * @return string
1223 + */
1224 + private function generation_key() {
1225 + // Deliberately OUTSIDE the `{salt}:` namespace: Redis flushes by
1226 + // deleting everything matching `{salt}:*`, which would otherwise
1227 + // sweep away this invalidation marker if a site were reconfigured
1228 + // from memcached to redis and back.
1229 + return $this->salt . '.xspeed-oc-gen';
1230 + }
1231 +
1232 + /**
1233 + * The lowest generation this site may use, read from the database.
1234 + *
1235 + * Memcached can evict the counter at any time; the database cannot, so
1236 + * this is what stops an eviction from silently rewinding the namespace
1237 + * and resurrecting keys a Purge already cleared.
1238 + *
1239 + * Read straight through $wpdb rather than get_option(), because the
1240 + * options API routes through this very cache and would recurse. Returns
1241 + * 1 whenever the database is not available yet — the drop-in loads
1242 + * before $wpdb exists, and on those early requests nothing has been
1243 + * flushed anyway.
1244 + *
1245 + * @return int
1246 + */
1247 + /**
1248 + * The options table holding the generation floor, or '' when the
1249 + * database cannot be queried yet.
1250 + *
1251 + * Always the NETWORK-wide table. On multisite $wpdb->options points at
1252 + * the current blog's table, but the salt and the generation have no
1253 + * blog component — they namespace the whole install, global groups
1254 + * included. Storing the floor per blog would let a purge on blog 2 go
1255 + * unseen by blog 1, so an eviction there would rewind the namespace and
1256 + * resurrect the shared blog-details / blog-lookup entries that are the
1257 + * original bug.
1258 + *
1259 + * @param object $wpdb The database handle.
1260 + * @return string Table name, or '' when unusable.
1261 + */
1262 + private function generation_table( $wpdb ) {
1263 + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'get_var' ) || ! method_exists( $wpdb, 'prepare' ) ) {
1264 + return '';
1265 + }
1266 +
1267 + // During wp-admin/install.php and `wp core install` the drop-in is
1268 + // already live while the options table does not exist yet. A query
1269 + // then emits a database error that our try/catch cannot suppress,
1270 + // because get_var() reports rather than throws.
1271 + if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
1272 + return '';
1273 + }
1274 +
1275 + $base = isset( $wpdb->base_prefix ) ? (string) $wpdb->base_prefix : '';
1276 + if ( '' !== $base ) {
1277 + return $base . 'options';
1278 + }
1279 +
1280 + return isset( $wpdb->options ) ? (string) $wpdb->options : '';
1281 + }
1282 +
1283 + private function generation_floor() {
1284 + if ( null !== $this->generation_floor ) {
1285 + return $this->generation_floor;
1286 + }
1287 +
1288 + $this->generation_floor = 1;
1289 +
1290 + if ( 'memcached' !== $this->backend || ! isset( $GLOBALS['wpdb'] ) ) {
1291 + return $this->generation_floor;
1292 + }
1293 +
1294 + $wpdb = $GLOBALS['wpdb'];
1295 + $table = $this->generation_table( $wpdb );
1296 + if ( '' === $table ) {
1297 + return $this->generation_floor;
1298 + }
1299 +
1300 + try {
1301 + $val = $wpdb->get_var(
1302 + $wpdb->prepare(
1303 + "SELECT option_value FROM {$table} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is derived from $wpdb, never user input.
1304 + self::GENERATION_OPTION
1305 + )
1306 + );
1307 + if ( is_numeric( $val ) && (int) $val > 1 ) {
1308 + $this->generation_floor = (int) $val;
1309 + }
1310 + } catch ( \Throwable $e ) {
1311 + $this->generation_floor = 1;
1312 + }
1313 +
1314 + return $this->generation_floor;
1315 + }
1316 +
1317 + /**
1318 + * Persist the generation as the new floor, so an eviction of the cached
1319 + * counter cannot rewind past it.
1320 + *
1321 + * @param int $generation The generation just written.
1322 + * @return void
1323 + */
1324 + private function persist_generation_floor( $generation ) {
1325 + if ( 'memcached' !== $this->backend || ! isset( $GLOBALS['wpdb'] ) ) {
1326 + return;
1327 + }
1328 +
1329 + $wpdb = $GLOBALS['wpdb'];
1330 + $table = $this->generation_table( $wpdb );
1331 + if ( '' === $table || ! method_exists( $wpdb, 'query' ) ) {
1332 + return;
1333 + }
1334 +
1335 + try {
1336 + // Upsert without the options API, which would recurse through
1337 + // this cache. autoload='no' keeps it out of alloptions.
1338 + $wpdb->query(
1339 + $wpdb->prepare(
1340 + "INSERT INTO {$table} (option_name, option_value, autoload)
1341 + VALUES (%s, %s, 'no')
1342 + ON DUPLICATE KEY UPDATE option_value = VALUES(option_value)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is derived from $wpdb, never user input.
1343 + self::GENERATION_OPTION,
1344 + (string) (int) $generation
1345 + )
1346 + );
1347 + $this->generation_floor = (int) $generation;
1348 + } catch ( \Throwable $e ) {
1349 + // Best effort: the cached counter still carries the flush for
1350 + // as long as it survives.
1351 + return;
1352 + }
1353 + }
1354 +
1355 + /**
1356 + * Read this site's namespace generation, once per request.
1357 + *
1358 + * Only meaningful for Memcached, where flushing works by advancing the
1359 + * generation rather than deleting keys. Redis deletes by pattern, so it
1360 + * stays on generation 1 and its key shape is unchanged.
1361 + *
1362 + * @return int
1363 + */
1364 + private function generation() {
1365 + if ( null !== $this->generation ) {
1366 + return $this->generation;
1367 + }
1368 +
1369 + // The floor comes from the database, because the counter lives in
1370 + // the cache it namespaces and Memcached can evict it under LRU.
1371 + // Falling back to generation 1 after an eviction would re-expose
1372 + // the keys an earlier Purge cleared, so the DB copy — which cannot
1373 + // be evicted — is what the generation may never drop below.
1374 + $this->generation = $this->generation_floor();
1375 +
1376 + if ( 'memcached' === $this->backend && $this->persistent ) {
1377 + try {
1378 + $val = $this->conn->get( $this->generation_key() );
1379 + if ( is_numeric( $val ) && (int) $val > $this->generation ) {
1380 + $this->generation = (int) $val;
1381 + }
1382 + } catch ( \Throwable $e ) {
1383 + // Unreadable counter — the floor still applies, so a
1384 + // previous flush is never undone.
1385 + $this->generation = max( 1, $this->generation );
1386 + }
1387 + }
1388 +
1389 + return $this->generation;
1390 + }
1391 +
1392 + /**
1393 + * Escape Redis glob metacharacters so a literal string matches only
1394 + * itself inside a SCAN MATCH pattern.
1395 + *
1396 + * The salt and group name are interpolated into the flush patterns
1397 + * below, and neither is guaranteed to be glob-safe: an explicit Cache
1398 + * Key Prefix is whatever the user typed, and a host-pinned
1399 + * WP_CACHE_KEY_SALT is whatever the host wrote (WordPress builds these
1400 + * from the site URL, so punctuation is normal). Left unescaped, `*`,
1401 + * `?` and `[...]` are wildcards — `wp_[dev]site_:*` matches a
1402 + * NEIGHBOURING site's `wp_dsite_:*` keys, so purging one site deletes
1403 + * another site's cache. A bare `*` prefix matches everything and
1404 + * empties the whole Redis DB, which is the exact damage the scoped
1405 + * flush exists to prevent.
1406 + *
1407 + * Redis's stringmatchlen() treats `\` as the escape character, so
1408 + * backslash-prefixing each metacharacter makes it literal. `\` itself
1409 + * is escaped first, or escaping the others would be undone.
1410 + *
1411 + * @param string $literal Text to match literally.
1412 + * @return string Glob-safe form of $literal.
1413 + */
1414 + private function escape_glob( $literal ) {
1415 + return str_replace(
1416 + array( '\\', '*', '?', '[', ']' ),
1417 + array( '\\\\', '\\*', '\\?', '\\[', '\\]' ),
1418 + (string) $literal
1419 + );
1420 + }
1421 +
1422 + /**
738 1423 * Delete every Redis key matching $pattern across both client kinds
739 1424 * (phpredis native scan + our pure-PHP Redis_Client). Returns the
740 1425 * count deleted, or -1 if the backend isn't redis. SCAN-based so it
741 1426 * never blocks the server the way KEYS would. (FBS-83119)
1427 + *
1428 + * Callers MUST pass any literal segment through escape_glob() — this
1429 + * receives a finished pattern and cannot tell wildcard from data.
742 1430 */
743 1431 private function delete_redis_pattern( $pattern ) {
744 1432 if ( 'redis' !== $this->backend || ! $this->conn ) {
745 1433 return -1;
@@ -799,9 +1487,11 @@
799 1487 // both blog-prefixed and global groups for this site's namespace.
800 1488 // (FBS-83119)
801 1489 if ( $this->is_persistent_group( $group ) && 'redis' === $this->backend && '' !== $this->salt ) {
802 1490 try {
803 - $this->delete_redis_pattern( $this->salt . ':*:' . $group . ':*' );
1491 + $this->delete_redis_pattern(
1492 + $this->escape_glob( $this->salt ) . ':*:' . $this->escape_glob( $group ) . ':*'
1493 + );
804 1494 } catch ( \Throwable $e ) {
805 1495 return false;
806 1496 }
807 1497 }