| 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 |
|