PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / object-cache.php

object-cache.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.2.0, 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