PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.1 All 27 releases
xspeed / includes / object-cache.php

object-cache.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.0, at includes/object-cache.php

1,545 lines 56.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * XSPEED_OBJECT_CACHE_DROPIN
4 *
5 * xSpeed's self-contained persistent object cache drop-in.
6 *
7 * Supports Redis (phpredis extension, with a graceful no-op fall-through when
8 * absent) and Memcached. Implements the full WordPress object-cache API as a
9 * global WP_Object_Cache class + wp_cache_* functions.
10 *
11 * Design principles:
12 * - NEVER fatal the site. If the backend can't be reached, we degrade to a
13 * non-persistent in-request array cache. A misconfigured Redis must never
14 * take a site down — that's why connection_timeout defaults low.
15 * - Read config from constants written by xSpeed into wp-config.php
16 * (XSPEED_OC_*), falling back to the widely-used WP_REDIS_* conventions so
17 * existing setups keep working.
18 * - WP-compliant: groups, global groups, multisite blog-id prefixing,
19 * add/get/set/delete/incr/decr/replace, flush, get_multiple, add_multiple.
20 *
21 * This file is copied to wp-content/object-cache.php by xSpeed when the user
22 * clicks "Enable Object Cache". It is loaded by WordPress very early
23 * (wp-settings.php), before most of core — so it must be self-sufficient.
24 *
25 * @package XSpeed
26 */
27
28 defined( 'ABSPATH' ) || exit;
29
30 // -----------------------------------------------------------------------------
31 // Config resolution. Prefer xSpeed's own XSPEED_OC_* constants; fall back to the
32 // de-facto WP_REDIS_* / $memcached_servers conventions so we interoperate.
33 // -----------------------------------------------------------------------------
34 if ( ! function_exists( 'xspeed_oc_config' ) ) {
35 /**
36 * Resolve a single config value from constants with sane defaults.
37 */
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
57 $map = array(
58 'backend' => array( 'XSPEED_OC_BACKEND' ),
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' ),
79 'password' => array( 'XSPEED_OC_PASSWORD', 'WP_REDIS_PASSWORD' ),
80 'database' => array( 'XSPEED_OC_DATABASE', 'WP_REDIS_DATABASE' ),
81 'timeout' => array( 'XSPEED_OC_TIMEOUT', 'WP_REDIS_TIMEOUT' ),
82 'salt' => array( 'XSPEED_OC_SALT', 'WP_REDIS_PREFIX', 'WP_CACHE_KEY_SALT' ),
83 'persist' => array( 'XSPEED_OC_PERSISTENT', 'WP_REDIS_PERSISTENT' ),
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
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();
165 foreach ( $map[ $key ] as $const ) {
166 if ( 0 === strpos( $const, 'XSPEED_OC_' ) ) {
167 continue;
168 }
169 if ( 'WP_CACHE_KEY_SALT' === $const ) {
170 continue;
171 }
172 if ( defined( $const ) && ! in_array( $const, $ours, true ) ) {
173 $sorted[] = $const;
174 }
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 }
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
211 return $default;
212 }
213 }
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
460 // -----------------------------------------------------------------------------
461 // WordPress object-cache API surface. Thin wrappers over the global instance.
462 // -----------------------------------------------------------------------------
463 if ( ! function_exists( 'wp_cache_init' ) ) {
464
465 function wp_cache_init() {
466 $GLOBALS['wp_object_cache'] = new XSpeed_Object_Cache();
467 }
468
469 function wp_cache_add( $key, $data, $group = '', $expire = 0 ) {
470 return $GLOBALS['wp_object_cache']->add( $key, $data, $group, (int) $expire );
471 }
472
473 function wp_cache_add_multiple( array $data, $group = '', $expire = 0 ) {
474 $out = array();
475 foreach ( $data as $key => $value ) {
476 $out[ $key ] = wp_cache_add( $key, $value, $group, $expire );
477 }
478 return $out;
479 }
480
481 function wp_cache_replace( $key, $data, $group = '', $expire = 0 ) {
482 return $GLOBALS['wp_object_cache']->replace( $key, $data, $group, (int) $expire );
483 }
484
485 function wp_cache_set( $key, $data, $group = '', $expire = 0 ) {
486 return $GLOBALS['wp_object_cache']->set( $key, $data, $group, (int) $expire );
487 }
488
489 function wp_cache_set_multiple( array $data, $group = '', $expire = 0 ) {
490 $out = array();
491 foreach ( $data as $key => $value ) {
492 $out[ $key ] = wp_cache_set( $key, $value, $group, $expire );
493 }
494 return $out;
495 }
496
497 function wp_cache_get( $key, $group = '', $force = false, &$found = null ) {
498 return $GLOBALS['wp_object_cache']->get( $key, $group, $force, $found );
499 }
500
501 function wp_cache_get_multiple( $keys, $group = '', $force = false ) {
502 return $GLOBALS['wp_object_cache']->get_multiple( $keys, $group, $force );
503 }
504
505 function wp_cache_delete( $key, $group = '' ) {
506 return $GLOBALS['wp_object_cache']->delete( $key, $group );
507 }
508
509 function wp_cache_delete_multiple( array $keys, $group = '' ) {
510 $out = array();
511 foreach ( $keys as $key ) {
512 $out[ $key ] = wp_cache_delete( $key, $group );
513 }
514 return $out;
515 }
516
517 function wp_cache_incr( $key, $offset = 1, $group = '' ) {
518 return $GLOBALS['wp_object_cache']->incr( $key, (int) $offset, $group );
519 }
520
521 function wp_cache_decr( $key, $offset = 1, $group = '' ) {
522 return $GLOBALS['wp_object_cache']->decr( $key, (int) $offset, $group );
523 }
524
525 function wp_cache_flush() {
526 return $GLOBALS['wp_object_cache']->flush();
527 }
528
529 function wp_cache_flush_runtime() {
530 return $GLOBALS['wp_object_cache']->flush_runtime();
531 }
532
533 function wp_cache_flush_group( $group ) {
534 return $GLOBALS['wp_object_cache']->flush_group( $group );
535 }
536
537 function wp_cache_supports( $feature ) {
538 return in_array( $feature, array( 'get_multiple', 'set_multiple', 'add_multiple', 'delete_multiple', 'flush_runtime', 'flush_group' ), true );
539 }
540
541 function wp_cache_close() {
542 return $GLOBALS['wp_object_cache']->close();
543 }
544
545 function wp_cache_add_global_groups( $groups ) {
546 $GLOBALS['wp_object_cache']->add_global_groups( $groups );
547 }
548
549 function wp_cache_add_non_persistent_groups( $groups ) {
550 $GLOBALS['wp_object_cache']->add_non_persistent_groups( $groups );
551 }
552
553 function wp_cache_switch_to_blog( $blog_id ) {
554 $GLOBALS['wp_object_cache']->switch_to_blog( (int) $blog_id );
555 }
556
557 function wp_cache_reset() {
558 // Deprecated in core; kept for back-compat.
559 return $GLOBALS['wp_object_cache']->flush_runtime();
560 }
561 }
562
563 // -----------------------------------------------------------------------------
564 // The cache implementation.
565 // -----------------------------------------------------------------------------
566 if ( ! class_exists( 'XSpeed_Object_Cache' ) ) {
567
568 class XSpeed_Object_Cache {
569
570 /** @var array In-request cache (always populated; also the fallback store). */
571 private $cache = array();
572
573 /** @var \Redis|\Memcached|null Persistent backend handle, or null when degraded. */
574 private $conn = null;
575
576 /** @var string redis|memcached */
577 private $backend = 'redis';
578
579 /** @var string Concrete client driving a Redis backend: phpredis|builtin. */
580 private $client = 'phpredis';
581
582 /** @var bool True once a persistent backend is connected. */
583 private $persistent = false;
584
585 /**
586 * @var bool True when the drop-in is active but could NOT connect a
587 * persistent backend, so it's silently serving a non-persistent
588 * in-request cache. Surfaced so the dashboard can report "degraded"
589 * instead of implying object caching is healthy. (FBS-82210)
590 */
591 public $degraded = false;
592
593 /** @var string Key salt / prefix. */
594 private $salt = '';
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
614 /** @var int Current blog id (multisite prefixing). */
615 private $blog_prefix = 0;
616
617 /** @var bool */
618 private $multisite = false;
619
620 /** @var array<string,bool> Groups shared across the whole network. */
621 private $global_groups = array();
622
623 /** @var array<string,bool> Groups that must never hit the persistent store. */
624 private $non_persistent_groups = array();
625
626 /** @var int Cache hits this request. */
627 public $cache_hits = 0;
628
629 /** @var int Cache misses this request. */
630 public $cache_misses = 0;
631
632 public function __construct() {
633 $this->multisite = function_exists( 'is_multisite' ) && is_multisite();
634 $this->blog_prefix = $this->multisite ? (int) get_current_blog_id() : 0;
635 $this->salt = xspeed_oc_salt();
636 $this->backend = strtolower( (string) xspeed_oc_config( 'backend', 'redis' ) );
637
638 // Non-persistent groups. We deliberately DO persist `options`
639 // (incl. the autoloaded `alloptions` blob), `comment`, and
640 // `counts` — these are the highest-volume, highest-hit groups,
641 // and excluding them was why the persistent cache stored only a
642 // fraction of the keys a mature object cache (e.g. Redis Object
643 // Cache) does. Redis Object Cache persists all of them by
644 // default; matching that is the whole point of the feature.
645 //
646 // The historical "can't deactivate a plugin" bug (FBS-82210) was
647 // a stale `alloptions` being read back after a plugin write. That
648 // is NOT solved by refusing to persist options — a correct cache
649 // solves it by invalidating on write, which WordPress core already
650 // does: update_option()/add_option()/delete_option() each call
651 // wp_cache_delete( 'alloptions', 'options' ). Our delete()
652 // propagates to the backend for every persistent group (see
653 // delete()), so the stale blob is removed the moment WP writes an
654 // option — deactivation stays correct WITH options persisted.
655 //
656 // `plugins` and `themes` remain non-persistent: they're tiny,
657 // rebuilt cheaply per request, and never worth a round trip.
658 $this->add_non_persistent_groups(
659 array( 'plugins', 'themes' )
660 );
661
662 $this->connect();
663 }
664
665 // --- Connection -----------------------------------------------------
666
667 private function connect() {
668 $timeout = (float) xspeed_oc_config( 'timeout', 1 );
669 try {
670 if ( 'memcached' === $this->backend ) {
671 $host = (string) xspeed_oc_config( 'host', '127.0.0.1' );
672 $port = (int) xspeed_oc_config( 'port', 11211 );
673
674 if ( class_exists( 'Memcached' ) ) {
675 // ext/memcached (preferred).
676 $this->client = 'ext-memcached';
677 $mc = new Memcached();
678 $mc->addServer( $host, $port );
679 $mc->setOption( Memcached::OPT_CONNECT_TIMEOUT, (int) ( $timeout * 1000 ) );
680 $stats = @$mc->getStats();
681 if ( is_array( $stats ) && ! empty( $stats ) ) {
682 $this->conn = $mc;
683 $this->persistent = true;
684 }
685 } elseif ( $this->load_builtin_memcached() ) {
686 // xSpeed's own pure-PHP Memcached client.
687 $this->client = 'builtin-memcached';
688 $mc = new \XSpeed\Memcached_Client( $host, $port, $timeout );
689 if ( $mc->connect() && false !== $mc->version() ) {
690 $this->conn = $mc;
691 $this->persistent = true;
692 }
693 }
694 } else {
695 $this->backend = 'redis';
696 $host = (string) xspeed_oc_config( 'host', '127.0.0.1' );
697 $port = (int) xspeed_oc_config( 'port', 6379 );
698 $user = (string) xspeed_oc_config( 'user', '' );
699 $pass = (string) xspeed_oc_config( 'password', '' );
700 $db = (int) xspeed_oc_config( 'database', 0 );
701 $persist = (bool) xspeed_oc_config( 'persist', false );
702
703 if ( class_exists( 'Redis' ) ) {
704 // phpredis extension (preferred).
705 $this->client = 'phpredis';
706 $redis = new Redis();
707 $ok = $persist
708 ? @$redis->pconnect( $host, $port, $timeout )
709 : @$redis->connect( $host, $port, $timeout );
710 if ( $ok ) {
711 // Redis 6+ ACL: ['user'=>..,'pass'=>..] when a username
712 // is configured; legacy password-only otherwise.
713 if ( '' !== $user ) {
714 @$redis->auth( array( 'user' => $user, 'pass' => $pass ) );
715 } elseif ( '' !== $pass ) {
716 @$redis->auth( $pass );
717 }
718 if ( $db > 0 ) {
719 @$redis->select( $db );
720 }
721 if ( '+PONG' === @$redis->ping() || true === @$redis->ping() ) {
722 $this->conn = $redis;
723 $this->persistent = true;
724 }
725 }
726 } elseif ( $this->load_builtin_client() ) {
727 // xSpeed's own pure-PHP client (no extension, no library).
728 $this->client = 'builtin';
729 $rc = new \XSpeed\Redis_Client( $host, $port, (float) $timeout, $persist );
730 if ( $rc->connect() ) {
731 if ( '' !== $pass || '' !== $user ) {
732 $rc->auth( $pass, $user );
733 }
734 if ( $db > 0 ) {
735 $rc->select( $db );
736 }
737 $pong = $rc->ping();
738 if ( is_string( $pong ) && false !== stripos( $pong, 'PONG' ) ) {
739 $this->conn = $rc;
740 $this->persistent = true;
741 }
742 }
743 }
744 }
745 } catch ( \Throwable $e ) {
746 // Any failure → stay in non-persistent mode. Never fatal.
747 $this->conn = null;
748 $this->persistent = false;
749 }
750
751 // Connecting to an unreachable/unresolvable backend (e.g.
752 // `Redis::pconnect()` → "getaddrinfo for redis failed", or
753 // `stream_socket_client()` in our builtin clients) emits a PHP
754 // warning. We `@`-suppress those above and degrade gracefully to a
755 // non-persistent cache — but the warning still lingers in
756 // `error_get_last()`. WP reads that at `admin_body_class` time and
757 // tags every admin page `php-error`, which renders an empty banner
758 // above the admin menu even though nothing is actually broken.
759 //
760 // Clear it so a degraded-but-handled backend doesn't masquerade as
761 // a site error — but ONLY when the lingering error is OUR connect
762 // warning. We never blindly wipe the slot: matching on the
763 // originating file (this drop-in, or our bundled socket clients)
764 // guarantees we can't swallow an unrelated warning that happened to
765 // land in error_get_last() first. This does NOT touch the error
766 // LOG — if WP_DEBUG_LOG is on, PHP already wrote the warning to
767 // debug.log before this runs, and the explicit "NOT persisting"
768 // diagnostic below is the signal meant for humans.
769 if ( function_exists( 'error_clear_last' ) ) {
770 $last = error_get_last();
771 if ( is_array( $last ) && isset( $last['file'] ) ) {
772 $file = $last['file'];
773 if ( __FILE__ === $file
774 || false !== strpos( $file, 'class-redis-client.php' )
775 || false !== strpos( $file, 'class-memcached-client.php' )
776 ) {
777 error_clear_last();
778 }
779 }
780 }
781
782 // The drop-in is installed (we're running), so if we didn't manage
783 // to connect a persistent backend, object caching is effectively
784 // doing nothing — writes succeed but evaporate at request end.
785 // Flag it so detect()/the dashboard can report "degraded" instead
786 // of a false-healthy state, and log once per request so the failure
787 // is diagnosable rather than silent. (FBS-82210)
788 if ( ! $this->persistent ) {
789 $this->degraded = true;
790 $should_log = function_exists( 'apply_filters' )
791 ? apply_filters( 'xspeed_object_cache_log_degraded', true )
792 : true;
793 // Diagnostic only, and only when debug logging is on — keeps
794 // the production error log quiet (Plugin Check flags an
795 // unconditional error_log()).
796 if ( $should_log && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
797 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated degraded-state diagnostic.
798 error_log( sprintf(
799 '[xSpeed] Object cache drop-in active but NOT persisting: could not connect a %s backend (client: %s). Serving a non-persistent in-request cache. Check the backend host/port and that the extension OR xSpeed\'s bundled client is loadable.',
800 $this->backend,
801 $this->client
802 ) );
803 }
804 }
805 }
806
807 /**
808 * Whether a persistent backend is actually connected. False means the
809 * drop-in is degraded (non-persistent) — see $this->degraded.
810 */
811 public function is_persistent() {
812 return (bool) $this->persistent;
813 }
814
815 /** Concrete client in use: phpredis|builtin|ext-memcached|builtin-memcached|''. */
816 public function client_name() {
817 return $this->persistent ? (string) $this->client : '';
818 }
819
820 /**
821 * Load xSpeed's own Redis_Client on demand. The drop-in runs before
822 * the plugin's autoloader, so we require the class file directly from
823 * the plugin. Returns true once \XSpeed\Redis_Client is available.
824 */
825 private function load_builtin_client() {
826 return $this->load_builtin( '\\XSpeed\\Redis_Client', 'class-redis-client.php' );
827 }
828
829 /**
830 * Robustly locate + require one of xSpeed's bundled, extension-free
831 * clients. This is the linchpin of the "no extension to install"
832 * promise: on a host without phpredis/ext-memcached, the drop-in MUST
833 * be able to load this file or it silently degrades to a non-persistent
834 * cache (writes return true but never reach the backend). (FBS-82210)
835 *
836 * The original implementation only tried WP_PLUGIN_DIR — which fails
837 * when the plugin dir is symlinked, when WP_PLUGIN_DIR points somewhere
838 * unexpected, or when the constant isn't defined yet at drop-in load
839 * time. We add a __DIR__-relative candidate: the drop-in lives in
840 * wp-content/, and the plugin sits at wp-content/plugins/xspeed/includes/,
841 * so we can resolve the client relative to our own location regardless
842 * of how the plugin is mounted. realpath() also resolves symlinks.
843 *
844 * @param string $class Fully-qualified class name to check for.
845 * @param string $filename Client file under the plugin's includes/ dir.
846 * @return bool True once the class is available.
847 */
848 private function load_builtin( $class, $filename ) {
849 if ( class_exists( $class ) ) {
850 return true;
851 }
852
853 $candidates = array();
854 if ( defined( 'WP_PLUGIN_DIR' ) ) {
855 $candidates[] = WP_PLUGIN_DIR . '/xspeed/includes/' . $filename;
856 }
857 if ( defined( 'WP_CONTENT_DIR' ) ) {
858 $candidates[] = WP_CONTENT_DIR . '/plugins/xspeed/includes/' . $filename;
859 $candidates[] = WP_CONTENT_DIR . '/mu-plugins/xspeed/includes/' . $filename;
860 }
861 // __DIR__-relative: this file is wp-content/object-cache.php, so the
862 // plugin is a sibling under plugins/xspeed/ — survives symlinks and
863 // odd WP_PLUGIN_DIR values the candidates above don't.
864 $candidates[] = __DIR__ . '/plugins/xspeed/includes/' . $filename;
865
866 foreach ( $candidates as $path ) {
867 if ( ! $path ) {
868 continue;
869 }
870 $real = @realpath( $path );
871 $path = false !== $real ? $real : $path;
872 if ( file_exists( $path ) ) {
873 require_once $path;
874 if ( class_exists( $class ) ) {
875 return true;
876 }
877 }
878 }
879
880 return class_exists( $class );
881 }
882
883 // --- Key helpers ----------------------------------------------------
884
885 private function group( $group ) {
886 return '' === (string) $group ? 'default' : (string) $group;
887 }
888
889 private function full_key( $key, $group ) {
890 $group = $this->group( $group );
891 $prefix = isset( $this->global_groups[ $group ] ) ? 0 : $this->blog_prefix;
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;
898 }
899
900 private function is_persistent_group( $group ) {
901 return $this->persistent && ! isset( $this->non_persistent_groups[ $this->group( $group ) ] );
902 }
903
904 // --- Core ops -------------------------------------------------------
905
906 public function add( $key, $data, $group = 'default', $expire = 0 ) {
907 if ( wp_suspend_cache_addition() ) {
908 return false;
909 }
910 $id = $this->full_key( $key, $group );
911 // Present in THIS request's runtime cache → already added.
912 if ( isset( $this->cache[ $id ] ) ) {
913 return false;
914 }
915
916 // For persistent groups, add() must fail if the key exists in the
917 // BACKEND too — not just this request's runtime array. Use the
918 // backend's atomic add (Redis SET NX / memcached add) so two
919 // processes racing to add the same key behave correctly and the
920 // existing value is never clobbered. Falling back to the runtime
921 // check alone (the old behaviour) let process B overwrite a key
922 // process A had already stored. (FBS-82111 Bug 2)
923 if ( $this->is_persistent_group( $group ) && $this->conn ) {
924 try {
925 if ( is_object( $data ) ) {
926 $data = clone $data;
927 }
928 $payload = maybe_serialize( $data );
929 $stored = $this->conn->add( $id, $payload, (int) $expire );
930 if ( ! $stored ) {
931 return false; // key already exists in the backend.
932 }
933 $this->cache[ $id ] = $data;
934 return true;
935 } catch ( \Throwable $e ) {
936 // Backend hiccup — fall through to the runtime-only path so
937 // add() still works against the in-request array cache.
938 }
939 }
940
941 return $this->set( $key, $data, $group, $expire );
942 }
943
944 public function replace( $key, $data, $group = 'default', $expire = 0 ) {
945 $id = $this->full_key( $key, $group );
946 if ( ! isset( $this->cache[ $id ] ) && false === $this->get( $key, $group ) ) {
947 return false;
948 }
949 return $this->set( $key, $data, $group, $expire );
950 }
951
952 public function set( $key, $data, $group = 'default', $expire = 0 ) {
953 $id = $this->full_key( $key, $group );
954 if ( is_object( $data ) ) {
955 $data = clone $data;
956 }
957 $this->cache[ $id ] = $data;
958
959 if ( $this->is_persistent_group( $group ) ) {
960 $stored = false;
961 $threw = false;
962 try {
963 $payload = maybe_serialize( $data );
964 if ( 'redis' === $this->backend ) {
965 $stored = $expire > 0
966 ? (bool) $this->conn->setex( $id, (int) $expire, $payload )
967 : (bool) $this->conn->set( $id, $payload );
968 } else {
969 $stored = (bool) $this->conn->set( $id, $payload, (int) $expire );
970 }
971 } catch ( \Throwable $e ) {
972 $threw = true;
973 }
974 if ( ! $stored ) {
975 // The backend may still hold the PREVIOUS value for this
976 // key (classic: memcached rejecting an alloptions blob
977 // over its item-size limit). The DB now has the new value;
978 // leaving the old one here would serve stale data to every
979 // later request — e.g. a settings change confirmed over
980 // REST/MCP that the dashboard never shows. Evict so
981 // readers fall back to the database.
982 try {
983 $this->backend_delete( $id );
984 } catch ( \Throwable $e ) {
985 // Backend fully down → reads fail too, so no staleness.
986 }
987 }
988 // Return value: the runtime cache always accepted the value, and
989 // WP core's contract for wp_cache_set() is "was it cached",
990 // which a persistent-backend refusal doesn't falsify — the
991 // value is live for this request and the DB holds the truth
992 // for later ones (we evicted the stale copy above). Some
993 // callers treat false as "the write was lost" and retry or
994 // bail, so report success and surface backend trouble through
995 // the degraded flag instead.
996 //
997 // Exception: a THROWN backend is a hard failure we still
998 // report as cached for the same reason — the runtime cache
999 // holds it.
1000 if ( ! $stored ) {
1001 $this->degraded = true;
1002 }
1003 return true;
1004 }
1005 return true;
1006 }
1007
1008 public function get( $key, $group = 'default', $force = false, &$found = null ) {
1009 $id = $this->full_key( $key, $group );
1010
1011 if ( ! $force && isset( $this->cache[ $id ] ) ) {
1012 $found = true;
1013 ++$this->cache_hits;
1014 $val = $this->cache[ $id ];
1015 return is_object( $val ) ? clone $val : $val;
1016 }
1017
1018 if ( $this->is_persistent_group( $group ) ) {
1019 try {
1020 $raw = $this->conn->get( $id );
1021 if ( false !== $raw && null !== $raw ) {
1022 $val = maybe_unserialize( $raw );
1023 $this->cache[ $id ] = $val;
1024 $found = true;
1025 ++$this->cache_hits;
1026 return is_object( $val ) ? clone $val : $val;
1027 }
1028 } catch ( \Throwable $e ) {
1029 // fall through to miss
1030 }
1031 }
1032
1033 $found = false;
1034 ++$this->cache_misses;
1035 return false;
1036 }
1037
1038 public function get_multiple( $keys, $group = 'default', $force = false ) {
1039 $out = array();
1040 foreach ( (array) $keys as $key ) {
1041 $out[ $key ] = $this->get( $key, $group, $force );
1042 }
1043 return $out;
1044 }
1045
1046 public function delete( $key, $group = 'default' ) {
1047 $id = $this->full_key( $key, $group );
1048 unset( $this->cache[ $id ] );
1049 if ( $this->is_persistent_group( $group ) ) {
1050 try {
1051 return (bool) $this->backend_delete( $id );
1052 } catch ( \Throwable $e ) {
1053 return true;
1054 }
1055 }
1056 return true;
1057 }
1058
1059 public function incr( $key, $offset = 1, $group = 'default' ) {
1060 $id = $this->full_key( $key, $group );
1061 $offset = max( 0, (int) $offset );
1062 if ( $this->is_persistent_group( $group ) ) {
1063 try {
1064 $new = $this->backend_incr( $id, $offset );
1065 if ( false !== $new ) {
1066 $this->cache[ $id ] = (int) $new;
1067 return (int) $new;
1068 }
1069 } catch ( \Throwable $e ) {
1070 // fall through
1071 }
1072 }
1073 $val = isset( $this->cache[ $id ] ) ? (int) $this->cache[ $id ] : 0;
1074 $val = max( 0, $val + $offset );
1075 $this->cache[ $id ] = $val;
1076 return $val;
1077 }
1078
1079 public function decr( $key, $offset = 1, $group = 'default' ) {
1080 $id = $this->full_key( $key, $group );
1081 $offset = max( 0, (int) $offset );
1082 if ( $this->is_persistent_group( $group ) ) {
1083 try {
1084 $new = $this->backend_decr( $id, $offset );
1085 if ( false !== $new ) {
1086 $new = max( 0, (int) $new );
1087 $this->cache[ $id ] = $new;
1088 return $new;
1089 }
1090 } catch ( \Throwable $e ) {
1091 // fall through
1092 }
1093 }
1094 $val = isset( $this->cache[ $id ] ) ? (int) $this->cache[ $id ] : 0;
1095 $val = max( 0, $val - $offset );
1096 $this->cache[ $id ] = $val;
1097 return $val;
1098 }
1099
1100 public function flush() {
1101 $this->cache = array();
1102 if ( $this->persistent ) {
1103 try {
1104 return (bool) $this->backend_flush( );
1105 } catch ( \Throwable $e ) {
1106 return false;
1107 }
1108 }
1109 return true;
1110 }
1111
1112 // --- Backend dispatch ------------------------------------------
1113 // Normalises method-name differences across the four client kinds:
1114 // phpredis + our Redis_Client (redis backend), ext/memcached + our
1115 // Memcached_Client (memcached backend).
1116
1117 private function backend_delete( $id ) {
1118 if ( 'redis' === $this->backend ) {
1119 return $this->conn->del( $id );
1120 }
1121 return $this->conn->delete( $id );
1122 }
1123
1124 private function backend_incr( $id, $offset ) {
1125 if ( 'redis' === $this->backend ) {
1126 return $this->conn->incrBy( $id, $offset );
1127 }
1128 return 'builtin-memcached' === $this->client
1129 ? $this->conn->incr( $id, $offset )
1130 : $this->conn->increment( $id, $offset );
1131 }
1132
1133 private function backend_decr( $id, $offset ) {
1134 if ( 'redis' === $this->backend ) {
1135 return $this->conn->decrBy( $id, $offset );
1136 }
1137 return 'builtin-memcached' === $this->client
1138 ? $this->conn->decr( $id, $offset )
1139 : $this->conn->decrement( $id, $offset );
1140 }
1141
1142 private function backend_flush() {
1143 if ( 'redis' === $this->backend ) {
1144 // Scope the flush to THIS site's namespace (salt:*) instead of
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.
1157 if ( '' === $this->salt ) {
1158 return false;
1159 }
1160 return $this->delete_redis_pattern( $this->escape_glob( $this->salt ) . ':*' ) >= 0;
1161 }
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();
1170 }
1171
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 /**
1423 * Delete every Redis key matching $pattern across both client kinds
1424 * (phpredis native scan + our pure-PHP Redis_Client). Returns the
1425 * count deleted, or -1 if the backend isn't redis. SCAN-based so it
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.
1430 */
1431 private function delete_redis_pattern( $pattern ) {
1432 if ( 'redis' !== $this->backend || ! $this->conn ) {
1433 return -1;
1434 }
1435 // Our pure-PHP client.
1436 if ( method_exists( $this->conn, 'delete_by_pattern' ) ) {
1437 return $this->conn->delete_by_pattern( $pattern );
1438 }
1439 // phpredis: iterate the SCAN cursor (setOption SCAN_RETRY keeps it
1440 // simple — scan() returns false when the cursor is exhausted).
1441 if ( $this->conn instanceof \Redis ) {
1442 $deleted = 0;
1443 $it = null;
1444 if ( defined( '\Redis::SCAN_RETRY' ) ) {
1445 $this->conn->setOption( \Redis::OPT_SCAN, \Redis::SCAN_RETRY );
1446 }
1447 do {
1448 $keys = $this->conn->scan( $it, $pattern, 500 );
1449 if ( is_array( $keys ) && ! empty( $keys ) ) {
1450 $deleted += (int) $this->conn->del( $keys );
1451 }
1452 } while ( $it > 0 );
1453 return $deleted;
1454 }
1455 return -1;
1456 }
1457
1458 /**
1459 * Load xSpeed's own Memcached_Client (pure-PHP) on demand, the same
1460 * way load_builtin_client() loads the Redis one.
1461 */
1462 private function load_builtin_memcached() {
1463 return $this->load_builtin( '\\XSpeed\\Memcached_Client', 'class-memcached-client.php' );
1464 }
1465
1466 public function flush_runtime() {
1467 $this->cache = array();
1468 return true;
1469 }
1470
1471 public function flush_group( $group ) {
1472 $group = $this->group( $group );
1473
1474 // Runtime copy first — drop every in-request entry for this group.
1475 $needle = $this->full_key( '', $group );
1476 foreach ( array_keys( $this->cache ) as $id ) {
1477 if ( 0 === strpos( $id, $needle ) ) {
1478 unset( $this->cache[ $id ] );
1479 }
1480 }
1481
1482 // Persistent store: actually evict the group's keys from Redis so a
1483 // targeted invalidation (core or third-party calling
1484 // wp_cache_flush_group) stops serving stale data — previously this
1485 // was a runtime-only no-op against the backend. The key layout is
1486 // salt:{prefix}:{group}:{key}, so match salt:*:{group}:* to cover
1487 // both blog-prefixed and global groups for this site's namespace.
1488 // (FBS-83119)
1489 if ( $this->is_persistent_group( $group ) && 'redis' === $this->backend && '' !== $this->salt ) {
1490 try {
1491 $this->delete_redis_pattern(
1492 $this->escape_glob( $this->salt ) . ':*:' . $this->escape_glob( $group ) . ':*'
1493 );
1494 } catch ( \Throwable $e ) {
1495 return false;
1496 }
1497 }
1498 return true;
1499 }
1500
1501 public function close() {
1502 if ( $this->persistent && $this->conn ) {
1503 try {
1504 if ( 'redis' === $this->backend ) {
1505 $this->conn->close();
1506 } else {
1507 $this->conn->quit();
1508 }
1509 } catch ( \Throwable $e ) {
1510 // ignore
1511 }
1512 }
1513 return true;
1514 }
1515
1516 // --- Group config ---------------------------------------------------
1517
1518 public function add_global_groups( $groups ) {
1519 foreach ( (array) $groups as $g ) {
1520 $this->global_groups[ $g ] = true;
1521 }
1522 }
1523
1524 public function add_non_persistent_groups( $groups ) {
1525 foreach ( (array) $groups as $g ) {
1526 $this->non_persistent_groups[ $g ] = true;
1527 }
1528 }
1529
1530 public function switch_to_blog( $blog_id ) {
1531 $this->blog_prefix = $this->multisite ? (int) $blog_id : 0;
1532 }
1533
1534 /** @return array{backend:string,persistent:bool,hits:int,misses:int} */
1535 public function stats() {
1536 return array(
1537 'backend' => $this->backend,
1538 'persistent' => $this->persistent,
1539 'hits' => $this->cache_hits,
1540 'misses' => $this->cache_misses,
1541 );
1542 }
1543 }
1544 }
1545