PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.0
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 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / object-cache.php

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

822 lines 28.3 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 try {
539 $payload = maybe_serialize( $data );
540 if ( 'redis' === $this->backend ) {
541 return $expire > 0
542 ? (bool) $this->conn->setex( $id, (int) $expire, $payload )
543 : (bool) $this->conn->set( $id, $payload );
544 }
545 return (bool) $this->conn->set( $id, $payload, (int) $expire );
546 } catch ( \Throwable $e ) {
547 return true; // runtime cache still set
548 }
549 }
550 return true;
551 }
552
553 public function get( $key, $group = 'default', $force = false, &$found = null ) {
554 $id = $this->full_key( $key, $group );
555
556 if ( ! $force && isset( $this->cache[ $id ] ) ) {
557 $found = true;
558 ++$this->cache_hits;
559 $val = $this->cache[ $id ];
560 return is_object( $val ) ? clone $val : $val;
561 }
562
563 if ( $this->is_persistent_group( $group ) ) {
564 try {
565 $raw = $this->conn->get( $id );
566 if ( false !== $raw && null !== $raw ) {
567 $val = maybe_unserialize( $raw );
568 $this->cache[ $id ] = $val;
569 $found = true;
570 ++$this->cache_hits;
571 return is_object( $val ) ? clone $val : $val;
572 }
573 } catch ( \Throwable $e ) {
574 // fall through to miss
575 }
576 }
577
578 $found = false;
579 ++$this->cache_misses;
580 return false;
581 }
582
583 public function get_multiple( $keys, $group = 'default', $force = false ) {
584 $out = array();
585 foreach ( (array) $keys as $key ) {
586 $out[ $key ] = $this->get( $key, $group, $force );
587 }
588 return $out;
589 }
590
591 public function delete( $key, $group = 'default' ) {
592 $id = $this->full_key( $key, $group );
593 unset( $this->cache[ $id ] );
594 if ( $this->is_persistent_group( $group ) ) {
595 try {
596 return (bool) $this->backend_delete( $id );
597 } catch ( \Throwable $e ) {
598 return true;
599 }
600 }
601 return true;
602 }
603
604 public function incr( $key, $offset = 1, $group = 'default' ) {
605 $id = $this->full_key( $key, $group );
606 $offset = max( 0, (int) $offset );
607 if ( $this->is_persistent_group( $group ) ) {
608 try {
609 $new = $this->backend_incr( $id, $offset );
610 if ( false !== $new ) {
611 $this->cache[ $id ] = (int) $new;
612 return (int) $new;
613 }
614 } catch ( \Throwable $e ) {
615 // fall through
616 }
617 }
618 $val = isset( $this->cache[ $id ] ) ? (int) $this->cache[ $id ] : 0;
619 $val = max( 0, $val + $offset );
620 $this->cache[ $id ] = $val;
621 return $val;
622 }
623
624 public function decr( $key, $offset = 1, $group = 'default' ) {
625 $id = $this->full_key( $key, $group );
626 $offset = max( 0, (int) $offset );
627 if ( $this->is_persistent_group( $group ) ) {
628 try {
629 $new = $this->backend_decr( $id, $offset );
630 if ( false !== $new ) {
631 $new = max( 0, (int) $new );
632 $this->cache[ $id ] = $new;
633 return $new;
634 }
635 } catch ( \Throwable $e ) {
636 // fall through
637 }
638 }
639 $val = isset( $this->cache[ $id ] ) ? (int) $this->cache[ $id ] : 0;
640 $val = max( 0, $val - $offset );
641 $this->cache[ $id ] = $val;
642 return $val;
643 }
644
645 public function flush() {
646 $this->cache = array();
647 if ( $this->persistent ) {
648 try {
649 return (bool) $this->backend_flush( );
650 } catch ( \Throwable $e ) {
651 return false;
652 }
653 }
654 return true;
655 }
656
657 // --- Backend dispatch ------------------------------------------
658 // Normalises method-name differences across the four client kinds:
659 // phpredis + our Redis_Client (redis backend), ext/memcached + our
660 // Memcached_Client (memcached backend).
661
662 private function backend_delete( $id ) {
663 if ( 'redis' === $this->backend ) {
664 return $this->conn->del( $id );
665 }
666 return $this->conn->delete( $id );
667 }
668
669 private function backend_incr( $id, $offset ) {
670 if ( 'redis' === $this->backend ) {
671 return $this->conn->incrBy( $id, $offset );
672 }
673 return 'builtin-memcached' === $this->client
674 ? $this->conn->incr( $id, $offset )
675 : $this->conn->increment( $id, $offset );
676 }
677
678 private function backend_decr( $id, $offset ) {
679 if ( 'redis' === $this->backend ) {
680 return $this->conn->decrBy( $id, $offset );
681 }
682 return 'builtin-memcached' === $this->client
683 ? $this->conn->decr( $id, $offset )
684 : $this->conn->decrement( $id, $offset );
685 }
686
687 private function backend_flush() {
688 if ( 'redis' === $this->backend ) {
689 // Scope the flush to THIS site's namespace (salt:*) instead of
690 // FLUSHDB, which would wipe the entire Redis database — including
691 // other sites / apps sharing the same DB index. Falls back to
692 // FLUSHDB only when no salt is configured (single-tenant) so
693 // behavior is unchanged on a dedicated Redis. (FBS-83119)
694 if ( '' === $this->salt ) {
695 return $this->conn->flushDB();
696 }
697 return $this->delete_redis_pattern( $this->salt . ':*' ) >= 0;
698 }
699 return 'builtin-memcached' === $this->client
700 ? $this->conn->flush_all()
701 : $this->conn->flush();
702 }
703
704 /**
705 * Delete every Redis key matching $pattern across both client kinds
706 * (phpredis native scan + our pure-PHP Redis_Client). Returns the
707 * count deleted, or -1 if the backend isn't redis. SCAN-based so it
708 * never blocks the server the way KEYS would. (FBS-83119)
709 */
710 private function delete_redis_pattern( $pattern ) {
711 if ( 'redis' !== $this->backend || ! $this->conn ) {
712 return -1;
713 }
714 // Our pure-PHP client.
715 if ( method_exists( $this->conn, 'delete_by_pattern' ) ) {
716 return $this->conn->delete_by_pattern( $pattern );
717 }
718 // phpredis: iterate the SCAN cursor (setOption SCAN_RETRY keeps it
719 // simple — scan() returns false when the cursor is exhausted).
720 if ( $this->conn instanceof \Redis ) {
721 $deleted = 0;
722 $it = null;
723 if ( defined( '\Redis::SCAN_RETRY' ) ) {
724 $this->conn->setOption( \Redis::OPT_SCAN, \Redis::SCAN_RETRY );
725 }
726 do {
727 $keys = $this->conn->scan( $it, $pattern, 500 );
728 if ( is_array( $keys ) && ! empty( $keys ) ) {
729 $deleted += (int) $this->conn->del( $keys );
730 }
731 } while ( $it > 0 );
732 return $deleted;
733 }
734 return -1;
735 }
736
737 /**
738 * Load xSpeed's own Memcached_Client (pure-PHP) on demand, the same
739 * way load_builtin_client() loads the Redis one.
740 */
741 private function load_builtin_memcached() {
742 return $this->load_builtin( '\\XSpeed\\Memcached_Client', 'class-memcached-client.php' );
743 }
744
745 public function flush_runtime() {
746 $this->cache = array();
747 return true;
748 }
749
750 public function flush_group( $group ) {
751 $group = $this->group( $group );
752
753 // Runtime copy first — drop every in-request entry for this group.
754 $needle = $this->full_key( '', $group );
755 foreach ( array_keys( $this->cache ) as $id ) {
756 if ( 0 === strpos( $id, $needle ) ) {
757 unset( $this->cache[ $id ] );
758 }
759 }
760
761 // Persistent store: actually evict the group's keys from Redis so a
762 // targeted invalidation (core or third-party calling
763 // wp_cache_flush_group) stops serving stale data — previously this
764 // was a runtime-only no-op against the backend. The key layout is
765 // salt:{prefix}:{group}:{key}, so match salt:*:{group}:* to cover
766 // both blog-prefixed and global groups for this site's namespace.
767 // (FBS-83119)
768 if ( $this->is_persistent_group( $group ) && 'redis' === $this->backend && '' !== $this->salt ) {
769 try {
770 $this->delete_redis_pattern( $this->salt . ':*:' . $group . ':*' );
771 } catch ( \Throwable $e ) {
772 return false;
773 }
774 }
775 return true;
776 }
777
778 public function close() {
779 if ( $this->persistent && $this->conn ) {
780 try {
781 if ( 'redis' === $this->backend ) {
782 $this->conn->close();
783 } else {
784 $this->conn->quit();
785 }
786 } catch ( \Throwable $e ) {
787 // ignore
788 }
789 }
790 return true;
791 }
792
793 // --- Group config ---------------------------------------------------
794
795 public function add_global_groups( $groups ) {
796 foreach ( (array) $groups as $g ) {
797 $this->global_groups[ $g ] = true;
798 }
799 }
800
801 public function add_non_persistent_groups( $groups ) {
802 foreach ( (array) $groups as $g ) {
803 $this->non_persistent_groups[ $g ] = true;
804 }
805 }
806
807 public function switch_to_blog( $blog_id ) {
808 $this->blog_prefix = $this->multisite ? (int) $blog_id : 0;
809 }
810
811 /** @return array{backend:string,persistent:bool,hits:int,misses:int} */
812 public function stats() {
813 return array(
814 'backend' => $this->backend,
815 'persistent' => $this->persistent,
816 'hits' => $this->cache_hits,
817 'misses' => $this->cache_misses,
818 );
819 }
820 }
821 }
822