PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 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 All 36 releases
xspeed / includes / object-cache.php

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

1,581 lines 57.6 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_PATH', '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_USERNAME', '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 // WP_REDIS_PATH names a socket only when WP_REDIS_SCHEME is
187 // `unix` in any case, the same gate the schema declares in
188 // `constants_when`. Redis Object Cache uses strcasecmp(). (#566)
189 if ( 'WP_REDIS_PATH' === $const && ! ( defined( 'WP_REDIS_SCHEME' ) && is_string( constant( 'WP_REDIS_SCHEME' ) ) && 0 === strcasecmp( constant( 'WP_REDIS_SCHEME' ), 'unix' ) ) ) {
190 continue;
191 }
192 $value = constant( $const );
193 // WP_REDIS_PASSWORD only answers for `user` in its array form,
194 // which carries the ACL username. As a plain string it is just
195 // a password: skip it, or we would authenticate with the
196 // password as the username.
197 if ( 'user' === $key && 'WP_REDIS_PASSWORD' === $const && ! is_array( $value ) ) {
198 continue;
199 }
200 return xspeed_oc_credential_part( $key, $value );
201 }
202 }
203
204 /*
205 * No constant answered. On a host where wp-config.php is not writable
206 * the panel stores the configuration in a sidecar beside this drop-in
207 * instead, so consult it before falling back to the built-in default --
208 * otherwise every setting the user saved there would be ignored at
209 * runtime while the panel showed it as active. (#398)
210 */
211 $sidecar = xspeed_oc_sidecar();
212 $field = isset( $sidecar_map[ $key ] ) ? $sidecar_map[ $key ] : null;
213 if ( null !== $field && array_key_exists( $field, $sidecar ) ) {
214 return xspeed_oc_credential_part( $key, $sidecar[ $field ] );
215 }
216
217 return $default;
218 }
219 }
220
221 if ( ! function_exists( 'xspeed_oc_first_memcached_server' ) ) {
222 /**
223 * Host and port of the first server in a `$memcached_servers` global.
224 *
225 * Two shapes are in circulation and hosts write both:
226 *
227 * array( array( 'host', 11211 ) ) // W3TC pair form
228 * array( 'default' => array( 'host:11211' ) ) // Memcached Object Cache
229 *
230 * Supporting only the first left the second reading the whole "host:port"
231 * string as the hostname -- or missing entirely, since its bucket is keyed
232 * `default` rather than 0. Kept in one function because Settings_Manager
233 * has to answer identically or the panel and the runtime disagree, which is
234 * the bug this whole change closes. (#398)
235 *
236 * @param mixed $servers The global's value, unvalidated.
237 * @return array{0:?string,1:?int}|null Host and port, either possibly null.
238 */
239 function xspeed_oc_first_memcached_server( $servers ) {
240 if ( ! is_array( $servers ) || array() === $servers ) {
241 return null;
242 }
243
244 // Either the 0th bucket or, for the keyed form, whichever comes first.
245 $bucket = array_key_exists( 0, $servers ) ? $servers[0] : reset( $servers );
246
247 /*
248 * A bucket is EITHER a [host, port] pair or a list of server entries.
249 * Telling them apart by shape, not by nesting depth: descending into
250 * array( 'mc.example', 11211 ) yields the host string and drops the
251 * port on the floor, which is the commonest form there is.
252 */
253 $entry = $bucket;
254 if ( is_array( $bucket ) && isset( $bucket[0] ) && is_array( $bucket[0] ) ) {
255 $entry = $bucket[0];
256 }
257
258 // Pair form: [ host, port ].
259 if ( is_array( $entry ) ) {
260 $host = isset( $entry[0] ) && ! is_array( $entry[0] ) ? (string) $entry[0] : null;
261 $port = isset( $entry[1] ) && ! is_array( $entry[1] ) ? (int) $entry[1] : null;
262 // A single-element list, array( 'host:port' ), is the keyed form's
263 // bucket rather than a pair -- fall through to the string parser.
264 if ( null !== $host && null === $port && is_string( $entry[0] ) && false !== strpos( $entry[0], ':' ) ) {
265 $entry = $entry[0];
266 } else {
267 return ( null === $host && null === $port ) ? null : array( $host, $port );
268 }
269 }
270
271 if ( ! is_string( $entry ) || '' === $entry ) {
272 return null;
273 }
274
275 // "host:port", or a bare host. A unix socket path has no port and can
276 // contain no colon we should split on, so only split the LAST one and
277 // only when what follows is numeric.
278 $at = strrpos( $entry, ':' );
279 if ( false !== $at && ctype_digit( substr( $entry, $at + 1 ) ) ) {
280 return array( substr( $entry, 0, $at ), (int) substr( $entry, $at + 1 ) );
281 }
282 return array( $entry, null );
283 }
284 }
285
286 if ( ! function_exists( 'xspeed_oc_redis_socket' ) ) {
287 /**
288 * The unix socket path a Redis host names, or '' for a network host.
289 *
290 * A copy of \XSpeed\Redis_Client::socket_path(), which this drop-in cannot
291 * call when phpredis is the client: the class is only loaded for the
292 * built-in one. ObjectCacheSocketTest runs both over the same inputs, so
293 * the panel's Test connection and the runtime agree on what a socket is.
294 * (#566)
295 *
296 * @param string $host Configured Redis host.
297 * @return string Socket path, or ''.
298 */
299 function xspeed_oc_redis_socket( $host ) {
300 $host = trim( (string) $host );
301 if ( 0 === stripos( $host, 'unix:' ) ) {
302 // unix:///path and unix:/path both name /path.
303 $host = '/' . ltrim( substr( $host, 5 ), '/' );
304 }
305 return ( '' !== $host && '/' === $host[0] && '/' !== $host ) ? $host : '';
306 }
307 }
308
309 if ( ! function_exists( 'xspeed_oc_our_constants' ) ) {
310 /**
311 * Constant names defined inside OUR fenced block in wp-config.php.
312 *
313 * Ownership is decided by WHERE a define sits, exactly as
314 * Object_Cache::our_constants() decides it for the admin half. The drop-in
315 * needs the same answer for the same reason the panel does: a define we
316 * wrote is only a mirror of the option row, so a HOST define has to outrank
317 * it. Without this the drop-in kept connecting to our stale snapshot after
318 * a host rotated its credentials, while the panel -- which does apply the
319 * rule -- showed the new one. Panel and runtime disagreeing is the whole
320 * bug this change exists to remove. (#398)
321 *
322 * @return string[]
323 */
324 function xspeed_oc_our_constants() {
325 static $names = null;
326 if ( null !== $names ) {
327 return $names;
328 }
329 $names = array();
330
331 // ABSPATH is defined by wp-load.php before the drop-in is included.
332 $path = defined( 'ABSPATH' ) ? ABSPATH . 'wp-config.php' : '';
333 if ( '' === $path || ! is_readable( $path ) ) {
334 // One level up is the standard "wp-config outside the root" layout.
335 $alt = defined( 'ABSPATH' ) ? dirname( ABSPATH ) . '/wp-config.php' : '';
336 $path = ( '' !== $alt && is_readable( $alt ) ) ? $alt : '';
337 }
338 if ( '' === $path ) {
339 return $names;
340 }
341
342 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- runs before WordPress; WP_Filesystem does not exist yet.
343 $config = (string) @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable wp-config just means "we own nothing".
344 if ( '' === $config ) {
345 return $names;
346 }
347 if ( ! preg_match( '/\/\* BEGIN xSpeed Object Cache \*\/(.*?)\/\* END xSpeed Object Cache \*\//s', $config, $m ) ) {
348 return $names;
349 }
350 if ( preg_match_all( "/define\(\s*'([A-Z0-9_]+)'/", $m[1], $found ) ) {
351 $names = $found[1];
352 }
353 return $names;
354 }
355 }
356
357 if ( ! function_exists( 'xspeed_oc_sidecar' ) ) {
358 /**
359 * Configuration written beside this drop-in when wp-config.php is
360 * read-only. Returns an empty array when there is none.
361 *
362 * This file IS wp-content/object-cache.php, so the sidecar sits in the
363 * same directory -- no constant needed to locate it, which matters because
364 * WP_CONTENT_DIR is not guaranteed to be defined this early.
365 *
366 * @return array<string,mixed>
367 */
368 function xspeed_oc_sidecar() {
369 static $data = null;
370 if ( null !== $data ) {
371 return $data;
372 }
373 $data = array();
374 $path = __DIR__ . '/xspeed-object-cache.php';
375 if ( ! is_readable( $path ) ) {
376 return $data;
377 }
378
379 /*
380 * This runs before WordPress, so a parse error here is a white screen
381 * on every request rather than a degraded cache. The writer renames
382 * into place atomically, but a file truncated by something else -- a
383 * failed deploy, a partial restore -- must not take the site down, so
384 * the include is guarded and any failure degrades to "no sidecar".
385 */
386 try {
387 $loaded = @include $path; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a broken sidecar must not white-screen the site.
388 if ( is_array( $loaded ) ) {
389 $data = $loaded;
390 }
391 } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- degrade to no sidecar.
392 $data = array();
393 }
394 return $data;
395 }
396 }
397
398 if ( ! function_exists( 'xspeed_oc_salt' ) ) {
399 /**
400 * Resolve the salt that namespaces this site's keys.
401 *
402 * Normally the salt constant is written into wp-config.php on enable. When
403 * wp-config is NOT writable (some managed hosts) the drop-in still gets
404 * installed, so without a fallback every key would come out as
405 * `:{blog}:{group}:{key}` — identical on every install. Two sites sharing a
406 * Redis/Memcached server would then read each other's blog-details /
407 * blog-lookup entries and the second site would redirect to the first.
408 *
409 * Derived the same way Object_Cache::derive_salt() does, so a site keeps
410 * the same namespace whether the constant is present or not.
411 *
412 * @return string Non-empty salt.
413 */
414 function xspeed_oc_salt() {
415 $salt = (string) xspeed_oc_config( 'salt', '' );
416 if ( '' !== $salt ) {
417 return $salt;
418 }
419
420 global $table_prefix;
421
422 $url = '';
423 if ( defined( 'WP_HOME' ) ) {
424 $url = (string) WP_HOME;
425 } elseif ( defined( 'WP_SITEURL' ) ) {
426 $url = (string) WP_SITEURL;
427 }
428
429 // WP_HOME / WP_SITEURL are OPTIONAL and absent from a stock
430 // wp-config.php, so the URL is usually empty here — DB_NAME alone
431 // would then be identical for two sites sharing one database and the
432 // collision this salt exists to prevent would come straight back.
433 // $table_prefix separates them: it is assigned in wp-config.php itself,
434 // so it is already a global by the time the drop-in loads (well before
435 // $wpdb exists). ABSPATH is added whenever no URL was available, since
436 // two installs on one database necessarily live in different
437 // directories.
438 $parts = array(
439 $url,
440 defined( 'DB_NAME' ) ? (string) DB_NAME : '',
441 isset( $table_prefix ) ? (string) $table_prefix : '',
442 );
443 if ( '' === $url ) {
444 $parts[] = defined( 'ABSPATH' ) ? (string) ABSPATH : '';
445 }
446
447 $seed = implode( '|', $parts );
448 if ( '' === trim( $seed, '|' ) ) {
449 $seed = 'xspeed';
450 }
451
452 return 'xs' . substr( md5( $seed ), 0, 12 );
453 }
454 }
455
456 if ( ! function_exists( 'xspeed_oc_credential_part' ) ) {
457 /**
458 * Unpack the array form of a Redis credential constant.
459 *
460 * Managed hosts that provision Redis ACL users (xCloud, Cloudways) ship
461 * the pair in one define:
462 *
463 * define( 'WP_REDIS_PASSWORD', array( 'acl_user', 's3cret' ) );
464 *
465 * Casting that to string yields "Array" and a notice, so the site would
466 * authenticate with garbage. Split it here, at the single point both
467 * `user` and `password` resolve through, rather than in each caller.
468 *
469 * @param string $key Config key being resolved.
470 * @param mixed $value Raw constant value.
471 * @return mixed
472 */
473 function xspeed_oc_credential_part( $key, $value ) {
474 if ( ! is_array( $value ) || ( 'user' !== $key && 'password' !== $key ) ) {
475 return $value;
476 }
477 // Scalars only. A nested value would stringify to "Array" and emit a
478 // warning on EVERY request from the drop-in -- before headers are sent,
479 // on the code path whose whole design rule is never to break the site.
480 $parts = array_values( array_filter( $value, 'is_scalar' ) );
481 if ( 'user' === $key ) {
482 // A one-element array is a password with no ACL user.
483 return count( $parts ) > 1 ? (string) $parts[0] : '';
484 }
485 return (string) ( count( $parts ) > 1 ? $parts[1] : ( $parts[0] ?? '' ) );
486 }
487 }
488
489 // -----------------------------------------------------------------------------
490 // WordPress object-cache API surface. Thin wrappers over the global instance.
491 // -----------------------------------------------------------------------------
492 if ( ! function_exists( 'wp_cache_init' ) ) {
493
494 function wp_cache_init() {
495 $GLOBALS['wp_object_cache'] = new XSpeed_Object_Cache();
496 }
497
498 function wp_cache_add( $key, $data, $group = '', $expire = 0 ) {
499 return $GLOBALS['wp_object_cache']->add( $key, $data, $group, (int) $expire );
500 }
501
502 function wp_cache_add_multiple( array $data, $group = '', $expire = 0 ) {
503 $out = array();
504 foreach ( $data as $key => $value ) {
505 $out[ $key ] = wp_cache_add( $key, $value, $group, $expire );
506 }
507 return $out;
508 }
509
510 function wp_cache_replace( $key, $data, $group = '', $expire = 0 ) {
511 return $GLOBALS['wp_object_cache']->replace( $key, $data, $group, (int) $expire );
512 }
513
514 function wp_cache_set( $key, $data, $group = '', $expire = 0 ) {
515 return $GLOBALS['wp_object_cache']->set( $key, $data, $group, (int) $expire );
516 }
517
518 function wp_cache_set_multiple( array $data, $group = '', $expire = 0 ) {
519 $out = array();
520 foreach ( $data as $key => $value ) {
521 $out[ $key ] = wp_cache_set( $key, $value, $group, $expire );
522 }
523 return $out;
524 }
525
526 function wp_cache_get( $key, $group = '', $force = false, &$found = null ) {
527 return $GLOBALS['wp_object_cache']->get( $key, $group, $force, $found );
528 }
529
530 function wp_cache_get_multiple( $keys, $group = '', $force = false ) {
531 return $GLOBALS['wp_object_cache']->get_multiple( $keys, $group, $force );
532 }
533
534 function wp_cache_delete( $key, $group = '' ) {
535 return $GLOBALS['wp_object_cache']->delete( $key, $group );
536 }
537
538 function wp_cache_delete_multiple( array $keys, $group = '' ) {
539 $out = array();
540 foreach ( $keys as $key ) {
541 $out[ $key ] = wp_cache_delete( $key, $group );
542 }
543 return $out;
544 }
545
546 function wp_cache_incr( $key, $offset = 1, $group = '' ) {
547 return $GLOBALS['wp_object_cache']->incr( $key, (int) $offset, $group );
548 }
549
550 function wp_cache_decr( $key, $offset = 1, $group = '' ) {
551 return $GLOBALS['wp_object_cache']->decr( $key, (int) $offset, $group );
552 }
553
554 function wp_cache_flush() {
555 return $GLOBALS['wp_object_cache']->flush();
556 }
557
558 function wp_cache_flush_runtime() {
559 return $GLOBALS['wp_object_cache']->flush_runtime();
560 }
561
562 function wp_cache_flush_group( $group ) {
563 return $GLOBALS['wp_object_cache']->flush_group( $group );
564 }
565
566 function wp_cache_supports( $feature ) {
567 return in_array( $feature, array( 'get_multiple', 'set_multiple', 'add_multiple', 'delete_multiple', 'flush_runtime', 'flush_group' ), true );
568 }
569
570 function wp_cache_close() {
571 return $GLOBALS['wp_object_cache']->close();
572 }
573
574 function wp_cache_add_global_groups( $groups ) {
575 $GLOBALS['wp_object_cache']->add_global_groups( $groups );
576 }
577
578 function wp_cache_add_non_persistent_groups( $groups ) {
579 $GLOBALS['wp_object_cache']->add_non_persistent_groups( $groups );
580 }
581
582 function wp_cache_switch_to_blog( $blog_id ) {
583 $GLOBALS['wp_object_cache']->switch_to_blog( (int) $blog_id );
584 }
585
586 function wp_cache_reset() {
587 // Deprecated in core; kept for back-compat.
588 return $GLOBALS['wp_object_cache']->flush_runtime();
589 }
590 }
591
592 // -----------------------------------------------------------------------------
593 // The cache implementation.
594 // -----------------------------------------------------------------------------
595 if ( ! class_exists( 'XSpeed_Object_Cache' ) ) {
596
597 class XSpeed_Object_Cache {
598
599 /** @var array In-request cache (always populated; also the fallback store). */
600 private $cache = array();
601
602 /** @var \Redis|\Memcached|null Persistent backend handle, or null when degraded. */
603 private $conn = null;
604
605 /** @var string redis|memcached */
606 private $backend = 'redis';
607
608 /** @var string Concrete client driving a Redis backend: phpredis|builtin. */
609 private $client = 'phpredis';
610
611 /** @var bool True once a persistent backend is connected. */
612 private $persistent = false;
613
614 /**
615 * @var bool True when the drop-in is active but could NOT connect a
616 * persistent backend, so it's silently serving a non-persistent
617 * in-request cache. Surfaced so the dashboard can report "degraded"
618 * instead of implying object caching is healthy. (FBS-82210)
619 */
620 public $degraded = false;
621
622 /** @var string Key salt / prefix. */
623 private $salt = '';
624
625 /** Option holding the Memcached generation floor (see generation_floor()). */
626 const GENERATION_OPTION = 'xspeed_oc_generation';
627
628 /**
629 * @var int|null This site's namespace generation, resolved lazily once
630 * per request. Advancing it is how Memcached flushes only this site's
631 * keys — the daemon offers no way to enumerate or scope a real flush.
632 * Null until first read; 1 means the original, unsuffixed key shape.
633 */
634 private $generation = null;
635
636 /**
637 * @var int|null Lowest generation this site may use, mirrored in the
638 * database so an LRU eviction of the cached counter cannot rewind the
639 * namespace. Null until first read.
640 */
641 private $generation_floor = null;
642
643 /** @var int Current blog id (multisite prefixing). */
644 private $blog_prefix = 0;
645
646 /** @var bool */
647 private $multisite = false;
648
649 /** @var array<string,bool> Groups shared across the whole network. */
650 private $global_groups = array();
651
652 /** @var array<string,bool> Groups that must never hit the persistent store. */
653 private $non_persistent_groups = array();
654
655 /** @var int Cache hits this request. */
656 public $cache_hits = 0;
657
658 /** @var int Cache misses this request. */
659 public $cache_misses = 0;
660
661 public function __construct() {
662 $this->multisite = function_exists( 'is_multisite' ) && is_multisite();
663 $this->blog_prefix = $this->multisite ? (int) get_current_blog_id() : 0;
664 $this->salt = xspeed_oc_salt();
665 $this->backend = strtolower( (string) xspeed_oc_config( 'backend', 'redis' ) );
666
667 // Non-persistent groups. We deliberately DO persist `options`
668 // (incl. the autoloaded `alloptions` blob), `comment`, and
669 // `counts` — these are the highest-volume, highest-hit groups,
670 // and excluding them was why the persistent cache stored only a
671 // fraction of the keys a mature object cache (e.g. Redis Object
672 // Cache) does. Redis Object Cache persists all of them by
673 // default; matching that is the whole point of the feature.
674 //
675 // The historical "can't deactivate a plugin" bug (FBS-82210) was
676 // a stale `alloptions` being read back after a plugin write. That
677 // is NOT solved by refusing to persist options — a correct cache
678 // solves it by invalidating on write, which WordPress core already
679 // does: update_option()/add_option()/delete_option() each call
680 // wp_cache_delete( 'alloptions', 'options' ). Our delete()
681 // propagates to the backend for every persistent group (see
682 // delete()), so the stale blob is removed the moment WP writes an
683 // option — deactivation stays correct WITH options persisted.
684 //
685 // `plugins` and `themes` remain non-persistent: they're tiny,
686 // rebuilt cheaply per request, and never worth a round trip.
687 $this->add_non_persistent_groups(
688 array( 'plugins', 'themes' )
689 );
690
691 $this->connect();
692 }
693
694 // --- Connection -----------------------------------------------------
695
696 private function connect() {
697 $timeout = (float) xspeed_oc_config( 'timeout', 1 );
698 try {
699 if ( 'memcached' === $this->backend ) {
700 $host = (string) xspeed_oc_config( 'host', '127.0.0.1' );
701 $port = (int) xspeed_oc_config( 'port', 11211 );
702
703 if ( class_exists( 'Memcached' ) ) {
704 // ext/memcached (preferred).
705 $this->client = 'ext-memcached';
706 $mc = new Memcached();
707 $mc->addServer( $host, $port );
708 $mc->setOption( Memcached::OPT_CONNECT_TIMEOUT, (int) ( $timeout * 1000 ) );
709 $stats = @$mc->getStats();
710 if ( is_array( $stats ) && ! empty( $stats ) ) {
711 $this->conn = $mc;
712 $this->persistent = true;
713 }
714 } elseif ( $this->load_builtin_memcached() ) {
715 // xSpeed's own pure-PHP Memcached client.
716 $this->client = 'builtin-memcached';
717 $mc = new \XSpeed\Memcached_Client( $host, $port, $timeout );
718 if ( $mc->connect() && false !== $mc->version() ) {
719 $this->conn = $mc;
720 $this->persistent = true;
721 }
722 }
723 } else {
724 $this->backend = 'redis';
725 $host = (string) xspeed_oc_config( 'host', '127.0.0.1' );
726 $port = (int) xspeed_oc_config( 'port', 6379 );
727 $user = (string) xspeed_oc_config( 'user', '' );
728 $pass = (string) xspeed_oc_config( 'password', '' );
729 $db = (int) xspeed_oc_config( 'database', 0 );
730 $persist = (bool) xspeed_oc_config( 'persist', false );
731
732 if ( class_exists( 'Redis' ) ) {
733 // phpredis extension (preferred). It opens a unix
734 // socket only for a bare path with a port below 1;
735 // with any real port it tries TCP to the path. (#566)
736 $this->client = 'phpredis';
737 $redis = new Redis();
738 $socket = xspeed_oc_redis_socket( $host );
739 if ( '' !== $socket ) {
740 $host = $socket;
741 $port = 0;
742 }
743 $ok = $persist
744 ? @$redis->pconnect( $host, $port, $timeout )
745 : @$redis->connect( $host, $port, $timeout );
746 if ( $ok ) {
747 // Redis 6+ ACL: ['user'=>..,'pass'=>..] when a username
748 // is configured; legacy password-only otherwise.
749 if ( '' !== $user ) {
750 @$redis->auth( array( 'user' => $user, 'pass' => $pass ) );
751 } elseif ( '' !== $pass ) {
752 @$redis->auth( $pass );
753 }
754 if ( $db > 0 ) {
755 @$redis->select( $db );
756 }
757 if ( '+PONG' === @$redis->ping() || true === @$redis->ping() ) {
758 $this->conn = $redis;
759 $this->persistent = true;
760 }
761 }
762 } elseif ( $this->load_builtin_client() ) {
763 // xSpeed's own pure-PHP client (no extension, no library).
764 $this->client = 'builtin';
765 $rc = new \XSpeed\Redis_Client( $host, $port, (float) $timeout, $persist );
766 if ( $rc->connect() ) {
767 if ( '' !== $pass || '' !== $user ) {
768 $rc->auth( $pass, $user );
769 }
770 if ( $db > 0 ) {
771 $rc->select( $db );
772 }
773 $pong = $rc->ping();
774 if ( is_string( $pong ) && false !== stripos( $pong, 'PONG' ) ) {
775 $this->conn = $rc;
776 $this->persistent = true;
777 }
778 }
779 }
780 }
781 } catch ( \Throwable $e ) {
782 // Any failure → stay in non-persistent mode. Never fatal.
783 $this->conn = null;
784 $this->persistent = false;
785 }
786
787 // Connecting to an unreachable/unresolvable backend (e.g.
788 // `Redis::pconnect()` → "getaddrinfo for redis failed", or
789 // `stream_socket_client()` in our builtin clients) emits a PHP
790 // warning. We `@`-suppress those above and degrade gracefully to a
791 // non-persistent cache — but the warning still lingers in
792 // `error_get_last()`. WP reads that at `admin_body_class` time and
793 // tags every admin page `php-error`, which renders an empty banner
794 // above the admin menu even though nothing is actually broken.
795 //
796 // Clear it so a degraded-but-handled backend doesn't masquerade as
797 // a site error — but ONLY when the lingering error is OUR connect
798 // warning. We never blindly wipe the slot: matching on the
799 // originating file (this drop-in, or our bundled socket clients)
800 // guarantees we can't swallow an unrelated warning that happened to
801 // land in error_get_last() first. This does NOT touch the error
802 // LOG — if WP_DEBUG_LOG is on, PHP already wrote the warning to
803 // debug.log before this runs, and the explicit "NOT persisting"
804 // diagnostic below is the signal meant for humans.
805 if ( function_exists( 'error_clear_last' ) ) {
806 $last = error_get_last();
807 if ( is_array( $last ) && isset( $last['file'] ) ) {
808 $file = $last['file'];
809 if ( __FILE__ === $file
810 || false !== strpos( $file, 'class-redis-client.php' )
811 || false !== strpos( $file, 'class-memcached-client.php' )
812 ) {
813 error_clear_last();
814 }
815 }
816 }
817
818 // The drop-in is installed (we're running), so if we didn't manage
819 // to connect a persistent backend, object caching is effectively
820 // doing nothing — writes succeed but evaporate at request end.
821 // Flag it so detect()/the dashboard can report "degraded" instead
822 // of a false-healthy state, and log once per request so the failure
823 // is diagnosable rather than silent. (FBS-82210)
824 if ( ! $this->persistent ) {
825 $this->degraded = true;
826 $should_log = function_exists( 'apply_filters' )
827 ? apply_filters( 'xspeed_object_cache_log_degraded', true )
828 : true;
829 // Diagnostic only, and only when debug logging is on — keeps
830 // the production error log quiet (Plugin Check flags an
831 // unconditional error_log()).
832 if ( $should_log && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
833 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated degraded-state diagnostic.
834 error_log( sprintf(
835 '[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.',
836 $this->backend,
837 $this->client
838 ) );
839 }
840 }
841 }
842
843 /**
844 * Whether a persistent backend is actually connected. False means the
845 * drop-in is degraded (non-persistent) — see $this->degraded.
846 */
847 public function is_persistent() {
848 return (bool) $this->persistent;
849 }
850
851 /** Concrete client in use: phpredis|builtin|ext-memcached|builtin-memcached|''. */
852 public function client_name() {
853 return $this->persistent ? (string) $this->client : '';
854 }
855
856 /**
857 * Load xSpeed's own Redis_Client on demand. The drop-in runs before
858 * the plugin's autoloader, so we require the class file directly from
859 * the plugin. Returns true once \XSpeed\Redis_Client is available.
860 */
861 private function load_builtin_client() {
862 return $this->load_builtin( '\\XSpeed\\Redis_Client', 'class-redis-client.php' );
863 }
864
865 /**
866 * Robustly locate + require one of xSpeed's bundled, extension-free
867 * clients. This is the linchpin of the "no extension to install"
868 * promise: on a host without phpredis/ext-memcached, the drop-in MUST
869 * be able to load this file or it silently degrades to a non-persistent
870 * cache (writes return true but never reach the backend). (FBS-82210)
871 *
872 * The original implementation only tried WP_PLUGIN_DIR — which fails
873 * when the plugin dir is symlinked, when WP_PLUGIN_DIR points somewhere
874 * unexpected, or when the constant isn't defined yet at drop-in load
875 * time. We add a __DIR__-relative candidate: the drop-in lives in
876 * wp-content/, and the plugin sits at wp-content/plugins/xspeed/includes/,
877 * so we can resolve the client relative to our own location regardless
878 * of how the plugin is mounted. realpath() also resolves symlinks.
879 *
880 * @param string $class Fully-qualified class name to check for.
881 * @param string $filename Client file under the plugin's includes/ dir.
882 * @return bool True once the class is available.
883 */
884 private function load_builtin( $class, $filename ) {
885 if ( class_exists( $class ) ) {
886 return true;
887 }
888
889 $candidates = array();
890 if ( defined( 'WP_PLUGIN_DIR' ) ) {
891 $candidates[] = WP_PLUGIN_DIR . '/xspeed/includes/' . $filename;
892 }
893 if ( defined( 'WP_CONTENT_DIR' ) ) {
894 $candidates[] = WP_CONTENT_DIR . '/plugins/xspeed/includes/' . $filename;
895 $candidates[] = WP_CONTENT_DIR . '/mu-plugins/xspeed/includes/' . $filename;
896 }
897 // __DIR__-relative: this file is wp-content/object-cache.php, so the
898 // plugin is a sibling under plugins/xspeed/ — survives symlinks and
899 // odd WP_PLUGIN_DIR values the candidates above don't.
900 $candidates[] = __DIR__ . '/plugins/xspeed/includes/' . $filename;
901
902 foreach ( $candidates as $path ) {
903 if ( ! $path ) {
904 continue;
905 }
906 $real = @realpath( $path );
907 $path = false !== $real ? $real : $path;
908 if ( file_exists( $path ) ) {
909 require_once $path;
910 if ( class_exists( $class ) ) {
911 return true;
912 }
913 }
914 }
915
916 return class_exists( $class );
917 }
918
919 // --- Key helpers ----------------------------------------------------
920
921 private function group( $group ) {
922 return '' === (string) $group ? 'default' : (string) $group;
923 }
924
925 private function full_key( $key, $group ) {
926 $group = $this->group( $group );
927 $prefix = isset( $this->global_groups[ $group ] ) ? 0 : $this->blog_prefix;
928 $gen = $this->generation();
929 // Generation 1 keeps the historical key shape, so Redis (which
930 // flushes by pattern and never advances the generation) is
931 // byte-identical to before and existing entries stay readable.
932 $ns = 1 === $gen ? $this->salt : $this->salt . '.g' . $gen;
933 return $ns . ':' . $prefix . ':' . $group . ':' . $key;
934 }
935
936 private function is_persistent_group( $group ) {
937 return $this->persistent && ! isset( $this->non_persistent_groups[ $this->group( $group ) ] );
938 }
939
940 // --- Core ops -------------------------------------------------------
941
942 public function add( $key, $data, $group = 'default', $expire = 0 ) {
943 if ( wp_suspend_cache_addition() ) {
944 return false;
945 }
946 $id = $this->full_key( $key, $group );
947 // Present in THIS request's runtime cache → already added.
948 if ( isset( $this->cache[ $id ] ) ) {
949 return false;
950 }
951
952 // For persistent groups, add() must fail if the key exists in the
953 // BACKEND too — not just this request's runtime array. Use the
954 // backend's atomic add (Redis SET NX / memcached add) so two
955 // processes racing to add the same key behave correctly and the
956 // existing value is never clobbered. Falling back to the runtime
957 // check alone (the old behaviour) let process B overwrite a key
958 // process A had already stored. (FBS-82111 Bug 2)
959 if ( $this->is_persistent_group( $group ) && $this->conn ) {
960 try {
961 if ( is_object( $data ) ) {
962 $data = clone $data;
963 }
964 $payload = maybe_serialize( $data );
965 $stored = $this->conn->add( $id, $payload, (int) $expire );
966 if ( ! $stored ) {
967 return false; // key already exists in the backend.
968 }
969 $this->cache[ $id ] = $data;
970 return true;
971 } catch ( \Throwable $e ) {
972 // Backend hiccup — fall through to the runtime-only path so
973 // add() still works against the in-request array cache.
974 }
975 }
976
977 return $this->set( $key, $data, $group, $expire );
978 }
979
980 public function replace( $key, $data, $group = 'default', $expire = 0 ) {
981 $id = $this->full_key( $key, $group );
982 if ( ! isset( $this->cache[ $id ] ) && false === $this->get( $key, $group ) ) {
983 return false;
984 }
985 return $this->set( $key, $data, $group, $expire );
986 }
987
988 public function set( $key, $data, $group = 'default', $expire = 0 ) {
989 $id = $this->full_key( $key, $group );
990 if ( is_object( $data ) ) {
991 $data = clone $data;
992 }
993 $this->cache[ $id ] = $data;
994
995 if ( $this->is_persistent_group( $group ) ) {
996 $stored = false;
997 $threw = false;
998 try {
999 $payload = maybe_serialize( $data );
1000 if ( 'redis' === $this->backend ) {
1001 $stored = $expire > 0
1002 ? (bool) $this->conn->setex( $id, (int) $expire, $payload )
1003 : (bool) $this->conn->set( $id, $payload );
1004 } else {
1005 $stored = (bool) $this->conn->set( $id, $payload, (int) $expire );
1006 }
1007 } catch ( \Throwable $e ) {
1008 $threw = true;
1009 }
1010 if ( ! $stored ) {
1011 // The backend may still hold the PREVIOUS value for this
1012 // key (classic: memcached rejecting an alloptions blob
1013 // over its item-size limit). The DB now has the new value;
1014 // leaving the old one here would serve stale data to every
1015 // later request — e.g. a settings change confirmed over
1016 // REST/MCP that the dashboard never shows. Evict so
1017 // readers fall back to the database.
1018 try {
1019 $this->backend_delete( $id );
1020 } catch ( \Throwable $e ) {
1021 // Backend fully down → reads fail too, so no staleness.
1022 }
1023 }
1024 // Return value: the runtime cache always accepted the value, and
1025 // WP core's contract for wp_cache_set() is "was it cached",
1026 // which a persistent-backend refusal doesn't falsify — the
1027 // value is live for this request and the DB holds the truth
1028 // for later ones (we evicted the stale copy above). Some
1029 // callers treat false as "the write was lost" and retry or
1030 // bail, so report success and surface backend trouble through
1031 // the degraded flag instead.
1032 //
1033 // Exception: a THROWN backend is a hard failure we still
1034 // report as cached for the same reason — the runtime cache
1035 // holds it.
1036 if ( ! $stored ) {
1037 $this->degraded = true;
1038 }
1039 return true;
1040 }
1041 return true;
1042 }
1043
1044 public function get( $key, $group = 'default', $force = false, &$found = null ) {
1045 $id = $this->full_key( $key, $group );
1046
1047 if ( ! $force && isset( $this->cache[ $id ] ) ) {
1048 $found = true;
1049 ++$this->cache_hits;
1050 $val = $this->cache[ $id ];
1051 return is_object( $val ) ? clone $val : $val;
1052 }
1053
1054 if ( $this->is_persistent_group( $group ) ) {
1055 try {
1056 $raw = $this->conn->get( $id );
1057 if ( false !== $raw && null !== $raw ) {
1058 $val = maybe_unserialize( $raw );
1059 $this->cache[ $id ] = $val;
1060 $found = true;
1061 ++$this->cache_hits;
1062 return is_object( $val ) ? clone $val : $val;
1063 }
1064 } catch ( \Throwable $e ) {
1065 // fall through to miss
1066 }
1067 }
1068
1069 $found = false;
1070 ++$this->cache_misses;
1071 return false;
1072 }
1073
1074 public function get_multiple( $keys, $group = 'default', $force = false ) {
1075 $out = array();
1076 foreach ( (array) $keys as $key ) {
1077 $out[ $key ] = $this->get( $key, $group, $force );
1078 }
1079 return $out;
1080 }
1081
1082 public function delete( $key, $group = 'default' ) {
1083 $id = $this->full_key( $key, $group );
1084 unset( $this->cache[ $id ] );
1085 if ( $this->is_persistent_group( $group ) ) {
1086 try {
1087 return (bool) $this->backend_delete( $id );
1088 } catch ( \Throwable $e ) {
1089 return true;
1090 }
1091 }
1092 return true;
1093 }
1094
1095 public function incr( $key, $offset = 1, $group = 'default' ) {
1096 $id = $this->full_key( $key, $group );
1097 $offset = max( 0, (int) $offset );
1098 if ( $this->is_persistent_group( $group ) ) {
1099 try {
1100 $new = $this->backend_incr( $id, $offset );
1101 if ( false !== $new ) {
1102 $this->cache[ $id ] = (int) $new;
1103 return (int) $new;
1104 }
1105 } catch ( \Throwable $e ) {
1106 // fall through
1107 }
1108 }
1109 $val = isset( $this->cache[ $id ] ) ? (int) $this->cache[ $id ] : 0;
1110 $val = max( 0, $val + $offset );
1111 $this->cache[ $id ] = $val;
1112 return $val;
1113 }
1114
1115 public function decr( $key, $offset = 1, $group = 'default' ) {
1116 $id = $this->full_key( $key, $group );
1117 $offset = max( 0, (int) $offset );
1118 if ( $this->is_persistent_group( $group ) ) {
1119 try {
1120 $new = $this->backend_decr( $id, $offset );
1121 if ( false !== $new ) {
1122 $new = max( 0, (int) $new );
1123 $this->cache[ $id ] = $new;
1124 return $new;
1125 }
1126 } catch ( \Throwable $e ) {
1127 // fall through
1128 }
1129 }
1130 $val = isset( $this->cache[ $id ] ) ? (int) $this->cache[ $id ] : 0;
1131 $val = max( 0, $val - $offset );
1132 $this->cache[ $id ] = $val;
1133 return $val;
1134 }
1135
1136 public function flush() {
1137 $this->cache = array();
1138 if ( $this->persistent ) {
1139 try {
1140 return (bool) $this->backend_flush( );
1141 } catch ( \Throwable $e ) {
1142 return false;
1143 }
1144 }
1145 return true;
1146 }
1147
1148 // --- Backend dispatch ------------------------------------------
1149 // Normalises method-name differences across the four client kinds:
1150 // phpredis + our Redis_Client (redis backend), ext/memcached + our
1151 // Memcached_Client (memcached backend).
1152
1153 private function backend_delete( $id ) {
1154 if ( 'redis' === $this->backend ) {
1155 return $this->conn->del( $id );
1156 }
1157 return $this->conn->delete( $id );
1158 }
1159
1160 private function backend_incr( $id, $offset ) {
1161 if ( 'redis' === $this->backend ) {
1162 return $this->conn->incrBy( $id, $offset );
1163 }
1164 return 'builtin-memcached' === $this->client
1165 ? $this->conn->incr( $id, $offset )
1166 : $this->conn->increment( $id, $offset );
1167 }
1168
1169 private function backend_decr( $id, $offset ) {
1170 if ( 'redis' === $this->backend ) {
1171 return $this->conn->decrBy( $id, $offset );
1172 }
1173 return 'builtin-memcached' === $this->client
1174 ? $this->conn->decr( $id, $offset )
1175 : $this->conn->decrement( $id, $offset );
1176 }
1177
1178 private function backend_flush() {
1179 if ( 'redis' === $this->backend ) {
1180 // Scope the flush to THIS site's namespace (salt:*) instead of
1181 // FLUSHDB, which would wipe the entire Redis database —
1182 // including other sites / apps sharing the same DB index.
1183 // (FBS-83119)
1184 //
1185 // There is deliberately NO empty-salt fallback to FLUSHDB.
1186 // xspeed_oc_salt() always returns a non-empty value, but a
1187 // drop-in left over from an older version can still be the
1188 // object loaded for the request that runs the upgrade
1189 // migration — and that is exactly when a global flush would
1190 // destroy a neighbouring site's cache. An unsalted pattern is
1191 // scoped to nothing, so we bail rather than widen the blast
1192 // radius.
1193 if ( '' === $this->salt ) {
1194 return false;
1195 }
1196 return $this->delete_redis_pattern( $this->escape_glob( $this->salt ) . ':*' ) >= 0;
1197 }
1198
1199 // Memcached has no key enumeration, so flush_all() / flush() are
1200 // unavoidably SERVER-WIDE — they wipe every other site and app on
1201 // the same daemon. Bump this site's namespace generation instead:
1202 // every key is built through it (see full_key()), so incrementing
1203 // it orphans this site's entries and leaves everyone else's alone.
1204 // The orphans expire on their own under Memcached's LRU.
1205 return $this->bump_generation();
1206 }
1207
1208 /**
1209 * Advance this site's namespace generation, invalidating every key
1210 * built from it. Used as the Memcached flush primitive.
1211 *
1212 * @return bool
1213 */
1214 private function bump_generation() {
1215 $key = $this->generation_key();
1216 $new = null;
1217
1218 try {
1219 $new = 'builtin-memcached' === $this->client
1220 ? $this->conn->incr( $key, 1 )
1221 : $this->conn->increment( $key, 1 );
1222 } catch ( \Throwable $e ) {
1223 $new = false;
1224 }
1225
1226 // increment() fails when the counter does not exist — either it was
1227 // never seeded, or the daemon evicted it under LRU. Either way,
1228 // resuming from the CURRENT generation is what matters: seeding
1229 // back to a low number could land on a generation this site used
1230 // before and resurrect the keys this flush is meant to clear.
1231 // Jumping forward from the generation we resolved for this request
1232 // keeps the namespace monotonic across an eviction.
1233 if ( false === $new || null === $new ) {
1234 $next = $this->generation() + 1;
1235 try {
1236 // Store as a string: the bundled Memcached_Client types
1237 // this parameter `string`, and Memcached stores scalars as
1238 // strings regardless. (This file has no strict_types — it
1239 // must load standalone — so an int would be coerced rather
1240 // than rejected, but passing the right type keeps the two
1241 // clients behaving identically.)
1242 $this->conn->set( $key, (string) $next, 0 );
1243 $new = $next;
1244 } catch ( \Throwable $e ) {
1245 return false;
1246 }
1247 }
1248
1249 $this->generation = (int) $new;
1250 $this->persist_generation_floor( $this->generation );
1251 return true;
1252 }
1253
1254 /**
1255 * The counter key holding this site's namespace generation. Salted, so
1256 * each site owns its own counter on a shared daemon.
1257 *
1258 * @return string
1259 */
1260 private function generation_key() {
1261 // Deliberately OUTSIDE the `{salt}:` namespace: Redis flushes by
1262 // deleting everything matching `{salt}:*`, which would otherwise
1263 // sweep away this invalidation marker if a site were reconfigured
1264 // from memcached to redis and back.
1265 return $this->salt . '.xspeed-oc-gen';
1266 }
1267
1268 /**
1269 * The lowest generation this site may use, read from the database.
1270 *
1271 * Memcached can evict the counter at any time; the database cannot, so
1272 * this is what stops an eviction from silently rewinding the namespace
1273 * and resurrecting keys a Purge already cleared.
1274 *
1275 * Read straight through $wpdb rather than get_option(), because the
1276 * options API routes through this very cache and would recurse. Returns
1277 * 1 whenever the database is not available yet — the drop-in loads
1278 * before $wpdb exists, and on those early requests nothing has been
1279 * flushed anyway.
1280 *
1281 * @return int
1282 */
1283 /**
1284 * The options table holding the generation floor, or '' when the
1285 * database cannot be queried yet.
1286 *
1287 * Always the NETWORK-wide table. On multisite $wpdb->options points at
1288 * the current blog's table, but the salt and the generation have no
1289 * blog component — they namespace the whole install, global groups
1290 * included. Storing the floor per blog would let a purge on blog 2 go
1291 * unseen by blog 1, so an eviction there would rewind the namespace and
1292 * resurrect the shared blog-details / blog-lookup entries that are the
1293 * original bug.
1294 *
1295 * @param object $wpdb The database handle.
1296 * @return string Table name, or '' when unusable.
1297 */
1298 private function generation_table( $wpdb ) {
1299 if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'get_var' ) || ! method_exists( $wpdb, 'prepare' ) ) {
1300 return '';
1301 }
1302
1303 // During wp-admin/install.php and `wp core install` the drop-in is
1304 // already live while the options table does not exist yet. A query
1305 // then emits a database error that our try/catch cannot suppress,
1306 // because get_var() reports rather than throws.
1307 if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
1308 return '';
1309 }
1310
1311 $base = isset( $wpdb->base_prefix ) ? (string) $wpdb->base_prefix : '';
1312 if ( '' !== $base ) {
1313 return $base . 'options';
1314 }
1315
1316 return isset( $wpdb->options ) ? (string) $wpdb->options : '';
1317 }
1318
1319 private function generation_floor() {
1320 if ( null !== $this->generation_floor ) {
1321 return $this->generation_floor;
1322 }
1323
1324 $this->generation_floor = 1;
1325
1326 if ( 'memcached' !== $this->backend || ! isset( $GLOBALS['wpdb'] ) ) {
1327 return $this->generation_floor;
1328 }
1329
1330 $wpdb = $GLOBALS['wpdb'];
1331 $table = $this->generation_table( $wpdb );
1332 if ( '' === $table ) {
1333 return $this->generation_floor;
1334 }
1335
1336 try {
1337 $val = $wpdb->get_var(
1338 $wpdb->prepare(
1339 "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.
1340 self::GENERATION_OPTION
1341 )
1342 );
1343 if ( is_numeric( $val ) && (int) $val > 1 ) {
1344 $this->generation_floor = (int) $val;
1345 }
1346 } catch ( \Throwable $e ) {
1347 $this->generation_floor = 1;
1348 }
1349
1350 return $this->generation_floor;
1351 }
1352
1353 /**
1354 * Persist the generation as the new floor, so an eviction of the cached
1355 * counter cannot rewind past it.
1356 *
1357 * @param int $generation The generation just written.
1358 * @return void
1359 */
1360 private function persist_generation_floor( $generation ) {
1361 if ( 'memcached' !== $this->backend || ! isset( $GLOBALS['wpdb'] ) ) {
1362 return;
1363 }
1364
1365 $wpdb = $GLOBALS['wpdb'];
1366 $table = $this->generation_table( $wpdb );
1367 if ( '' === $table || ! method_exists( $wpdb, 'query' ) ) {
1368 return;
1369 }
1370
1371 try {
1372 // Upsert without the options API, which would recurse through
1373 // this cache. autoload='no' keeps it out of alloptions.
1374 $wpdb->query(
1375 $wpdb->prepare(
1376 "INSERT INTO {$table} (option_name, option_value, autoload)
1377 VALUES (%s, %s, 'no')
1378 ON DUPLICATE KEY UPDATE option_value = VALUES(option_value)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is derived from $wpdb, never user input.
1379 self::GENERATION_OPTION,
1380 (string) (int) $generation
1381 )
1382 );
1383 $this->generation_floor = (int) $generation;
1384 } catch ( \Throwable $e ) {
1385 // Best effort: the cached counter still carries the flush for
1386 // as long as it survives.
1387 return;
1388 }
1389 }
1390
1391 /**
1392 * Read this site's namespace generation, once per request.
1393 *
1394 * Only meaningful for Memcached, where flushing works by advancing the
1395 * generation rather than deleting keys. Redis deletes by pattern, so it
1396 * stays on generation 1 and its key shape is unchanged.
1397 *
1398 * @return int
1399 */
1400 private function generation() {
1401 if ( null !== $this->generation ) {
1402 return $this->generation;
1403 }
1404
1405 // The floor comes from the database, because the counter lives in
1406 // the cache it namespaces and Memcached can evict it under LRU.
1407 // Falling back to generation 1 after an eviction would re-expose
1408 // the keys an earlier Purge cleared, so the DB copy — which cannot
1409 // be evicted — is what the generation may never drop below.
1410 $this->generation = $this->generation_floor();
1411
1412 if ( 'memcached' === $this->backend && $this->persistent ) {
1413 try {
1414 $val = $this->conn->get( $this->generation_key() );
1415 if ( is_numeric( $val ) && (int) $val > $this->generation ) {
1416 $this->generation = (int) $val;
1417 }
1418 } catch ( \Throwable $e ) {
1419 // Unreadable counter — the floor still applies, so a
1420 // previous flush is never undone.
1421 $this->generation = max( 1, $this->generation );
1422 }
1423 }
1424
1425 return $this->generation;
1426 }
1427
1428 /**
1429 * Escape Redis glob metacharacters so a literal string matches only
1430 * itself inside a SCAN MATCH pattern.
1431 *
1432 * The salt and group name are interpolated into the flush patterns
1433 * below, and neither is guaranteed to be glob-safe: an explicit Cache
1434 * Key Prefix is whatever the user typed, and a host-pinned
1435 * WP_CACHE_KEY_SALT is whatever the host wrote (WordPress builds these
1436 * from the site URL, so punctuation is normal). Left unescaped, `*`,
1437 * `?` and `[...]` are wildcards — `wp_[dev]site_:*` matches a
1438 * NEIGHBOURING site's `wp_dsite_:*` keys, so purging one site deletes
1439 * another site's cache. A bare `*` prefix matches everything and
1440 * empties the whole Redis DB, which is the exact damage the scoped
1441 * flush exists to prevent.
1442 *
1443 * Redis's stringmatchlen() treats `\` as the escape character, so
1444 * backslash-prefixing each metacharacter makes it literal. `\` itself
1445 * is escaped first, or escaping the others would be undone.
1446 *
1447 * @param string $literal Text to match literally.
1448 * @return string Glob-safe form of $literal.
1449 */
1450 private function escape_glob( $literal ) {
1451 return str_replace(
1452 array( '\\', '*', '?', '[', ']' ),
1453 array( '\\\\', '\\*', '\\?', '\\[', '\\]' ),
1454 (string) $literal
1455 );
1456 }
1457
1458 /**
1459 * Delete every Redis key matching $pattern across both client kinds
1460 * (phpredis native scan + our pure-PHP Redis_Client). Returns the
1461 * count deleted, or -1 if the backend isn't redis. SCAN-based so it
1462 * never blocks the server the way KEYS would. (FBS-83119)
1463 *
1464 * Callers MUST pass any literal segment through escape_glob() — this
1465 * receives a finished pattern and cannot tell wildcard from data.
1466 */
1467 private function delete_redis_pattern( $pattern ) {
1468 if ( 'redis' !== $this->backend || ! $this->conn ) {
1469 return -1;
1470 }
1471 // Our pure-PHP client.
1472 if ( method_exists( $this->conn, 'delete_by_pattern' ) ) {
1473 return $this->conn->delete_by_pattern( $pattern );
1474 }
1475 // phpredis: iterate the SCAN cursor (setOption SCAN_RETRY keeps it
1476 // simple — scan() returns false when the cursor is exhausted).
1477 if ( $this->conn instanceof \Redis ) {
1478 $deleted = 0;
1479 $it = null;
1480 if ( defined( '\Redis::SCAN_RETRY' ) ) {
1481 $this->conn->setOption( \Redis::OPT_SCAN, \Redis::SCAN_RETRY );
1482 }
1483 do {
1484 $keys = $this->conn->scan( $it, $pattern, 500 );
1485 if ( is_array( $keys ) && ! empty( $keys ) ) {
1486 $deleted += (int) $this->conn->del( $keys );
1487 }
1488 } while ( $it > 0 );
1489 return $deleted;
1490 }
1491 return -1;
1492 }
1493
1494 /**
1495 * Load xSpeed's own Memcached_Client (pure-PHP) on demand, the same
1496 * way load_builtin_client() loads the Redis one.
1497 */
1498 private function load_builtin_memcached() {
1499 return $this->load_builtin( '\\XSpeed\\Memcached_Client', 'class-memcached-client.php' );
1500 }
1501
1502 public function flush_runtime() {
1503 $this->cache = array();
1504 return true;
1505 }
1506
1507 public function flush_group( $group ) {
1508 $group = $this->group( $group );
1509
1510 // Runtime copy first — drop every in-request entry for this group.
1511 $needle = $this->full_key( '', $group );
1512 foreach ( array_keys( $this->cache ) as $id ) {
1513 if ( 0 === strpos( $id, $needle ) ) {
1514 unset( $this->cache[ $id ] );
1515 }
1516 }
1517
1518 // Persistent store: actually evict the group's keys from Redis so a
1519 // targeted invalidation (core or third-party calling
1520 // wp_cache_flush_group) stops serving stale data — previously this
1521 // was a runtime-only no-op against the backend. The key layout is
1522 // salt:{prefix}:{group}:{key}, so match salt:*:{group}:* to cover
1523 // both blog-prefixed and global groups for this site's namespace.
1524 // (FBS-83119)
1525 if ( $this->is_persistent_group( $group ) && 'redis' === $this->backend && '' !== $this->salt ) {
1526 try {
1527 $this->delete_redis_pattern(
1528 $this->escape_glob( $this->salt ) . ':*:' . $this->escape_glob( $group ) . ':*'
1529 );
1530 } catch ( \Throwable $e ) {
1531 return false;
1532 }
1533 }
1534 return true;
1535 }
1536
1537 public function close() {
1538 if ( $this->persistent && $this->conn ) {
1539 try {
1540 if ( 'redis' === $this->backend ) {
1541 $this->conn->close();
1542 } else {
1543 $this->conn->quit();
1544 }
1545 } catch ( \Throwable $e ) {
1546 // ignore
1547 }
1548 }
1549 return true;
1550 }
1551
1552 // --- Group config ---------------------------------------------------
1553
1554 public function add_global_groups( $groups ) {
1555 foreach ( (array) $groups as $g ) {
1556 $this->global_groups[ $g ] = true;
1557 }
1558 }
1559
1560 public function add_non_persistent_groups( $groups ) {
1561 foreach ( (array) $groups as $g ) {
1562 $this->non_persistent_groups[ $g ] = true;
1563 }
1564 }
1565
1566 public function switch_to_blog( $blog_id ) {
1567 $this->blog_prefix = $this->multisite ? (int) $blog_id : 0;
1568 }
1569
1570 /** @return array{backend:string,persistent:bool,hits:int,misses:int} */
1571 public function stats() {
1572 return array(
1573 'backend' => $this->backend,
1574 'persistent' => $this->persistent,
1575 'hits' => $this->cache_hits,
1576 'misses' => $this->cache_misses,
1577 );
1578 }
1579 }
1580 }
1581