PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.2 1.2.3
xspeed / includes / object-cache.php

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

855 lines 29.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 $map = array(
40 'backend' => array( 'XSPEED_OC_BACKEND' ),
41 'host' => array( 'XSPEED_OC_HOST', 'WP_REDIS_HOST' ),
42 'port' => array( 'XSPEED_OC_PORT', 'WP_REDIS_PORT' ),
43 'user' => array( 'XSPEED_OC_USER', 'WP_REDIS_USER' ),
44 'password' => array( 'XSPEED_OC_PASSWORD', 'WP_REDIS_PASSWORD' ),
45 'database' => array( 'XSPEED_OC_DATABASE', 'WP_REDIS_DATABASE' ),
46 'timeout' => array( 'XSPEED_OC_TIMEOUT', 'WP_REDIS_TIMEOUT' ),
47 'salt' => array( 'XSPEED_OC_SALT', 'WP_CACHE_KEY_SALT' ),
48 'persist' => array( 'XSPEED_OC_PERSISTENT', 'WP_REDIS_PERSISTENT' ),
49 );
50 if ( isset( $map[ $key ] ) ) {
51 foreach ( $map[ $key ] as $const ) {
52 if ( defined( $const ) ) {
53 return constant( $const );
54 }
55 }
56 }
57 return $default;
58 }
59 }
60
61 // -----------------------------------------------------------------------------
62 // WordPress object-cache API surface. Thin wrappers over the global instance.
63 // -----------------------------------------------------------------------------
64 if ( ! function_exists( 'wp_cache_init' ) ) {
65
66 function wp_cache_init() {
67 $GLOBALS['wp_object_cache'] = new XSpeed_Object_Cache();
68 }
69
70 function wp_cache_add( $key, $data, $group = '', $expire = 0 ) {
71 return $GLOBALS['wp_object_cache']->add( $key, $data, $group, (int) $expire );
72 }
73
74 function wp_cache_add_multiple( array $data, $group = '', $expire = 0 ) {
75 $out = array();
76 foreach ( $data as $key => $value ) {
77 $out[ $key ] = wp_cache_add( $key, $value, $group, $expire );
78 }
79 return $out;
80 }
81
82 function wp_cache_replace( $key, $data, $group = '', $expire = 0 ) {
83 return $GLOBALS['wp_object_cache']->replace( $key, $data, $group, (int) $expire );
84 }
85
86 function wp_cache_set( $key, $data, $group = '', $expire = 0 ) {
87 return $GLOBALS['wp_object_cache']->set( $key, $data, $group, (int) $expire );
88 }
89
90 function wp_cache_set_multiple( array $data, $group = '', $expire = 0 ) {
91 $out = array();
92 foreach ( $data as $key => $value ) {
93 $out[ $key ] = wp_cache_set( $key, $value, $group, $expire );
94 }
95 return $out;
96 }
97
98 function wp_cache_get( $key, $group = '', $force = false, &$found = null ) {
99 return $GLOBALS['wp_object_cache']->get( $key, $group, $force, $found );
100 }
101
102 function wp_cache_get_multiple( $keys, $group = '', $force = false ) {
103 return $GLOBALS['wp_object_cache']->get_multiple( $keys, $group, $force );
104 }
105
106 function wp_cache_delete( $key, $group = '' ) {
107 return $GLOBALS['wp_object_cache']->delete( $key, $group );
108 }
109
110 function wp_cache_delete_multiple( array $keys, $group = '' ) {
111 $out = array();
112 foreach ( $keys as $key ) {
113 $out[ $key ] = wp_cache_delete( $key, $group );
114 }
115 return $out;
116 }
117
118 function wp_cache_incr( $key, $offset = 1, $group = '' ) {
119 return $GLOBALS['wp_object_cache']->incr( $key, (int) $offset, $group );
120 }
121
122 function wp_cache_decr( $key, $offset = 1, $group = '' ) {
123 return $GLOBALS['wp_object_cache']->decr( $key, (int) $offset, $group );
124 }
125
126 function wp_cache_flush() {
127 return $GLOBALS['wp_object_cache']->flush();
128 }
129
130 function wp_cache_flush_runtime() {
131 return $GLOBALS['wp_object_cache']->flush_runtime();
132 }
133
134 function wp_cache_flush_group( $group ) {
135 return $GLOBALS['wp_object_cache']->flush_group( $group );
136 }
137
138 function wp_cache_supports( $feature ) {
139 return in_array( $feature, array( 'get_multiple', 'set_multiple', 'add_multiple', 'delete_multiple', 'flush_runtime', 'flush_group' ), true );
140 }
141
142 function wp_cache_close() {
143 return $GLOBALS['wp_object_cache']->close();
144 }
145
146 function wp_cache_add_global_groups( $groups ) {
147 $GLOBALS['wp_object_cache']->add_global_groups( $groups );
148 }
149
150 function wp_cache_add_non_persistent_groups( $groups ) {
151 $GLOBALS['wp_object_cache']->add_non_persistent_groups( $groups );
152 }
153
154 function wp_cache_switch_to_blog( $blog_id ) {
155 $GLOBALS['wp_object_cache']->switch_to_blog( (int) $blog_id );
156 }
157
158 function wp_cache_reset() {
159 // Deprecated in core; kept for back-compat.
160 return $GLOBALS['wp_object_cache']->flush_runtime();
161 }
162 }
163
164 // -----------------------------------------------------------------------------
165 // The cache implementation.
166 // -----------------------------------------------------------------------------
167 if ( ! class_exists( 'XSpeed_Object_Cache' ) ) {
168
169 class XSpeed_Object_Cache {
170
171 /** @var array In-request cache (always populated; also the fallback store). */
172 private $cache = array();
173
174 /** @var \Redis|\Memcached|null Persistent backend handle, or null when degraded. */
175 private $conn = null;
176
177 /** @var string redis|memcached */
178 private $backend = 'redis';
179
180 /** @var string Concrete client driving a Redis backend: phpredis|builtin. */
181 private $client = 'phpredis';
182
183 /** @var bool True once a persistent backend is connected. */
184 private $persistent = false;
185
186 /**
187 * @var bool True when the drop-in is active but could NOT connect a
188 * persistent backend, so it's silently serving a non-persistent
189 * in-request cache. Surfaced so the dashboard can report "degraded"
190 * instead of implying object caching is healthy. (FBS-82210)
191 */
192 public $degraded = false;
193
194 /** @var string Key salt / prefix. */
195 private $salt = '';
196
197 /** @var int Current blog id (multisite prefixing). */
198 private $blog_prefix = 0;
199
200 /** @var bool */
201 private $multisite = false;
202
203 /** @var array<string,bool> Groups shared across the whole network. */
204 private $global_groups = array();
205
206 /** @var array<string,bool> Groups that must never hit the persistent store. */
207 private $non_persistent_groups = array();
208
209 /** @var int Cache hits this request. */
210 public $cache_hits = 0;
211
212 /** @var int Cache misses this request. */
213 public $cache_misses = 0;
214
215 public function __construct() {
216 $this->multisite = function_exists( 'is_multisite' ) && is_multisite();
217 $this->blog_prefix = $this->multisite ? (int) get_current_blog_id() : 0;
218 $this->salt = (string) xspeed_oc_config( 'salt', '' );
219 $this->backend = strtolower( (string) xspeed_oc_config( 'backend', 'redis' ) );
220
221 // Non-persistent groups. We deliberately DO persist `options`
222 // (incl. the autoloaded `alloptions` blob), `comment`, and
223 // `counts` — these are the highest-volume, highest-hit groups,
224 // and excluding them was why the persistent cache stored only a
225 // fraction of the keys a mature object cache (e.g. Redis Object
226 // Cache) does. Redis Object Cache persists all of them by
227 // default; matching that is the whole point of the feature.
228 //
229 // The historical "can't deactivate a plugin" bug (FBS-82210) was
230 // a stale `alloptions` being read back after a plugin write. That
231 // is NOT solved by refusing to persist options — a correct cache
232 // solves it by invalidating on write, which WordPress core already
233 // does: update_option()/add_option()/delete_option() each call
234 // wp_cache_delete( 'alloptions', 'options' ). Our delete()
235 // propagates to the backend for every persistent group (see
236 // delete()), so the stale blob is removed the moment WP writes an
237 // option — deactivation stays correct WITH options persisted.
238 //
239 // `plugins` and `themes` remain non-persistent: they're tiny,
240 // rebuilt cheaply per request, and never worth a round trip.
241 $this->add_non_persistent_groups(
242 array( 'plugins', 'themes' )
243 );
244
245 $this->connect();
246 }
247
248 // --- Connection -----------------------------------------------------
249
250 private function connect() {
251 $timeout = (float) xspeed_oc_config( 'timeout', 1 );
252 try {
253 if ( 'memcached' === $this->backend ) {
254 $host = (string) xspeed_oc_config( 'host', '127.0.0.1' );
255 $port = (int) xspeed_oc_config( 'port', 11211 );
256
257 if ( class_exists( 'Memcached' ) ) {
258 // ext/memcached (preferred).
259 $this->client = 'ext-memcached';
260 $mc = new Memcached();
261 $mc->addServer( $host, $port );
262 $mc->setOption( Memcached::OPT_CONNECT_TIMEOUT, (int) ( $timeout * 1000 ) );
263 $stats = @$mc->getStats();
264 if ( is_array( $stats ) && ! empty( $stats ) ) {
265 $this->conn = $mc;
266 $this->persistent = true;
267 }
268 } elseif ( $this->load_builtin_memcached() ) {
269 // xSpeed's own pure-PHP Memcached client.
270 $this->client = 'builtin-memcached';
271 $mc = new \XSpeed\Memcached_Client( $host, $port, $timeout );
272 if ( $mc->connect() && false !== $mc->version() ) {
273 $this->conn = $mc;
274 $this->persistent = true;
275 }
276 }
277 } else {
278 $this->backend = 'redis';
279 $host = (string) xspeed_oc_config( 'host', '127.0.0.1' );
280 $port = (int) xspeed_oc_config( 'port', 6379 );
281 $user = (string) xspeed_oc_config( 'user', '' );
282 $pass = (string) xspeed_oc_config( 'password', '' );
283 $db = (int) xspeed_oc_config( 'database', 0 );
284 $persist = (bool) xspeed_oc_config( 'persist', false );
285
286 if ( class_exists( 'Redis' ) ) {
287 // phpredis extension (preferred).
288 $this->client = 'phpredis';
289 $redis = new Redis();
290 $ok = $persist
291 ? @$redis->pconnect( $host, $port, $timeout )
292 : @$redis->connect( $host, $port, $timeout );
293 if ( $ok ) {
294 // Redis 6+ ACL: ['user'=>..,'pass'=>..] when a username
295 // is configured; legacy password-only otherwise.
296 if ( '' !== $user ) {
297 @$redis->auth( array( 'user' => $user, 'pass' => $pass ) );
298 } elseif ( '' !== $pass ) {
299 @$redis->auth( $pass );
300 }
301 if ( $db > 0 ) {
302 @$redis->select( $db );
303 }
304 if ( '+PONG' === @$redis->ping() || true === @$redis->ping() ) {
305 $this->conn = $redis;
306 $this->persistent = true;
307 }
308 }
309 } elseif ( $this->load_builtin_client() ) {
310 // xSpeed's own pure-PHP client (no extension, no library).
311 $this->client = 'builtin';
312 $rc = new \XSpeed\Redis_Client( $host, $port, (float) $timeout, $persist );
313 if ( $rc->connect() ) {
314 if ( '' !== $pass || '' !== $user ) {
315 $rc->auth( $pass, $user );
316 }
317 if ( $db > 0 ) {
318 $rc->select( $db );
319 }
320 $pong = $rc->ping();
321 if ( is_string( $pong ) && false !== stripos( $pong, 'PONG' ) ) {
322 $this->conn = $rc;
323 $this->persistent = true;
324 }
325 }
326 }
327 }
328 } catch ( \Throwable $e ) {
329 // Any failure → stay in non-persistent mode. Never fatal.
330 $this->conn = null;
331 $this->persistent = false;
332 }
333
334 // Connecting to an unreachable/unresolvable backend (e.g.
335 // `Redis::pconnect()` → "getaddrinfo for redis failed", or
336 // `stream_socket_client()` in our builtin clients) emits a PHP
337 // warning. We `@`-suppress those above and degrade gracefully to a
338 // non-persistent cache — but the warning still lingers in
339 // `error_get_last()`. WP reads that at `admin_body_class` time and
340 // tags every admin page `php-error`, which renders an empty banner
341 // above the admin menu even though nothing is actually broken.
342 //
343 // Clear it so a degraded-but-handled backend doesn't masquerade as
344 // a site error — but ONLY when the lingering error is OUR connect
345 // warning. We never blindly wipe the slot: matching on the
346 // originating file (this drop-in, or our bundled socket clients)
347 // guarantees we can't swallow an unrelated warning that happened to
348 // land in error_get_last() first. This does NOT touch the error
349 // LOG — if WP_DEBUG_LOG is on, PHP already wrote the warning to
350 // debug.log before this runs, and the explicit "NOT persisting"
351 // diagnostic below is the signal meant for humans.
352 if ( function_exists( 'error_clear_last' ) ) {
353 $last = error_get_last();
354 if ( is_array( $last ) && isset( $last['file'] ) ) {
355 $file = $last['file'];
356 if ( __FILE__ === $file
357 || false !== strpos( $file, 'class-redis-client.php' )
358 || false !== strpos( $file, 'class-memcached-client.php' )
359 ) {
360 error_clear_last();
361 }
362 }
363 }
364
365 // The drop-in is installed (we're running), so if we didn't manage
366 // to connect a persistent backend, object caching is effectively
367 // doing nothing — writes succeed but evaporate at request end.
368 // Flag it so detect()/the dashboard can report "degraded" instead
369 // of a false-healthy state, and log once per request so the failure
370 // is diagnosable rather than silent. (FBS-82210)
371 if ( ! $this->persistent ) {
372 $this->degraded = true;
373 $should_log = function_exists( 'apply_filters' )
374 ? apply_filters( 'xspeed_object_cache_log_degraded', true )
375 : true;
376 // Diagnostic only, and only when debug logging is on — keeps
377 // the production error log quiet (Plugin Check flags an
378 // unconditional error_log()).
379 if ( $should_log && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
380 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated degraded-state diagnostic.
381 error_log( sprintf(
382 '[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.',
383 $this->backend,
384 $this->client
385 ) );
386 }
387 }
388 }
389
390 /**
391 * Whether a persistent backend is actually connected. False means the
392 * drop-in is degraded (non-persistent) — see $this->degraded.
393 */
394 public function is_persistent() {
395 return (bool) $this->persistent;
396 }
397
398 /** Concrete client in use: phpredis|builtin|ext-memcached|builtin-memcached|''. */
399 public function client_name() {
400 return $this->persistent ? (string) $this->client : '';
401 }
402
403 /**
404 * Load xSpeed's own Redis_Client on demand. The drop-in runs before
405 * the plugin's autoloader, so we require the class file directly from
406 * the plugin. Returns true once \XSpeed\Redis_Client is available.
407 */
408 private function load_builtin_client() {
409 return $this->load_builtin( '\\XSpeed\\Redis_Client', 'class-redis-client.php' );
410 }
411
412 /**
413 * Robustly locate + require one of xSpeed's bundled, extension-free
414 * clients. This is the linchpin of the "no extension to install"
415 * promise: on a host without phpredis/ext-memcached, the drop-in MUST
416 * be able to load this file or it silently degrades to a non-persistent
417 * cache (writes return true but never reach the backend). (FBS-82210)
418 *
419 * The original implementation only tried WP_PLUGIN_DIR — which fails
420 * when the plugin dir is symlinked, when WP_PLUGIN_DIR points somewhere
421 * unexpected, or when the constant isn't defined yet at drop-in load
422 * time. We add a __DIR__-relative candidate: the drop-in lives in
423 * wp-content/, and the plugin sits at wp-content/plugins/xspeed/includes/,
424 * so we can resolve the client relative to our own location regardless
425 * of how the plugin is mounted. realpath() also resolves symlinks.
426 *
427 * @param string $class Fully-qualified class name to check for.
428 * @param string $filename Client file under the plugin's includes/ dir.
429 * @return bool True once the class is available.
430 */
431 private function load_builtin( $class, $filename ) {
432 if ( class_exists( $class ) ) {
433 return true;
434 }
435
436 $candidates = array();
437 if ( defined( 'WP_PLUGIN_DIR' ) ) {
438 $candidates[] = WP_PLUGIN_DIR . '/xspeed/includes/' . $filename;
439 }
440 if ( defined( 'WP_CONTENT_DIR' ) ) {
441 $candidates[] = WP_CONTENT_DIR . '/plugins/xspeed/includes/' . $filename;
442 $candidates[] = WP_CONTENT_DIR . '/mu-plugins/xspeed/includes/' . $filename;
443 }
444 // __DIR__-relative: this file is wp-content/object-cache.php, so the
445 // plugin is a sibling under plugins/xspeed/ — survives symlinks and
446 // odd WP_PLUGIN_DIR values the candidates above don't.
447 $candidates[] = __DIR__ . '/plugins/xspeed/includes/' . $filename;
448
449 foreach ( $candidates as $path ) {
450 if ( ! $path ) {
451 continue;
452 }
453 $real = @realpath( $path );
454 $path = false !== $real ? $real : $path;
455 if ( file_exists( $path ) ) {
456 require_once $path;
457 if ( class_exists( $class ) ) {
458 return true;
459 }
460 }
461 }
462
463 return class_exists( $class );
464 }
465
466 // --- Key helpers ----------------------------------------------------
467
468 private function group( $group ) {
469 return '' === (string) $group ? 'default' : (string) $group;
470 }
471
472 private function full_key( $key, $group ) {
473 $group = $this->group( $group );
474 $prefix = isset( $this->global_groups[ $group ] ) ? 0 : $this->blog_prefix;
475 return $this->salt . ':' . $prefix . ':' . $group . ':' . $key;
476 }
477
478 private function is_persistent_group( $group ) {
479 return $this->persistent && ! isset( $this->non_persistent_groups[ $this->group( $group ) ] );
480 }
481
482 // --- Core ops -------------------------------------------------------
483
484 public function add( $key, $data, $group = 'default', $expire = 0 ) {
485 if ( wp_suspend_cache_addition() ) {
486 return false;
487 }
488 $id = $this->full_key( $key, $group );
489 // Present in THIS request's runtime cache → already added.
490 if ( isset( $this->cache[ $id ] ) ) {
491 return false;
492 }
493
494 // For persistent groups, add() must fail if the key exists in the
495 // BACKEND too — not just this request's runtime array. Use the
496 // backend's atomic add (Redis SET NX / memcached add) so two
497 // processes racing to add the same key behave correctly and the
498 // existing value is never clobbered. Falling back to the runtime
499 // check alone (the old behaviour) let process B overwrite a key
500 // process A had already stored. (FBS-82111 Bug 2)
501 if ( $this->is_persistent_group( $group ) && $this->conn ) {
502 try {
503 if ( is_object( $data ) ) {
504 $data = clone $data;
505 }
506 $payload = maybe_serialize( $data );
507 $stored = $this->conn->add( $id, $payload, (int) $expire );
508 if ( ! $stored ) {
509 return false; // key already exists in the backend.
510 }
511 $this->cache[ $id ] = $data;
512 return true;
513 } catch ( \Throwable $e ) {
514 // Backend hiccup — fall through to the runtime-only path so
515 // add() still works against the in-request array cache.
516 }
517 }
518
519 return $this->set( $key, $data, $group, $expire );
520 }
521
522 public function replace( $key, $data, $group = 'default', $expire = 0 ) {
523 $id = $this->full_key( $key, $group );
524 if ( ! isset( $this->cache[ $id ] ) && false === $this->get( $key, $group ) ) {
525 return false;
526 }
527 return $this->set( $key, $data, $group, $expire );
528 }
529
530 public function set( $key, $data, $group = 'default', $expire = 0 ) {
531 $id = $this->full_key( $key, $group );
532 if ( is_object( $data ) ) {
533 $data = clone $data;
534 }
535 $this->cache[ $id ] = $data;
536
537 if ( $this->is_persistent_group( $group ) ) {
538 $stored = false;
539 $threw = false;
540 try {
541 $payload = maybe_serialize( $data );
542 if ( 'redis' === $this->backend ) {
543 $stored = $expire > 0
544 ? (bool) $this->conn->setex( $id, (int) $expire, $payload )
545 : (bool) $this->conn->set( $id, $payload );
546 } else {
547 $stored = (bool) $this->conn->set( $id, $payload, (int) $expire );
548 }
549 } catch ( \Throwable $e ) {
550 $threw = true;
551 }
552 if ( ! $stored ) {
553 // The backend may still hold the PREVIOUS value for this
554 // key (classic: memcached rejecting an alloptions blob
555 // over its item-size limit). The DB now has the new value;
556 // leaving the old one here would serve stale data to every
557 // later request — e.g. a settings change confirmed over
558 // REST/MCP that the dashboard never shows. Evict so
559 // readers fall back to the database.
560 try {
561 $this->backend_delete( $id );
562 } catch ( \Throwable $e ) {
563 // Backend fully down → reads fail too, so no staleness.
564 }
565 }
566 // Return value: the runtime cache always accepted the value, and
567 // WP core's contract for wp_cache_set() is "was it cached",
568 // which a persistent-backend refusal doesn't falsify — the
569 // value is live for this request and the DB holds the truth
570 // for later ones (we evicted the stale copy above). Some
571 // callers treat false as "the write was lost" and retry or
572 // bail, so report success and surface backend trouble through
573 // the degraded flag instead.
574 //
575 // Exception: a THROWN backend is a hard failure we still
576 // report as cached for the same reason — the runtime cache
577 // holds it.
578 if ( ! $stored ) {
579 $this->degraded = true;
580 }
581 return true;
582 }
583 return true;
584 }
585
586 public function get( $key, $group = 'default', $force = false, &$found = null ) {
587 $id = $this->full_key( $key, $group );
588
589 if ( ! $force && isset( $this->cache[ $id ] ) ) {
590 $found = true;
591 ++$this->cache_hits;
592 $val = $this->cache[ $id ];
593 return is_object( $val ) ? clone $val : $val;
594 }
595
596 if ( $this->is_persistent_group( $group ) ) {
597 try {
598 $raw = $this->conn->get( $id );
599 if ( false !== $raw && null !== $raw ) {
600 $val = maybe_unserialize( $raw );
601 $this->cache[ $id ] = $val;
602 $found = true;
603 ++$this->cache_hits;
604 return is_object( $val ) ? clone $val : $val;
605 }
606 } catch ( \Throwable $e ) {
607 // fall through to miss
608 }
609 }
610
611 $found = false;
612 ++$this->cache_misses;
613 return false;
614 }
615
616 public function get_multiple( $keys, $group = 'default', $force = false ) {
617 $out = array();
618 foreach ( (array) $keys as $key ) {
619 $out[ $key ] = $this->get( $key, $group, $force );
620 }
621 return $out;
622 }
623
624 public function delete( $key, $group = 'default' ) {
625 $id = $this->full_key( $key, $group );
626 unset( $this->cache[ $id ] );
627 if ( $this->is_persistent_group( $group ) ) {
628 try {
629 return (bool) $this->backend_delete( $id );
630 } catch ( \Throwable $e ) {
631 return true;
632 }
633 }
634 return true;
635 }
636
637 public function incr( $key, $offset = 1, $group = 'default' ) {
638 $id = $this->full_key( $key, $group );
639 $offset = max( 0, (int) $offset );
640 if ( $this->is_persistent_group( $group ) ) {
641 try {
642 $new = $this->backend_incr( $id, $offset );
643 if ( false !== $new ) {
644 $this->cache[ $id ] = (int) $new;
645 return (int) $new;
646 }
647 } catch ( \Throwable $e ) {
648 // fall through
649 }
650 }
651 $val = isset( $this->cache[ $id ] ) ? (int) $this->cache[ $id ] : 0;
652 $val = max( 0, $val + $offset );
653 $this->cache[ $id ] = $val;
654 return $val;
655 }
656
657 public function decr( $key, $offset = 1, $group = 'default' ) {
658 $id = $this->full_key( $key, $group );
659 $offset = max( 0, (int) $offset );
660 if ( $this->is_persistent_group( $group ) ) {
661 try {
662 $new = $this->backend_decr( $id, $offset );
663 if ( false !== $new ) {
664 $new = max( 0, (int) $new );
665 $this->cache[ $id ] = $new;
666 return $new;
667 }
668 } catch ( \Throwable $e ) {
669 // fall through
670 }
671 }
672 $val = isset( $this->cache[ $id ] ) ? (int) $this->cache[ $id ] : 0;
673 $val = max( 0, $val - $offset );
674 $this->cache[ $id ] = $val;
675 return $val;
676 }
677
678 public function flush() {
679 $this->cache = array();
680 if ( $this->persistent ) {
681 try {
682 return (bool) $this->backend_flush( );
683 } catch ( \Throwable $e ) {
684 return false;
685 }
686 }
687 return true;
688 }
689
690 // --- Backend dispatch ------------------------------------------
691 // Normalises method-name differences across the four client kinds:
692 // phpredis + our Redis_Client (redis backend), ext/memcached + our
693 // Memcached_Client (memcached backend).
694
695 private function backend_delete( $id ) {
696 if ( 'redis' === $this->backend ) {
697 return $this->conn->del( $id );
698 }
699 return $this->conn->delete( $id );
700 }
701
702 private function backend_incr( $id, $offset ) {
703 if ( 'redis' === $this->backend ) {
704 return $this->conn->incrBy( $id, $offset );
705 }
706 return 'builtin-memcached' === $this->client
707 ? $this->conn->incr( $id, $offset )
708 : $this->conn->increment( $id, $offset );
709 }
710
711 private function backend_decr( $id, $offset ) {
712 if ( 'redis' === $this->backend ) {
713 return $this->conn->decrBy( $id, $offset );
714 }
715 return 'builtin-memcached' === $this->client
716 ? $this->conn->decr( $id, $offset )
717 : $this->conn->decrement( $id, $offset );
718 }
719
720 private function backend_flush() {
721 if ( 'redis' === $this->backend ) {
722 // Scope the flush to THIS site's namespace (salt:*) instead of
723 // FLUSHDB, which would wipe the entire Redis database — including
724 // other sites / apps sharing the same DB index. Falls back to
725 // FLUSHDB only when no salt is configured (single-tenant) so
726 // behavior is unchanged on a dedicated Redis. (FBS-83119)
727 if ( '' === $this->salt ) {
728 return $this->conn->flushDB();
729 }
730 return $this->delete_redis_pattern( $this->salt . ':*' ) >= 0;
731 }
732 return 'builtin-memcached' === $this->client
733 ? $this->conn->flush_all()
734 : $this->conn->flush();
735 }
736
737 /**
738 * Delete every Redis key matching $pattern across both client kinds
739 * (phpredis native scan + our pure-PHP Redis_Client). Returns the
740 * count deleted, or -1 if the backend isn't redis. SCAN-based so it
741 * never blocks the server the way KEYS would. (FBS-83119)
742 */
743 private function delete_redis_pattern( $pattern ) {
744 if ( 'redis' !== $this->backend || ! $this->conn ) {
745 return -1;
746 }
747 // Our pure-PHP client.
748 if ( method_exists( $this->conn, 'delete_by_pattern' ) ) {
749 return $this->conn->delete_by_pattern( $pattern );
750 }
751 // phpredis: iterate the SCAN cursor (setOption SCAN_RETRY keeps it
752 // simple — scan() returns false when the cursor is exhausted).
753 if ( $this->conn instanceof \Redis ) {
754 $deleted = 0;
755 $it = null;
756 if ( defined( '\Redis::SCAN_RETRY' ) ) {
757 $this->conn->setOption( \Redis::OPT_SCAN, \Redis::SCAN_RETRY );
758 }
759 do {
760 $keys = $this->conn->scan( $it, $pattern, 500 );
761 if ( is_array( $keys ) && ! empty( $keys ) ) {
762 $deleted += (int) $this->conn->del( $keys );
763 }
764 } while ( $it > 0 );
765 return $deleted;
766 }
767 return -1;
768 }
769
770 /**
771 * Load xSpeed's own Memcached_Client (pure-PHP) on demand, the same
772 * way load_builtin_client() loads the Redis one.
773 */
774 private function load_builtin_memcached() {
775 return $this->load_builtin( '\\XSpeed\\Memcached_Client', 'class-memcached-client.php' );
776 }
777
778 public function flush_runtime() {
779 $this->cache = array();
780 return true;
781 }
782
783 public function flush_group( $group ) {
784 $group = $this->group( $group );
785
786 // Runtime copy first — drop every in-request entry for this group.
787 $needle = $this->full_key( '', $group );
788 foreach ( array_keys( $this->cache ) as $id ) {
789 if ( 0 === strpos( $id, $needle ) ) {
790 unset( $this->cache[ $id ] );
791 }
792 }
793
794 // Persistent store: actually evict the group's keys from Redis so a
795 // targeted invalidation (core or third-party calling
796 // wp_cache_flush_group) stops serving stale data — previously this
797 // was a runtime-only no-op against the backend. The key layout is
798 // salt:{prefix}:{group}:{key}, so match salt:*:{group}:* to cover
799 // both blog-prefixed and global groups for this site's namespace.
800 // (FBS-83119)
801 if ( $this->is_persistent_group( $group ) && 'redis' === $this->backend && '' !== $this->salt ) {
802 try {
803 $this->delete_redis_pattern( $this->salt . ':*:' . $group . ':*' );
804 } catch ( \Throwable $e ) {
805 return false;
806 }
807 }
808 return true;
809 }
810
811 public function close() {
812 if ( $this->persistent && $this->conn ) {
813 try {
814 if ( 'redis' === $this->backend ) {
815 $this->conn->close();
816 } else {
817 $this->conn->quit();
818 }
819 } catch ( \Throwable $e ) {
820 // ignore
821 }
822 }
823 return true;
824 }
825
826 // --- Group config ---------------------------------------------------
827
828 public function add_global_groups( $groups ) {
829 foreach ( (array) $groups as $g ) {
830 $this->global_groups[ $g ] = true;
831 }
832 }
833
834 public function add_non_persistent_groups( $groups ) {
835 foreach ( (array) $groups as $g ) {
836 $this->non_persistent_groups[ $g ] = true;
837 }
838 }
839
840 public function switch_to_blog( $blog_id ) {
841 $this->blog_prefix = $this->multisite ? (int) $blog_id : 0;
842 }
843
844 /** @return array{backend:string,persistent:bool,hits:int,misses:int} */
845 public function stats() {
846 return array(
847 'backend' => $this->backend,
848 'persistent' => $this->persistent,
849 'hits' => $this->cache_hits,
850 'misses' => $this->cache_misses,
851 );
852 }
853 }
854 }
855