PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.7
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.0.7, at includes/object-cache.php

812 lines 27.8 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 // Core groups that must never be served from the shared
222 // persistent backend, or a stale copy can resurrect data WP
223 // just wrote. The critical one is `options`: WordPress keeps
224 // `active_plugins` inside the autoloaded `alloptions` blob, so
225 // a stale persistent `alloptions` makes a just-deactivated
226 // plugin read as still active (the write succeeds, the
227 // verify-read sees the old blob) — plugins then "can't be
228 // deactivated". These groups load once per request from the DB
229 // (still fast), kept per-request only. Same default set Redis
230 // Object Cache and friends ship.
231 $this->add_non_persistent_groups(
232 array( 'options', 'site-options', 'comment', 'counts', 'plugins', 'themes' )
233 );
234
235 $this->connect();
236 }
237
238 // --- Connection -----------------------------------------------------
239
240 private function connect() {
241 $timeout = (float) xspeed_oc_config( 'timeout', 1 );
242 try {
243 if ( 'memcached' === $this->backend ) {
244 $host = (string) xspeed_oc_config( 'host', '127.0.0.1' );
245 $port = (int) xspeed_oc_config( 'port', 11211 );
246
247 if ( class_exists( 'Memcached' ) ) {
248 // ext/memcached (preferred).
249 $this->client = 'ext-memcached';
250 $mc = new Memcached();
251 $mc->addServer( $host, $port );
252 $mc->setOption( Memcached::OPT_CONNECT_TIMEOUT, (int) ( $timeout * 1000 ) );
253 $stats = @$mc->getStats();
254 if ( is_array( $stats ) && ! empty( $stats ) ) {
255 $this->conn = $mc;
256 $this->persistent = true;
257 }
258 } elseif ( $this->load_builtin_memcached() ) {
259 // xSpeed's own pure-PHP Memcached client.
260 $this->client = 'builtin-memcached';
261 $mc = new \XSpeed\Memcached_Client( $host, $port, $timeout );
262 if ( $mc->connect() && false !== $mc->version() ) {
263 $this->conn = $mc;
264 $this->persistent = true;
265 }
266 }
267 } else {
268 $this->backend = 'redis';
269 $host = (string) xspeed_oc_config( 'host', '127.0.0.1' );
270 $port = (int) xspeed_oc_config( 'port', 6379 );
271 $user = (string) xspeed_oc_config( 'user', '' );
272 $pass = (string) xspeed_oc_config( 'password', '' );
273 $db = (int) xspeed_oc_config( 'database', 0 );
274 $persist = (bool) xspeed_oc_config( 'persist', false );
275
276 if ( class_exists( 'Redis' ) ) {
277 // phpredis extension (preferred).
278 $this->client = 'phpredis';
279 $redis = new Redis();
280 $ok = $persist
281 ? @$redis->pconnect( $host, $port, $timeout )
282 : @$redis->connect( $host, $port, $timeout );
283 if ( $ok ) {
284 // Redis 6+ ACL: ['user'=>..,'pass'=>..] when a username
285 // is configured; legacy password-only otherwise.
286 if ( '' !== $user ) {
287 @$redis->auth( array( 'user' => $user, 'pass' => $pass ) );
288 } elseif ( '' !== $pass ) {
289 @$redis->auth( $pass );
290 }
291 if ( $db > 0 ) {
292 @$redis->select( $db );
293 }
294 if ( '+PONG' === @$redis->ping() || true === @$redis->ping() ) {
295 $this->conn = $redis;
296 $this->persistent = true;
297 }
298 }
299 } elseif ( $this->load_builtin_client() ) {
300 // xSpeed's own pure-PHP client (no extension, no library).
301 $this->client = 'builtin';
302 $rc = new \XSpeed\Redis_Client( $host, $port, (float) $timeout, $persist );
303 if ( $rc->connect() ) {
304 if ( '' !== $pass || '' !== $user ) {
305 $rc->auth( $pass, $user );
306 }
307 if ( $db > 0 ) {
308 $rc->select( $db );
309 }
310 $pong = $rc->ping();
311 if ( is_string( $pong ) && false !== stripos( $pong, 'PONG' ) ) {
312 $this->conn = $rc;
313 $this->persistent = true;
314 }
315 }
316 }
317 }
318 } catch ( \Throwable $e ) {
319 // Any failure → stay in non-persistent mode. Never fatal.
320 $this->conn = null;
321 $this->persistent = false;
322 }
323
324 // Connecting to an unreachable/unresolvable backend (e.g.
325 // `Redis::pconnect()` → "getaddrinfo for redis failed", or
326 // `stream_socket_client()` in our builtin clients) emits a PHP
327 // warning. We `@`-suppress those above and degrade gracefully to a
328 // non-persistent cache — but the warning still lingers in
329 // `error_get_last()`. WP reads that at `admin_body_class` time and
330 // tags every admin page `php-error`, which renders an empty banner
331 // above the admin menu even though nothing is actually broken.
332 //
333 // Clear it so a degraded-but-handled backend doesn't masquerade as
334 // a site error — but ONLY when the lingering error is OUR connect
335 // warning. We never blindly wipe the slot: matching on the
336 // originating file (this drop-in, or our bundled socket clients)
337 // guarantees we can't swallow an unrelated warning that happened to
338 // land in error_get_last() first. This does NOT touch the error
339 // LOG — if WP_DEBUG_LOG is on, PHP already wrote the warning to
340 // debug.log before this runs, and the explicit "NOT persisting"
341 // diagnostic below is the signal meant for humans.
342 if ( function_exists( 'error_clear_last' ) ) {
343 $last = error_get_last();
344 if ( is_array( $last ) && isset( $last['file'] ) ) {
345 $file = $last['file'];
346 if ( __FILE__ === $file
347 || false !== strpos( $file, 'class-redis-client.php' )
348 || false !== strpos( $file, 'class-memcached-client.php' )
349 ) {
350 error_clear_last();
351 }
352 }
353 }
354
355 // The drop-in is installed (we're running), so if we didn't manage
356 // to connect a persistent backend, object caching is effectively
357 // doing nothing — writes succeed but evaporate at request end.
358 // Flag it so detect()/the dashboard can report "degraded" instead
359 // of a false-healthy state, and log once per request so the failure
360 // is diagnosable rather than silent. (FBS-82210)
361 if ( ! $this->persistent ) {
362 $this->degraded = true;
363 $should_log = function_exists( 'apply_filters' )
364 ? apply_filters( 'xspeed_object_cache_log_degraded', true )
365 : true;
366 // Diagnostic only, and only when debug logging is on — keeps
367 // the production error log quiet (Plugin Check flags an
368 // unconditional error_log()).
369 if ( $should_log && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
370 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated degraded-state diagnostic.
371 error_log( sprintf(
372 '[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.',
373 $this->backend,
374 $this->client
375 ) );
376 }
377 }
378 }
379
380 /**
381 * Whether a persistent backend is actually connected. False means the
382 * drop-in is degraded (non-persistent) — see $this->degraded.
383 */
384 public function is_persistent() {
385 return (bool) $this->persistent;
386 }
387
388 /** Concrete client in use: phpredis|builtin|ext-memcached|builtin-memcached|''. */
389 public function client_name() {
390 return $this->persistent ? (string) $this->client : '';
391 }
392
393 /**
394 * Load xSpeed's own Redis_Client on demand. The drop-in runs before
395 * the plugin's autoloader, so we require the class file directly from
396 * the plugin. Returns true once \XSpeed\Redis_Client is available.
397 */
398 private function load_builtin_client() {
399 return $this->load_builtin( '\\XSpeed\\Redis_Client', 'class-redis-client.php' );
400 }
401
402 /**
403 * Robustly locate + require one of xSpeed's bundled, extension-free
404 * clients. This is the linchpin of the "no extension to install"
405 * promise: on a host without phpredis/ext-memcached, the drop-in MUST
406 * be able to load this file or it silently degrades to a non-persistent
407 * cache (writes return true but never reach the backend). (FBS-82210)
408 *
409 * The original implementation only tried WP_PLUGIN_DIR — which fails
410 * when the plugin dir is symlinked, when WP_PLUGIN_DIR points somewhere
411 * unexpected, or when the constant isn't defined yet at drop-in load
412 * time. We add a __DIR__-relative candidate: the drop-in lives in
413 * wp-content/, and the plugin sits at wp-content/plugins/xspeed/includes/,
414 * so we can resolve the client relative to our own location regardless
415 * of how the plugin is mounted. realpath() also resolves symlinks.
416 *
417 * @param string $class Fully-qualified class name to check for.
418 * @param string $filename Client file under the plugin's includes/ dir.
419 * @return bool True once the class is available.
420 */
421 private function load_builtin( $class, $filename ) {
422 if ( class_exists( $class ) ) {
423 return true;
424 }
425
426 $candidates = array();
427 if ( defined( 'WP_PLUGIN_DIR' ) ) {
428 $candidates[] = WP_PLUGIN_DIR . '/xspeed/includes/' . $filename;
429 }
430 if ( defined( 'WP_CONTENT_DIR' ) ) {
431 $candidates[] = WP_CONTENT_DIR . '/plugins/xspeed/includes/' . $filename;
432 $candidates[] = WP_CONTENT_DIR . '/mu-plugins/xspeed/includes/' . $filename;
433 }
434 // __DIR__-relative: this file is wp-content/object-cache.php, so the
435 // plugin is a sibling under plugins/xspeed/ — survives symlinks and
436 // odd WP_PLUGIN_DIR values the candidates above don't.
437 $candidates[] = __DIR__ . '/plugins/xspeed/includes/' . $filename;
438
439 foreach ( $candidates as $path ) {
440 if ( ! $path ) {
441 continue;
442 }
443 $real = @realpath( $path );
444 $path = false !== $real ? $real : $path;
445 if ( file_exists( $path ) ) {
446 require_once $path;
447 if ( class_exists( $class ) ) {
448 return true;
449 }
450 }
451 }
452
453 return class_exists( $class );
454 }
455
456 // --- Key helpers ----------------------------------------------------
457
458 private function group( $group ) {
459 return '' === (string) $group ? 'default' : (string) $group;
460 }
461
462 private function full_key( $key, $group ) {
463 $group = $this->group( $group );
464 $prefix = isset( $this->global_groups[ $group ] ) ? 0 : $this->blog_prefix;
465 return $this->salt . ':' . $prefix . ':' . $group . ':' . $key;
466 }
467
468 private function is_persistent_group( $group ) {
469 return $this->persistent && ! isset( $this->non_persistent_groups[ $this->group( $group ) ] );
470 }
471
472 // --- Core ops -------------------------------------------------------
473
474 public function add( $key, $data, $group = 'default', $expire = 0 ) {
475 if ( wp_suspend_cache_addition() ) {
476 return false;
477 }
478 $id = $this->full_key( $key, $group );
479 // Present in THIS request's runtime cache → already added.
480 if ( isset( $this->cache[ $id ] ) ) {
481 return false;
482 }
483
484 // For persistent groups, add() must fail if the key exists in the
485 // BACKEND too — not just this request's runtime array. Use the
486 // backend's atomic add (Redis SET NX / memcached add) so two
487 // processes racing to add the same key behave correctly and the
488 // existing value is never clobbered. Falling back to the runtime
489 // check alone (the old behaviour) let process B overwrite a key
490 // process A had already stored. (FBS-82111 Bug 2)
491 if ( $this->is_persistent_group( $group ) && $this->conn ) {
492 try {
493 if ( is_object( $data ) ) {
494 $data = clone $data;
495 }
496 $payload = maybe_serialize( $data );
497 $stored = $this->conn->add( $id, $payload, (int) $expire );
498 if ( ! $stored ) {
499 return false; // key already exists in the backend.
500 }
501 $this->cache[ $id ] = $data;
502 return true;
503 } catch ( \Throwable $e ) {
504 // Backend hiccup — fall through to the runtime-only path so
505 // add() still works against the in-request array cache.
506 }
507 }
508
509 return $this->set( $key, $data, $group, $expire );
510 }
511
512 public function replace( $key, $data, $group = 'default', $expire = 0 ) {
513 $id = $this->full_key( $key, $group );
514 if ( ! isset( $this->cache[ $id ] ) && false === $this->get( $key, $group ) ) {
515 return false;
516 }
517 return $this->set( $key, $data, $group, $expire );
518 }
519
520 public function set( $key, $data, $group = 'default', $expire = 0 ) {
521 $id = $this->full_key( $key, $group );
522 if ( is_object( $data ) ) {
523 $data = clone $data;
524 }
525 $this->cache[ $id ] = $data;
526
527 if ( $this->is_persistent_group( $group ) ) {
528 try {
529 $payload = maybe_serialize( $data );
530 if ( 'redis' === $this->backend ) {
531 return $expire > 0
532 ? (bool) $this->conn->setex( $id, (int) $expire, $payload )
533 : (bool) $this->conn->set( $id, $payload );
534 }
535 return (bool) $this->conn->set( $id, $payload, (int) $expire );
536 } catch ( \Throwable $e ) {
537 return true; // runtime cache still set
538 }
539 }
540 return true;
541 }
542
543 public function get( $key, $group = 'default', $force = false, &$found = null ) {
544 $id = $this->full_key( $key, $group );
545
546 if ( ! $force && isset( $this->cache[ $id ] ) ) {
547 $found = true;
548 ++$this->cache_hits;
549 $val = $this->cache[ $id ];
550 return is_object( $val ) ? clone $val : $val;
551 }
552
553 if ( $this->is_persistent_group( $group ) ) {
554 try {
555 $raw = $this->conn->get( $id );
556 if ( false !== $raw && null !== $raw ) {
557 $val = maybe_unserialize( $raw );
558 $this->cache[ $id ] = $val;
559 $found = true;
560 ++$this->cache_hits;
561 return is_object( $val ) ? clone $val : $val;
562 }
563 } catch ( \Throwable $e ) {
564 // fall through to miss
565 }
566 }
567
568 $found = false;
569 ++$this->cache_misses;
570 return false;
571 }
572
573 public function get_multiple( $keys, $group = 'default', $force = false ) {
574 $out = array();
575 foreach ( (array) $keys as $key ) {
576 $out[ $key ] = $this->get( $key, $group, $force );
577 }
578 return $out;
579 }
580
581 public function delete( $key, $group = 'default' ) {
582 $id = $this->full_key( $key, $group );
583 unset( $this->cache[ $id ] );
584 if ( $this->is_persistent_group( $group ) ) {
585 try {
586 return (bool) $this->backend_delete( $id );
587 } catch ( \Throwable $e ) {
588 return true;
589 }
590 }
591 return true;
592 }
593
594 public function incr( $key, $offset = 1, $group = 'default' ) {
595 $id = $this->full_key( $key, $group );
596 $offset = max( 0, (int) $offset );
597 if ( $this->is_persistent_group( $group ) ) {
598 try {
599 $new = $this->backend_incr( $id, $offset );
600 if ( false !== $new ) {
601 $this->cache[ $id ] = (int) $new;
602 return (int) $new;
603 }
604 } catch ( \Throwable $e ) {
605 // fall through
606 }
607 }
608 $val = isset( $this->cache[ $id ] ) ? (int) $this->cache[ $id ] : 0;
609 $val = max( 0, $val + $offset );
610 $this->cache[ $id ] = $val;
611 return $val;
612 }
613
614 public function decr( $key, $offset = 1, $group = 'default' ) {
615 $id = $this->full_key( $key, $group );
616 $offset = max( 0, (int) $offset );
617 if ( $this->is_persistent_group( $group ) ) {
618 try {
619 $new = $this->backend_decr( $id, $offset );
620 if ( false !== $new ) {
621 $new = max( 0, (int) $new );
622 $this->cache[ $id ] = $new;
623 return $new;
624 }
625 } catch ( \Throwable $e ) {
626 // fall through
627 }
628 }
629 $val = isset( $this->cache[ $id ] ) ? (int) $this->cache[ $id ] : 0;
630 $val = max( 0, $val - $offset );
631 $this->cache[ $id ] = $val;
632 return $val;
633 }
634
635 public function flush() {
636 $this->cache = array();
637 if ( $this->persistent ) {
638 try {
639 return (bool) $this->backend_flush( );
640 } catch ( \Throwable $e ) {
641 return false;
642 }
643 }
644 return true;
645 }
646
647 // --- Backend dispatch ------------------------------------------
648 // Normalises method-name differences across the four client kinds:
649 // phpredis + our Redis_Client (redis backend), ext/memcached + our
650 // Memcached_Client (memcached backend).
651
652 private function backend_delete( $id ) {
653 if ( 'redis' === $this->backend ) {
654 return $this->conn->del( $id );
655 }
656 return $this->conn->delete( $id );
657 }
658
659 private function backend_incr( $id, $offset ) {
660 if ( 'redis' === $this->backend ) {
661 return $this->conn->incrBy( $id, $offset );
662 }
663 return 'builtin-memcached' === $this->client
664 ? $this->conn->incr( $id, $offset )
665 : $this->conn->increment( $id, $offset );
666 }
667
668 private function backend_decr( $id, $offset ) {
669 if ( 'redis' === $this->backend ) {
670 return $this->conn->decrBy( $id, $offset );
671 }
672 return 'builtin-memcached' === $this->client
673 ? $this->conn->decr( $id, $offset )
674 : $this->conn->decrement( $id, $offset );
675 }
676
677 private function backend_flush() {
678 if ( 'redis' === $this->backend ) {
679 // Scope the flush to THIS site's namespace (salt:*) instead of
680 // FLUSHDB, which would wipe the entire Redis database — including
681 // other sites / apps sharing the same DB index. Falls back to
682 // FLUSHDB only when no salt is configured (single-tenant) so
683 // behavior is unchanged on a dedicated Redis. (FBS-83119)
684 if ( '' === $this->salt ) {
685 return $this->conn->flushDB();
686 }
687 return $this->delete_redis_pattern( $this->salt . ':*' ) >= 0;
688 }
689 return 'builtin-memcached' === $this->client
690 ? $this->conn->flush_all()
691 : $this->conn->flush();
692 }
693
694 /**
695 * Delete every Redis key matching $pattern across both client kinds
696 * (phpredis native scan + our pure-PHP Redis_Client). Returns the
697 * count deleted, or -1 if the backend isn't redis. SCAN-based so it
698 * never blocks the server the way KEYS would. (FBS-83119)
699 */
700 private function delete_redis_pattern( $pattern ) {
701 if ( 'redis' !== $this->backend || ! $this->conn ) {
702 return -1;
703 }
704 // Our pure-PHP client.
705 if ( method_exists( $this->conn, 'delete_by_pattern' ) ) {
706 return $this->conn->delete_by_pattern( $pattern );
707 }
708 // phpredis: iterate the SCAN cursor (setOption SCAN_RETRY keeps it
709 // simple — scan() returns false when the cursor is exhausted).
710 if ( $this->conn instanceof \Redis ) {
711 $deleted = 0;
712 $it = null;
713 if ( defined( '\Redis::SCAN_RETRY' ) ) {
714 $this->conn->setOption( \Redis::OPT_SCAN, \Redis::SCAN_RETRY );
715 }
716 do {
717 $keys = $this->conn->scan( $it, $pattern, 500 );
718 if ( is_array( $keys ) && ! empty( $keys ) ) {
719 $deleted += (int) $this->conn->del( $keys );
720 }
721 } while ( $it > 0 );
722 return $deleted;
723 }
724 return -1;
725 }
726
727 /**
728 * Load xSpeed's own Memcached_Client (pure-PHP) on demand, the same
729 * way load_builtin_client() loads the Redis one.
730 */
731 private function load_builtin_memcached() {
732 return $this->load_builtin( '\\XSpeed\\Memcached_Client', 'class-memcached-client.php' );
733 }
734
735 public function flush_runtime() {
736 $this->cache = array();
737 return true;
738 }
739
740 public function flush_group( $group ) {
741 $group = $this->group( $group );
742
743 // Runtime copy first — drop every in-request entry for this group.
744 $needle = $this->full_key( '', $group );
745 foreach ( array_keys( $this->cache ) as $id ) {
746 if ( 0 === strpos( $id, $needle ) ) {
747 unset( $this->cache[ $id ] );
748 }
749 }
750
751 // Persistent store: actually evict the group's keys from Redis so a
752 // targeted invalidation (core or third-party calling
753 // wp_cache_flush_group) stops serving stale data — previously this
754 // was a runtime-only no-op against the backend. The key layout is
755 // salt:{prefix}:{group}:{key}, so match salt:*:{group}:* to cover
756 // both blog-prefixed and global groups for this site's namespace.
757 // (FBS-83119)
758 if ( $this->is_persistent_group( $group ) && 'redis' === $this->backend && '' !== $this->salt ) {
759 try {
760 $this->delete_redis_pattern( $this->salt . ':*:' . $group . ':*' );
761 } catch ( \Throwable $e ) {
762 return false;
763 }
764 }
765 return true;
766 }
767
768 public function close() {
769 if ( $this->persistent && $this->conn ) {
770 try {
771 if ( 'redis' === $this->backend ) {
772 $this->conn->close();
773 } else {
774 $this->conn->quit();
775 }
776 } catch ( \Throwable $e ) {
777 // ignore
778 }
779 }
780 return true;
781 }
782
783 // --- Group config ---------------------------------------------------
784
785 public function add_global_groups( $groups ) {
786 foreach ( (array) $groups as $g ) {
787 $this->global_groups[ $g ] = true;
788 }
789 }
790
791 public function add_non_persistent_groups( $groups ) {
792 foreach ( (array) $groups as $g ) {
793 $this->non_persistent_groups[ $g ] = true;
794 }
795 }
796
797 public function switch_to_blog( $blog_id ) {
798 $this->blog_prefix = $this->multisite ? (int) $blog_id : 0;
799 }
800
801 /** @return array{backend:string,persistent:bool,hits:int,misses:int} */
802 public function stats() {
803 return array(
804 'backend' => $this->backend,
805 'persistent' => $this->persistent,
806 'hits' => $this->cache_hits,
807 'misses' => $this->cache_misses,
808 );
809 }
810 }
811 }
812