PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 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 All 36 releases
← All changes | includes/object-cache.php +903 -22 1.0.4 → 1.4.2 View file →
@@ -35,29 +35,458 @@
35 35 /**
36 36 * Resolve a single config value from constants with sane defaults.
37 37 */
38 38 function xspeed_oc_config( $key, $default ) {
39 + // Keep this map in lockstep with the `constants` declared in
40 + // ObjectCacheModule::settings_schema(). The drop-in loads before
41 + // WordPress, so it cannot call Settings_Manager and the list genuinely
42 + // exists twice; ObjectCacheConstantParityTest asserts the two agree
43 + // field-for-field, because one rule in two files is how the last
44 + // ownership bug survived three review rounds. (#398)
45 + // The backend decides which host/port field to read, so it has to be
46 + // resolved the same way everything else is: constant first, then the
47 + // sidecar. Reading only the constant made a sidecar-configured
48 + // Memcached site read Redis's host and port.
49 + if ( defined( 'XSPEED_OC_BACKEND' ) ) {
50 + $backend = (string) constant( 'XSPEED_OC_BACKEND' );
51 + } else {
52 + $sc = xspeed_oc_sidecar();
53 + $backend = isset( $sc['backend'] ) ? (string) $sc['backend'] : '';
54 + }
55 + $is_memcached = ( 'memcached' === $backend );
56 +
39 57 $map = array(
40 58 'backend' => array( 'XSPEED_OC_BACKEND' ),
41 - 'host' => array( 'XSPEED_OC_HOST', 'WP_REDIS_HOST' ),
42 - 'port' => array( 'XSPEED_OC_PORT', 'WP_REDIS_PORT' ),
59 + // WP_REDIS_* are Redis's own conventions, so they only answer when
60 + // Redis is the backend. Consulting them on Memcached made a
61 + // Memcached site connect to the Redis host and port -- the same
62 + // cross-backend bleed that one shared XSPEED_OC_HOST/PORT pair
63 + // caused in the panel. (#398)
64 + // XSPEED_OC_HOST/PORT trail the Memcached names for BACKWARD
65 + // COMPATIBILITY: installs configured before the split have the
66 + // generic pair in their block, and dropping it would move them to
67 + // 127.0.0.1 on upgrade -- breaking a working cache. New writes use
68 + // XSPEED_OC_MC_*, so the fallback fades out on the next save.
69 + 'host' => $is_memcached
70 + ? array( 'XSPEED_OC_MC_HOST', 'XSPEED_OC_HOST' )
71 + : array( 'XSPEED_OC_HOST', 'WP_REDIS_PATH', 'WP_REDIS_HOST' ),
72 + 'port' => $is_memcached
73 + ? array( 'XSPEED_OC_MC_PORT', 'XSPEED_OC_PORT' )
74 + : array( 'XSPEED_OC_PORT', 'WP_REDIS_PORT' ),
75 + // WP_REDIS_PASSWORD trails the user names on purpose: in its array
76 + // form it carries the ACL username too, so a site that defines only
77 + // the password pair still authenticates as the right user.
78 + 'user' => array( 'XSPEED_OC_USER', 'WP_REDIS_USERNAME', 'WP_REDIS_USER', 'WP_REDIS_PASSWORD' ),
43 79 'password' => array( 'XSPEED_OC_PASSWORD', 'WP_REDIS_PASSWORD' ),
44 80 'database' => array( 'XSPEED_OC_DATABASE', 'WP_REDIS_DATABASE' ),
45 81 'timeout' => array( 'XSPEED_OC_TIMEOUT', 'WP_REDIS_TIMEOUT' ),
46 - 'salt' => array( 'XSPEED_OC_SALT', 'WP_CACHE_KEY_SALT' ),
82 + 'salt' => array( 'XSPEED_OC_SALT', 'WP_REDIS_PREFIX', 'WP_CACHE_KEY_SALT' ),
47 83 'persist' => array( 'XSPEED_OC_PERSISTENT', 'WP_REDIS_PERSISTENT' ),
48 84 );
85 + /*
86 + * This drop-in's short keys mapped to the schema field names the
87 + * sidecar stores. Redis and Memcached each name their own host/port
88 + * field, so switching backend cannot make one read the other's value.
89 + */
90 + $sidecar_map = array(
91 + 'backend' => 'backend',
92 + 'host' => $is_memcached ? 'memcached_host' : 'redis_host',
93 + 'port' => $is_memcached ? 'memcached_port' : 'redis_port',
94 + 'user' => 'redis_user',
95 + 'password' => 'redis_password',
96 + 'database' => 'redis_database',
97 + 'timeout' => 'connection_timeout',
98 + 'salt' => 'key_prefix',
99 + 'persist' => 'persistent',
100 + );
101 +
102 + /*
103 + * $memcached_servers is Memcached's convention the way WP_REDIS_* is
104 + * Redis's -- W3TC and the Memcached Object Cache drop-in both read it,
105 + * and hosts write it. Settings_Manager resolves it for the panel, so
106 + * without it here the panel and Test connection reported the host's
107 + * server while the drop-in quietly used 127.0.0.1 and cached nothing:
108 + * the two-truths split this whole change exists to close, on the one
109 + * backend where the convention IS a global. Ranked below our own
110 + * constants, matching the schema's constants-then-global order. (#398)
111 + */
112 + if ( $is_memcached && ( 'host' === $key || 'port' === $key ) ) {
113 + $ours = 'host' === $key
114 + ? array( 'XSPEED_OC_MC_HOST', 'XSPEED_OC_HOST' )
115 + : array( 'XSPEED_OC_MC_PORT', 'XSPEED_OC_PORT' );
116 + $pinned = false;
117 + foreach ( $ours as $const ) {
118 + if ( defined( $const ) ) {
119 + $pinned = true;
120 + break;
121 + }
122 + }
123 + if ( ! $pinned && isset( $GLOBALS['memcached_servers'] ) ) {
124 + $pair = xspeed_oc_first_memcached_server( $GLOBALS['memcached_servers'] );
125 + $slot = 'host' === $key ? 0 : 1;
126 + if ( null !== $pair && null !== $pair[ $slot ] ) {
127 + return $pair[ $slot ];
128 + }
129 + }
130 + }
131 +
49 132 if ( isset( $map[ $key ] ) ) {
133 + /*
134 + * A host's define outranks one we wrote, whatever order this map
135 + * lists them in -- the same rule Settings_Manager applies for the
136 + * panel. Ours only mirrors the option row, so preferring it meant a
137 + * rotated host credential was ignored until someone saved. Two
138 + * passes rather than a reorder, because the map's order is still
139 + * right for every other case (ours before the convention). (#398)
140 + */
141 + /*
142 + * Only a name that is not ours to begin with can be promoted. Our
143 + * OWN legacy aliases must never be: XSPEED_OC_HOST/PORT trail the
144 + * Memcached names for backward compatibility and, on a site that
145 + * ran Redis first, hold the REDIS host. Promoting one because it
146 + * happened to sit outside the current fence pointed a Memcached
147 + * site at the Redis server -- while the panel, which gates those
148 + * aliases on the backend (`constants_when`), still showed the right
149 + * value. That is the panel/runtime split this change exists to
150 + * close, reopened from the other side.
151 + *
152 + * WP_CACHE_KEY_SALT is NEVER promoted (#430). It is not a host's
153 + * namespace declaration the way WP_REDIS_* is -- it is WordPress's
154 + * OWN cache-uniqueness salt, present on almost every install and
155 + * usually a random value. On xCloud the provisioner writes the
156 + * correct namespace as XSPEED_OC_SALT AND WordPress carries its
157 + * own random WP_CACHE_KEY_SALT beside it; promoting the latter over
158 + * ours pointed every write outside the ACL namespace (NOPERM) while
159 + * released code -- which had no promotion pass -- worked. So for the
160 + * salt, our own define always wins over WP_CACHE_KEY_SALT; a genuine
161 + * WP_REDIS_PREFIX still ranks by position like any other convention.
162 + */
163 + $ours = xspeed_oc_our_constants();
164 + $sorted = array();
50 165 foreach ( $map[ $key ] as $const ) {
51 - if ( defined( $const ) ) {
52 - return constant( $const );
166 + if ( 0 === strpos( $const, 'XSPEED_OC_' ) ) {
167 + continue;
53 168 }
169 + if ( 'WP_CACHE_KEY_SALT' === $const ) {
170 + continue;
171 + }
172 + if ( defined( $const ) && ! in_array( $const, $ours, true ) ) {
173 + $sorted[] = $const;
174 + }
54 175 }
176 + foreach ( $map[ $key ] as $const ) {
177 + if ( ! in_array( $const, $sorted, true ) ) {
178 + $sorted[] = $const;
179 + }
180 + }
181 +
182 + foreach ( $sorted as $const ) {
183 + if ( ! defined( $const ) ) {
184 + continue;
185 + }
186 + // WP_REDIS_PATH names a socket only when WP_REDIS_SCHEME is
187 + // `unix` in any case, the same gate the schema declares in
188 + // `constants_when`. Redis Object Cache uses strcasecmp(). (#566)
189 + if ( 'WP_REDIS_PATH' === $const && ! ( defined( 'WP_REDIS_SCHEME' ) && is_string( constant( 'WP_REDIS_SCHEME' ) ) && 0 === strcasecmp( constant( 'WP_REDIS_SCHEME' ), 'unix' ) ) ) {
190 + continue;
191 + }
192 + $value = constant( $const );
193 + // WP_REDIS_PASSWORD only answers for `user` in its array form,
194 + // which carries the ACL username. As a plain string it is just
195 + // a password: skip it, or we would authenticate with the
196 + // password as the username.
197 + if ( 'user' === $key && 'WP_REDIS_PASSWORD' === $const && ! is_array( $value ) ) {
198 + continue;
199 + }
200 + return xspeed_oc_credential_part( $key, $value );
201 + }
55 202 }
203 +
204 + /*
205 + * No constant answered. On a host where wp-config.php is not writable
206 + * the panel stores the configuration in a sidecar beside this drop-in
207 + * instead, so consult it before falling back to the built-in default --
208 + * otherwise every setting the user saved there would be ignored at
209 + * runtime while the panel showed it as active. (#398)
210 + */
211 + $sidecar = xspeed_oc_sidecar();
212 + $field = isset( $sidecar_map[ $key ] ) ? $sidecar_map[ $key ] : null;
213 + if ( null !== $field && array_key_exists( $field, $sidecar ) ) {
214 + return xspeed_oc_credential_part( $key, $sidecar[ $field ] );
215 + }
216 +
56 217 return $default;
57 218 }
58 219 }
59 220
221 +if ( ! function_exists( 'xspeed_oc_first_memcached_server' ) ) {
222 + /**
223 + * Host and port of the first server in a `$memcached_servers` global.
224 + *
225 + * Two shapes are in circulation and hosts write both:
226 + *
227 + * array( array( 'host', 11211 ) ) // W3TC pair form
228 + * array( 'default' => array( 'host:11211' ) ) // Memcached Object Cache
229 + *
230 + * Supporting only the first left the second reading the whole "host:port"
231 + * string as the hostname -- or missing entirely, since its bucket is keyed
232 + * `default` rather than 0. Kept in one function because Settings_Manager
233 + * has to answer identically or the panel and the runtime disagree, which is
234 + * the bug this whole change closes. (#398)
235 + *
236 + * @param mixed $servers The global's value, unvalidated.
237 + * @return array{0:?string,1:?int}|null Host and port, either possibly null.
238 + */
239 + function xspeed_oc_first_memcached_server( $servers ) {
240 + if ( ! is_array( $servers ) || array() === $servers ) {
241 + return null;
242 + }
243 +
244 + // Either the 0th bucket or, for the keyed form, whichever comes first.
245 + $bucket = array_key_exists( 0, $servers ) ? $servers[0] : reset( $servers );
246 +
247 + /*
248 + * A bucket is EITHER a [host, port] pair or a list of server entries.
249 + * Telling them apart by shape, not by nesting depth: descending into
250 + * array( 'mc.example', 11211 ) yields the host string and drops the
251 + * port on the floor, which is the commonest form there is.
252 + */
253 + $entry = $bucket;
254 + if ( is_array( $bucket ) && isset( $bucket[0] ) && is_array( $bucket[0] ) ) {
255 + $entry = $bucket[0];
256 + }
257 +
258 + // Pair form: [ host, port ].
259 + if ( is_array( $entry ) ) {
260 + $host = isset( $entry[0] ) && ! is_array( $entry[0] ) ? (string) $entry[0] : null;
261 + $port = isset( $entry[1] ) && ! is_array( $entry[1] ) ? (int) $entry[1] : null;
262 + // A single-element list, array( 'host:port' ), is the keyed form's
263 + // bucket rather than a pair -- fall through to the string parser.
264 + if ( null !== $host && null === $port && is_string( $entry[0] ) && false !== strpos( $entry[0], ':' ) ) {
265 + $entry = $entry[0];
266 + } else {
267 + return ( null === $host && null === $port ) ? null : array( $host, $port );
268 + }
269 + }
270 +
271 + if ( ! is_string( $entry ) || '' === $entry ) {
272 + return null;
273 + }
274 +
275 + // "host:port", or a bare host. A unix socket path has no port and can
276 + // contain no colon we should split on, so only split the LAST one and
277 + // only when what follows is numeric.
278 + $at = strrpos( $entry, ':' );
279 + if ( false !== $at && ctype_digit( substr( $entry, $at + 1 ) ) ) {
280 + return array( substr( $entry, 0, $at ), (int) substr( $entry, $at + 1 ) );
281 + }
282 + return array( $entry, null );
283 + }
284 +}
285 +
286 +if ( ! function_exists( 'xspeed_oc_redis_socket' ) ) {
287 + /**
288 + * The unix socket path a Redis host names, or '' for a network host.
289 + *
290 + * A copy of \XSpeed\Redis_Client::socket_path(), which this drop-in cannot
291 + * call when phpredis is the client: the class is only loaded for the
292 + * built-in one. ObjectCacheSocketTest runs both over the same inputs, so
293 + * the panel's Test connection and the runtime agree on what a socket is.
294 + * (#566)
295 + *
296 + * @param string $host Configured Redis host.
297 + * @return string Socket path, or ''.
298 + */
299 + function xspeed_oc_redis_socket( $host ) {
300 + $host = trim( (string) $host );
301 + if ( 0 === stripos( $host, 'unix:' ) ) {
302 + // unix:///path and unix:/path both name /path.
303 + $host = '/' . ltrim( substr( $host, 5 ), '/' );
304 + }
305 + return ( '' !== $host && '/' === $host[0] && '/' !== $host ) ? $host : '';
306 + }
307 +}
308 +
309 +if ( ! function_exists( 'xspeed_oc_our_constants' ) ) {
310 + /**
311 + * Constant names defined inside OUR fenced block in wp-config.php.
312 + *
313 + * Ownership is decided by WHERE a define sits, exactly as
314 + * Object_Cache::our_constants() decides it for the admin half. The drop-in
315 + * needs the same answer for the same reason the panel does: a define we
316 + * wrote is only a mirror of the option row, so a HOST define has to outrank
317 + * it. Without this the drop-in kept connecting to our stale snapshot after
318 + * a host rotated its credentials, while the panel -- which does apply the
319 + * rule -- showed the new one. Panel and runtime disagreeing is the whole
320 + * bug this change exists to remove. (#398)
321 + *
322 + * @return string[]
323 + */
324 + function xspeed_oc_our_constants() {
325 + static $names = null;
326 + if ( null !== $names ) {
327 + return $names;
328 + }
329 + $names = array();
330 +
331 + // ABSPATH is defined by wp-load.php before the drop-in is included.
332 + $path = defined( 'ABSPATH' ) ? ABSPATH . 'wp-config.php' : '';
333 + if ( '' === $path || ! is_readable( $path ) ) {
334 + // One level up is the standard "wp-config outside the root" layout.
335 + $alt = defined( 'ABSPATH' ) ? dirname( ABSPATH ) . '/wp-config.php' : '';
336 + $path = ( '' !== $alt && is_readable( $alt ) ) ? $alt : '';
337 + }
338 + if ( '' === $path ) {
339 + return $names;
340 + }
341 +
342 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- runs before WordPress; WP_Filesystem does not exist yet.
343 + $config = (string) @file_get_contents( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an unreadable wp-config just means "we own nothing".
344 + if ( '' === $config ) {
345 + return $names;
346 + }
347 + if ( ! preg_match( '/\/\* BEGIN xSpeed Object Cache \*\/(.*?)\/\* END xSpeed Object Cache \*\//s', $config, $m ) ) {
348 + return $names;
349 + }
350 + if ( preg_match_all( "/define\(\s*'([A-Z0-9_]+)'/", $m[1], $found ) ) {
351 + $names = $found[1];
352 + }
353 + return $names;
354 + }
355 +}
356 +
357 +if ( ! function_exists( 'xspeed_oc_sidecar' ) ) {
358 + /**
359 + * Configuration written beside this drop-in when wp-config.php is
360 + * read-only. Returns an empty array when there is none.
361 + *
362 + * This file IS wp-content/object-cache.php, so the sidecar sits in the
363 + * same directory -- no constant needed to locate it, which matters because
364 + * WP_CONTENT_DIR is not guaranteed to be defined this early.
365 + *
366 + * @return array<string,mixed>
367 + */
368 + function xspeed_oc_sidecar() {
369 + static $data = null;
370 + if ( null !== $data ) {
371 + return $data;
372 + }
373 + $data = array();
374 + $path = __DIR__ . '/xspeed-object-cache.php';
375 + if ( ! is_readable( $path ) ) {
376 + return $data;
377 + }
378 +
379 + /*
380 + * This runs before WordPress, so a parse error here is a white screen
381 + * on every request rather than a degraded cache. The writer renames
382 + * into place atomically, but a file truncated by something else -- a
383 + * failed deploy, a partial restore -- must not take the site down, so
384 + * the include is guarded and any failure degrades to "no sidecar".
385 + */
386 + try {
387 + $loaded = @include $path; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a broken sidecar must not white-screen the site.
388 + if ( is_array( $loaded ) ) {
389 + $data = $loaded;
390 + }
391 + } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- degrade to no sidecar.
392 + $data = array();
393 + }
394 + return $data;
395 + }
396 +}
397 +
398 +if ( ! function_exists( 'xspeed_oc_salt' ) ) {
399 + /**
400 + * Resolve the salt that namespaces this site's keys.
401 + *
402 + * Normally the salt constant is written into wp-config.php on enable. When
403 + * wp-config is NOT writable (some managed hosts) the drop-in still gets
404 + * installed, so without a fallback every key would come out as
405 + * `:{blog}:{group}:{key}` — identical on every install. Two sites sharing a
406 + * Redis/Memcached server would then read each other's blog-details /
407 + * blog-lookup entries and the second site would redirect to the first.
408 + *
409 + * Derived the same way Object_Cache::derive_salt() does, so a site keeps
410 + * the same namespace whether the constant is present or not.
411 + *
412 + * @return string Non-empty salt.
413 + */
414 + function xspeed_oc_salt() {
415 + $salt = (string) xspeed_oc_config( 'salt', '' );
416 + if ( '' !== $salt ) {
417 + return $salt;
418 + }
419 +
420 + global $table_prefix;
421 +
422 + $url = '';
423 + if ( defined( 'WP_HOME' ) ) {
424 + $url = (string) WP_HOME;
425 + } elseif ( defined( 'WP_SITEURL' ) ) {
426 + $url = (string) WP_SITEURL;
427 + }
428 +
429 + // WP_HOME / WP_SITEURL are OPTIONAL and absent from a stock
430 + // wp-config.php, so the URL is usually empty here — DB_NAME alone
431 + // would then be identical for two sites sharing one database and the
432 + // collision this salt exists to prevent would come straight back.
433 + // $table_prefix separates them: it is assigned in wp-config.php itself,
434 + // so it is already a global by the time the drop-in loads (well before
435 + // $wpdb exists). ABSPATH is added whenever no URL was available, since
436 + // two installs on one database necessarily live in different
437 + // directories.
438 + $parts = array(
439 + $url,
440 + defined( 'DB_NAME' ) ? (string) DB_NAME : '',
441 + isset( $table_prefix ) ? (string) $table_prefix : '',
442 + );
443 + if ( '' === $url ) {
444 + $parts[] = defined( 'ABSPATH' ) ? (string) ABSPATH : '';
445 + }
446 +
447 + $seed = implode( '|', $parts );
448 + if ( '' === trim( $seed, '|' ) ) {
449 + $seed = 'xspeed';
450 + }
451 +
452 + return 'xs' . substr( md5( $seed ), 0, 12 );
453 + }
454 +}
455 +
456 +if ( ! function_exists( 'xspeed_oc_credential_part' ) ) {
457 + /**
458 + * Unpack the array form of a Redis credential constant.
459 + *
460 + * Managed hosts that provision Redis ACL users (xCloud, Cloudways) ship
461 + * the pair in one define:
462 + *
463 + * define( 'WP_REDIS_PASSWORD', array( 'acl_user', 's3cret' ) );
464 + *
465 + * Casting that to string yields "Array" and a notice, so the site would
466 + * authenticate with garbage. Split it here, at the single point both
467 + * `user` and `password` resolve through, rather than in each caller.
468 + *
469 + * @param string $key Config key being resolved.
470 + * @param mixed $value Raw constant value.
471 + * @return mixed
472 + */
473 + function xspeed_oc_credential_part( $key, $value ) {
474 + if ( ! is_array( $value ) || ( 'user' !== $key && 'password' !== $key ) ) {
475 + return $value;
476 + }
477 + // Scalars only. A nested value would stringify to "Array" and emit a
478 + // warning on EVERY request from the drop-in -- before headers are sent,
479 + // on the code path whose whole design rule is never to break the site.
480 + $parts = array_values( array_filter( $value, 'is_scalar' ) );
481 + if ( 'user' === $key ) {
482 + // A one-element array is a password with no ACL user.
483 + return count( $parts ) > 1 ? (string) $parts[0] : '';
484 + }
485 + return (string) ( count( $parts ) > 1 ? $parts[1] : ( $parts[0] ?? '' ) );
486 + }
487 +}
488 +
60 489 // -----------------------------------------------------------------------------
61 490 // WordPress object-cache API surface. Thin wrappers over the global instance.
62 491 // -----------------------------------------------------------------------------
63 492 if ( ! function_exists( 'wp_cache_init' ) ) {
@@ -192,8 +621,26 @@
192 621
193 622 /** @var string Key salt / prefix. */
194 623 private $salt = '';
195 624
625 + /** Option holding the Memcached generation floor (see generation_floor()). */
626 + const GENERATION_OPTION = 'xspeed_oc_generation';
627 +
628 + /**
629 + * @var int|null This site's namespace generation, resolved lazily once
630 + * per request. Advancing it is how Memcached flushes only this site's
631 + * keys — the daemon offers no way to enumerate or scope a real flush.
632 + * Null until first read; 1 means the original, unsuffixed key shape.
633 + */
634 + private $generation = null;
635 +
636 + /**
637 + * @var int|null Lowest generation this site may use, mirrored in the
638 + * database so an LRU eviction of the cached counter cannot rewind the
639 + * namespace. Null until first read.
640 + */
641 + private $generation_floor = null;
642 +
196 643 /** @var int Current blog id (multisite prefixing). */
197 644 private $blog_prefix = 0;
198 645
199 646 /** @var bool */
@@ -213,11 +660,35 @@
213 660
214 661 public function __construct() {
215 662 $this->multisite = function_exists( 'is_multisite' ) && is_multisite();
216 663 $this->blog_prefix = $this->multisite ? (int) get_current_blog_id() : 0;
217 - $this->salt = (string) xspeed_oc_config( 'salt', '' );
664 + $this->salt = xspeed_oc_salt();
218 665 $this->backend = strtolower( (string) xspeed_oc_config( 'backend', 'redis' ) );
219 666
667 + // Non-persistent groups. We deliberately DO persist `options`
668 + // (incl. the autoloaded `alloptions` blob), `comment`, and
669 + // `counts` — these are the highest-volume, highest-hit groups,
670 + // and excluding them was why the persistent cache stored only a
671 + // fraction of the keys a mature object cache (e.g. Redis Object
672 + // Cache) does. Redis Object Cache persists all of them by
673 + // default; matching that is the whole point of the feature.
674 + //
675 + // The historical "can't deactivate a plugin" bug (FBS-82210) was
676 + // a stale `alloptions` being read back after a plugin write. That
677 + // is NOT solved by refusing to persist options — a correct cache
678 + // solves it by invalidating on write, which WordPress core already
679 + // does: update_option()/add_option()/delete_option() each call
680 + // wp_cache_delete( 'alloptions', 'options' ). Our delete()
681 + // propagates to the backend for every persistent group (see
682 + // delete()), so the stale blob is removed the moment WP writes an
683 + // option — deactivation stays correct WITH options persisted.
684 + //
685 + // `plugins` and `themes` remain non-persistent: they're tiny,
686 + // rebuilt cheaply per request, and never worth a round trip.
687 + $this->add_non_persistent_groups(
688 + array( 'plugins', 'themes' )
689 + );
690 +
220 691 $this->connect();
221 692 }
222 693
223 694 // --- Connection -----------------------------------------------------
@@ -252,21 +723,33 @@
252 723 } else {
253 724 $this->backend = 'redis';
254 725 $host = (string) xspeed_oc_config( 'host', '127.0.0.1' );
255 726 $port = (int) xspeed_oc_config( 'port', 6379 );
727 + $user = (string) xspeed_oc_config( 'user', '' );
256 728 $pass = (string) xspeed_oc_config( 'password', '' );
257 729 $db = (int) xspeed_oc_config( 'database', 0 );
258 730 $persist = (bool) xspeed_oc_config( 'persist', false );
259 731
260 732 if ( class_exists( 'Redis' ) ) {
261 - // phpredis extension (preferred).
733 + // phpredis extension (preferred). It opens a unix
734 + // socket only for a bare path with a port below 1;
735 + // with any real port it tries TCP to the path. (#566)
262 736 $this->client = 'phpredis';
263 737 $redis = new Redis();
264 - $ok = $persist
738 + $socket = xspeed_oc_redis_socket( $host );
739 + if ( '' !== $socket ) {
740 + $host = $socket;
741 + $port = 0;
742 + }
743 + $ok = $persist
265 744 ? @$redis->pconnect( $host, $port, $timeout )
266 745 : @$redis->connect( $host, $port, $timeout );
267 746 if ( $ok ) {
268 - if ( '' !== $pass ) {
747 + // Redis 6+ ACL: ['user'=>..,'pass'=>..] when a username
748 + // is configured; legacy password-only otherwise.
749 + if ( '' !== $user ) {
750 + @$redis->auth( array( 'user' => $user, 'pass' => $pass ) );
751 + } elseif ( '' !== $pass ) {
269 752 @$redis->auth( $pass );
270 753 }
271 754 if ( $db > 0 ) {
272 755 @$redis->select( $db );
@@ -280,10 +763,10 @@
280 763 // xSpeed's own pure-PHP client (no extension, no library).
281 764 $this->client = 'builtin';
282 765 $rc = new \XSpeed\Redis_Client( $host, $port, (float) $timeout, $persist );
283 766 if ( $rc->connect() ) {
284 - if ( '' !== $pass ) {
285 - $rc->auth( $pass );
767 + if ( '' !== $pass || '' !== $user ) {
768 + $rc->auth( $pass, $user );
286 769 }
287 770 if ( $db > 0 ) {
288 771 $rc->select( $db );
289 772 }
@@ -300,8 +783,39 @@
300 783 $this->conn = null;
301 784 $this->persistent = false;
302 785 }
303 786
787 + // Connecting to an unreachable/unresolvable backend (e.g.
788 + // `Redis::pconnect()` → "getaddrinfo for redis failed", or
789 + // `stream_socket_client()` in our builtin clients) emits a PHP
790 + // warning. We `@`-suppress those above and degrade gracefully to a
791 + // non-persistent cache — but the warning still lingers in
792 + // `error_get_last()`. WP reads that at `admin_body_class` time and
793 + // tags every admin page `php-error`, which renders an empty banner
794 + // above the admin menu even though nothing is actually broken.
795 + //
796 + // Clear it so a degraded-but-handled backend doesn't masquerade as
797 + // a site error — but ONLY when the lingering error is OUR connect
798 + // warning. We never blindly wipe the slot: matching on the
799 + // originating file (this drop-in, or our bundled socket clients)
800 + // guarantees we can't swallow an unrelated warning that happened to
801 + // land in error_get_last() first. This does NOT touch the error
802 + // LOG — if WP_DEBUG_LOG is on, PHP already wrote the warning to
803 + // debug.log before this runs, and the explicit "NOT persisting"
804 + // diagnostic below is the signal meant for humans.
805 + if ( function_exists( 'error_clear_last' ) ) {
806 + $last = error_get_last();
807 + if ( is_array( $last ) && isset( $last['file'] ) ) {
808 + $file = $last['file'];
809 + if ( __FILE__ === $file
810 + || false !== strpos( $file, 'class-redis-client.php' )
811 + || false !== strpos( $file, 'class-memcached-client.php' )
812 + ) {
813 + error_clear_last();
814 + }
815 + }
816 + }
817 +
304 818 // The drop-in is installed (we're running), so if we didn't manage
305 819 // to connect a persistent backend, object caching is effectively
306 820 // doing nothing — writes succeed but evaporate at request end.
307 821 // Flag it so detect()/the dashboard can report "degraded" instead
@@ -311,9 +825,13 @@
311 825 $this->degraded = true;
312 826 $should_log = function_exists( 'apply_filters' )
313 827 ? apply_filters( 'xspeed_object_cache_log_degraded', true )
314 828 : true;
315 - if ( $should_log ) {
829 + // Diagnostic only, and only when debug logging is on — keeps
830 + // the production error log quiet (Plugin Check flags an
831 + // unconditional error_log()).
832 + if ( $should_log && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
833 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated degraded-state diagnostic.
316 834 error_log( sprintf(
317 835 '[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.',
318 836 $this->backend,
319 837 $this->client
@@ -406,9 +924,14 @@
406 924
407 925 private function full_key( $key, $group ) {
408 926 $group = $this->group( $group );
409 927 $prefix = isset( $this->global_groups[ $group ] ) ? 0 : $this->blog_prefix;
410 - return $this->salt . ':' . $prefix . ':' . $group . ':' . $key;
928 + $gen = $this->generation();
929 + // Generation 1 keeps the historical key shape, so Redis (which
930 + // flushes by pattern and never advances the generation) is
931 + // byte-identical to before and existing entries stay readable.
932 + $ns = 1 === $gen ? $this->salt : $this->salt . '.g' . $gen;
933 + return $ns . ':' . $prefix . ':' . $group . ':' . $key;
411 934 }
412 935
413 936 private function is_persistent_group( $group ) {
414 937 return $this->persistent && ! isset( $this->non_persistent_groups[ $this->group( $group ) ] );
@@ -469,19 +992,52 @@
469 992 }
470 993 $this->cache[ $id ] = $data;
471 994
472 995 if ( $this->is_persistent_group( $group ) ) {
996 + $stored = false;
997 + $threw = false;
473 998 try {
474 999 $payload = maybe_serialize( $data );
475 1000 if ( 'redis' === $this->backend ) {
476 - return $expire > 0
1001 + $stored = $expire > 0
477 1002 ? (bool) $this->conn->setex( $id, (int) $expire, $payload )
478 1003 : (bool) $this->conn->set( $id, $payload );
1004 + } else {
1005 + $stored = (bool) $this->conn->set( $id, $payload, (int) $expire );
479 1006 }
480 - return (bool) $this->conn->set( $id, $payload, (int) $expire );
481 1007 } catch ( \Throwable $e ) {
482 - return true; // runtime cache still set
1008 + $threw = true;
483 1009 }
1010 + if ( ! $stored ) {
1011 + // The backend may still hold the PREVIOUS value for this
1012 + // key (classic: memcached rejecting an alloptions blob
1013 + // over its item-size limit). The DB now has the new value;
1014 + // leaving the old one here would serve stale data to every
1015 + // later request — e.g. a settings change confirmed over
1016 + // REST/MCP that the dashboard never shows. Evict so
1017 + // readers fall back to the database.
1018 + try {
1019 + $this->backend_delete( $id );
1020 + } catch ( \Throwable $e ) {
1021 + // Backend fully down → reads fail too, so no staleness.
1022 + }
1023 + }
1024 + // Return value: the runtime cache always accepted the value, and
1025 + // WP core's contract for wp_cache_set() is "was it cached",
1026 + // which a persistent-backend refusal doesn't falsify — the
1027 + // value is live for this request and the DB holds the truth
1028 + // for later ones (we evicted the stale copy above). Some
1029 + // callers treat false as "the write was lost" and retry or
1030 + // bail, so report success and surface backend trouble through
1031 + // the degraded flag instead.
1032 + //
1033 + // Exception: a THROWN backend is a hard failure we still
1034 + // report as cached for the same reason — the runtime cache
1035 + // holds it.
1036 + if ( ! $stored ) {
1037 + $this->degraded = true;
1038 + }
1039 + return true;
484 1040 }
485 1041 return true;
486 1042 }
487 1043
@@ -620,16 +1176,323 @@
620 1176 }
621 1177
622 1178 private function backend_flush() {
623 1179 if ( 'redis' === $this->backend ) {
624 - return $this->conn->flushDB();
1180 + // Scope the flush to THIS site's namespace (salt:*) instead of
1181 + // FLUSHDB, which would wipe the entire Redis database —
1182 + // including other sites / apps sharing the same DB index.
1183 + // (FBS-83119)
1184 + //
1185 + // There is deliberately NO empty-salt fallback to FLUSHDB.
1186 + // xspeed_oc_salt() always returns a non-empty value, but a
1187 + // drop-in left over from an older version can still be the
1188 + // object loaded for the request that runs the upgrade
1189 + // migration — and that is exactly when a global flush would
1190 + // destroy a neighbouring site's cache. An unsalted pattern is
1191 + // scoped to nothing, so we bail rather than widen the blast
1192 + // radius.
1193 + if ( '' === $this->salt ) {
1194 + return false;
1195 + }
1196 + return $this->delete_redis_pattern( $this->escape_glob( $this->salt ) . ':*' ) >= 0;
625 1197 }
626 - return 'builtin-memcached' === $this->client
627 - ? $this->conn->flush_all()
628 - : $this->conn->flush();
1198 +
1199 + // Memcached has no key enumeration, so flush_all() / flush() are
1200 + // unavoidably SERVER-WIDE — they wipe every other site and app on
1201 + // the same daemon. Bump this site's namespace generation instead:
1202 + // every key is built through it (see full_key()), so incrementing
1203 + // it orphans this site's entries and leaves everyone else's alone.
1204 + // The orphans expire on their own under Memcached's LRU.
1205 + return $this->bump_generation();
629 1206 }
630 1207
631 1208 /**
1209 + * Advance this site's namespace generation, invalidating every key
1210 + * built from it. Used as the Memcached flush primitive.
1211 + *
1212 + * @return bool
1213 + */
1214 + private function bump_generation() {
1215 + $key = $this->generation_key();
1216 + $new = null;
1217 +
1218 + try {
1219 + $new = 'builtin-memcached' === $this->client
1220 + ? $this->conn->incr( $key, 1 )
1221 + : $this->conn->increment( $key, 1 );
1222 + } catch ( \Throwable $e ) {
1223 + $new = false;
1224 + }
1225 +
1226 + // increment() fails when the counter does not exist — either it was
1227 + // never seeded, or the daemon evicted it under LRU. Either way,
1228 + // resuming from the CURRENT generation is what matters: seeding
1229 + // back to a low number could land on a generation this site used
1230 + // before and resurrect the keys this flush is meant to clear.
1231 + // Jumping forward from the generation we resolved for this request
1232 + // keeps the namespace monotonic across an eviction.
1233 + if ( false === $new || null === $new ) {
1234 + $next = $this->generation() + 1;
1235 + try {
1236 + // Store as a string: the bundled Memcached_Client types
1237 + // this parameter `string`, and Memcached stores scalars as
1238 + // strings regardless. (This file has no strict_types — it
1239 + // must load standalone — so an int would be coerced rather
1240 + // than rejected, but passing the right type keeps the two
1241 + // clients behaving identically.)
1242 + $this->conn->set( $key, (string) $next, 0 );
1243 + $new = $next;
1244 + } catch ( \Throwable $e ) {
1245 + return false;
1246 + }
1247 + }
1248 +
1249 + $this->generation = (int) $new;
1250 + $this->persist_generation_floor( $this->generation );
1251 + return true;
1252 + }
1253 +
1254 + /**
1255 + * The counter key holding this site's namespace generation. Salted, so
1256 + * each site owns its own counter on a shared daemon.
1257 + *
1258 + * @return string
1259 + */
1260 + private function generation_key() {
1261 + // Deliberately OUTSIDE the `{salt}:` namespace: Redis flushes by
1262 + // deleting everything matching `{salt}:*`, which would otherwise
1263 + // sweep away this invalidation marker if a site were reconfigured
1264 + // from memcached to redis and back.
1265 + return $this->salt . '.xspeed-oc-gen';
1266 + }
1267 +
1268 + /**
1269 + * The lowest generation this site may use, read from the database.
1270 + *
1271 + * Memcached can evict the counter at any time; the database cannot, so
1272 + * this is what stops an eviction from silently rewinding the namespace
1273 + * and resurrecting keys a Purge already cleared.
1274 + *
1275 + * Read straight through $wpdb rather than get_option(), because the
1276 + * options API routes through this very cache and would recurse. Returns
1277 + * 1 whenever the database is not available yet — the drop-in loads
1278 + * before $wpdb exists, and on those early requests nothing has been
1279 + * flushed anyway.
1280 + *
1281 + * @return int
1282 + */
1283 + /**
1284 + * The options table holding the generation floor, or '' when the
1285 + * database cannot be queried yet.
1286 + *
1287 + * Always the NETWORK-wide table. On multisite $wpdb->options points at
1288 + * the current blog's table, but the salt and the generation have no
1289 + * blog component — they namespace the whole install, global groups
1290 + * included. Storing the floor per blog would let a purge on blog 2 go
1291 + * unseen by blog 1, so an eviction there would rewind the namespace and
1292 + * resurrect the shared blog-details / blog-lookup entries that are the
1293 + * original bug.
1294 + *
1295 + * @param object $wpdb The database handle.
1296 + * @return string Table name, or '' when unusable.
1297 + */
1298 + private function generation_table( $wpdb ) {
1299 + if ( ! is_object( $wpdb ) || ! method_exists( $wpdb, 'get_var' ) || ! method_exists( $wpdb, 'prepare' ) ) {
1300 + return '';
1301 + }
1302 +
1303 + // During wp-admin/install.php and `wp core install` the drop-in is
1304 + // already live while the options table does not exist yet. A query
1305 + // then emits a database error that our try/catch cannot suppress,
1306 + // because get_var() reports rather than throws.
1307 + if ( defined( 'WP_INSTALLING' ) && WP_INSTALLING ) {
1308 + return '';
1309 + }
1310 +
1311 + $base = isset( $wpdb->base_prefix ) ? (string) $wpdb->base_prefix : '';
1312 + if ( '' !== $base ) {
1313 + return $base . 'options';
1314 + }
1315 +
1316 + return isset( $wpdb->options ) ? (string) $wpdb->options : '';
1317 + }
1318 +
1319 + private function generation_floor() {
1320 + if ( null !== $this->generation_floor ) {
1321 + return $this->generation_floor;
1322 + }
1323 +
1324 + $this->generation_floor = 1;
1325 +
1326 + if ( 'memcached' !== $this->backend || ! isset( $GLOBALS['wpdb'] ) ) {
1327 + return $this->generation_floor;
1328 + }
1329 +
1330 + $wpdb = $GLOBALS['wpdb'];
1331 + $table = $this->generation_table( $wpdb );
1332 + if ( '' === $table ) {
1333 + return $this->generation_floor;
1334 + }
1335 +
1336 + try {
1337 + $val = $wpdb->get_var(
1338 + $wpdb->prepare(
1339 + "SELECT option_value FROM {$table} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is derived from $wpdb, never user input.
1340 + self::GENERATION_OPTION
1341 + )
1342 + );
1343 + if ( is_numeric( $val ) && (int) $val > 1 ) {
1344 + $this->generation_floor = (int) $val;
1345 + }
1346 + } catch ( \Throwable $e ) {
1347 + $this->generation_floor = 1;
1348 + }
1349 +
1350 + return $this->generation_floor;
1351 + }
1352 +
1353 + /**
1354 + * Persist the generation as the new floor, so an eviction of the cached
1355 + * counter cannot rewind past it.
1356 + *
1357 + * @param int $generation The generation just written.
1358 + * @return void
1359 + */
1360 + private function persist_generation_floor( $generation ) {
1361 + if ( 'memcached' !== $this->backend || ! isset( $GLOBALS['wpdb'] ) ) {
1362 + return;
1363 + }
1364 +
1365 + $wpdb = $GLOBALS['wpdb'];
1366 + $table = $this->generation_table( $wpdb );
1367 + if ( '' === $table || ! method_exists( $wpdb, 'query' ) ) {
1368 + return;
1369 + }
1370 +
1371 + try {
1372 + // Upsert without the options API, which would recurse through
1373 + // this cache. autoload='no' keeps it out of alloptions.
1374 + $wpdb->query(
1375 + $wpdb->prepare(
1376 + "INSERT INTO {$table} (option_name, option_value, autoload)
1377 + VALUES (%s, %s, 'no')
1378 + ON DUPLICATE KEY UPDATE option_value = VALUES(option_value)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is derived from $wpdb, never user input.
1379 + self::GENERATION_OPTION,
1380 + (string) (int) $generation
1381 + )
1382 + );
1383 + $this->generation_floor = (int) $generation;
1384 + } catch ( \Throwable $e ) {
1385 + // Best effort: the cached counter still carries the flush for
1386 + // as long as it survives.
1387 + return;
1388 + }
1389 + }
1390 +
1391 + /**
1392 + * Read this site's namespace generation, once per request.
1393 + *
1394 + * Only meaningful for Memcached, where flushing works by advancing the
1395 + * generation rather than deleting keys. Redis deletes by pattern, so it
1396 + * stays on generation 1 and its key shape is unchanged.
1397 + *
1398 + * @return int
1399 + */
1400 + private function generation() {
1401 + if ( null !== $this->generation ) {
1402 + return $this->generation;
1403 + }
1404 +
1405 + // The floor comes from the database, because the counter lives in
1406 + // the cache it namespaces and Memcached can evict it under LRU.
1407 + // Falling back to generation 1 after an eviction would re-expose
1408 + // the keys an earlier Purge cleared, so the DB copy — which cannot
1409 + // be evicted — is what the generation may never drop below.
1410 + $this->generation = $this->generation_floor();
1411 +
1412 + if ( 'memcached' === $this->backend && $this->persistent ) {
1413 + try {
1414 + $val = $this->conn->get( $this->generation_key() );
1415 + if ( is_numeric( $val ) && (int) $val > $this->generation ) {
1416 + $this->generation = (int) $val;
1417 + }
1418 + } catch ( \Throwable $e ) {
1419 + // Unreadable counter — the floor still applies, so a
1420 + // previous flush is never undone.
1421 + $this->generation = max( 1, $this->generation );
1422 + }
1423 + }
1424 +
1425 + return $this->generation;
1426 + }
1427 +
1428 + /**
1429 + * Escape Redis glob metacharacters so a literal string matches only
1430 + * itself inside a SCAN MATCH pattern.
1431 + *
1432 + * The salt and group name are interpolated into the flush patterns
1433 + * below, and neither is guaranteed to be glob-safe: an explicit Cache
1434 + * Key Prefix is whatever the user typed, and a host-pinned
1435 + * WP_CACHE_KEY_SALT is whatever the host wrote (WordPress builds these
1436 + * from the site URL, so punctuation is normal). Left unescaped, `*`,
1437 + * `?` and `[...]` are wildcards — `wp_[dev]site_:*` matches a
1438 + * NEIGHBOURING site's `wp_dsite_:*` keys, so purging one site deletes
1439 + * another site's cache. A bare `*` prefix matches everything and
1440 + * empties the whole Redis DB, which is the exact damage the scoped
1441 + * flush exists to prevent.
1442 + *
1443 + * Redis's stringmatchlen() treats `\` as the escape character, so
1444 + * backslash-prefixing each metacharacter makes it literal. `\` itself
1445 + * is escaped first, or escaping the others would be undone.
1446 + *
1447 + * @param string $literal Text to match literally.
1448 + * @return string Glob-safe form of $literal.
1449 + */
1450 + private function escape_glob( $literal ) {
1451 + return str_replace(
1452 + array( '\\', '*', '?', '[', ']' ),
1453 + array( '\\\\', '\\*', '\\?', '\\[', '\\]' ),
1454 + (string) $literal
1455 + );
1456 + }
1457 +
1458 + /**
1459 + * Delete every Redis key matching $pattern across both client kinds
1460 + * (phpredis native scan + our pure-PHP Redis_Client). Returns the
1461 + * count deleted, or -1 if the backend isn't redis. SCAN-based so it
1462 + * never blocks the server the way KEYS would. (FBS-83119)
1463 + *
1464 + * Callers MUST pass any literal segment through escape_glob() — this
1465 + * receives a finished pattern and cannot tell wildcard from data.
1466 + */
1467 + private function delete_redis_pattern( $pattern ) {
1468 + if ( 'redis' !== $this->backend || ! $this->conn ) {
1469 + return -1;
1470 + }
1471 + // Our pure-PHP client.
1472 + if ( method_exists( $this->conn, 'delete_by_pattern' ) ) {
1473 + return $this->conn->delete_by_pattern( $pattern );
1474 + }
1475 + // phpredis: iterate the SCAN cursor (setOption SCAN_RETRY keeps it
1476 + // simple — scan() returns false when the cursor is exhausted).
1477 + if ( $this->conn instanceof \Redis ) {
1478 + $deleted = 0;
1479 + $it = null;
1480 + if ( defined( '\Redis::SCAN_RETRY' ) ) {
1481 + $this->conn->setOption( \Redis::OPT_SCAN, \Redis::SCAN_RETRY );
1482 + }
1483 + do {
1484 + $keys = $this->conn->scan( $it, $pattern, 500 );
1485 + if ( is_array( $keys ) && ! empty( $keys ) ) {
1486 + $deleted += (int) $this->conn->del( $keys );
1487 + }
1488 + } while ( $it > 0 );
1489 + return $deleted;
1490 + }
1491 + return -1;
1492 + }
1493 +
1494 + /**
632 1495 * Load xSpeed's own Memcached_Client (pure-PHP) on demand, the same
633 1496 * way load_builtin_client() loads the Redis one.
634 1497 */
635 1498 private function load_builtin_memcached() {
@@ -641,14 +1504,32 @@
641 1504 return true;
642 1505 }
643 1506
644 1507 public function flush_group( $group ) {
645 - // Without key tagging we can't selectively flush a group on the
646 - // persistent store cheaply; clear the runtime copy for the group.
1508 + $group = $this->group( $group );
1509 +
1510 + // Runtime copy first — drop every in-request entry for this group.
647 1511 $needle = $this->full_key( '', $group );
648 1512 foreach ( array_keys( $this->cache ) as $id ) {
649 1513 if ( 0 === strpos( $id, $needle ) ) {
650 1514 unset( $this->cache[ $id ] );
1515 + }
1516 + }
1517 +
1518 + // Persistent store: actually evict the group's keys from Redis so a
1519 + // targeted invalidation (core or third-party calling
1520 + // wp_cache_flush_group) stops serving stale data — previously this
1521 + // was a runtime-only no-op against the backend. The key layout is
1522 + // salt:{prefix}:{group}:{key}, so match salt:*:{group}:* to cover
1523 + // both blog-prefixed and global groups for this site's namespace.
1524 + // (FBS-83119)
1525 + if ( $this->is_persistent_group( $group ) && 'redis' === $this->backend && '' !== $this->salt ) {
1526 + try {
1527 + $this->delete_redis_pattern(
1528 + $this->escape_glob( $this->salt ) . ':*:' . $this->escape_glob( $group ) . ':*'
1529 + );
1530 + } catch ( \Throwable $e ) {
1531 + return false;
651 1532 }
652 1533 }
653 1534 return true;
654 1535 }