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/modules/ObjectCache/ObjectCacheModule.php +840 -45 1.0.5 → 1.4.2 View file →
@@ -19,24 +19,38 @@
19 19 defined( 'ABSPATH' ) || exit;
20 20
21 21 use XSpeed\Module;
22 22 use XSpeed\Object_Cache;
23 +use XSpeed\Object_Cache_Takeover;
23 24
24 25 final class ObjectCacheModule extends Module {
25 26
26 27 public const SLUG = 'object-cache';
27 28 public const TIER = self::TIER_FREE;
28 - public const VERSION = '1.0.0';
29 + public const VERSION = '1.1.0';
29 30
30 31 public function ui_metadata(): array {
31 32 return array(
32 - 'label' => 'Object Cache',
33 + 'label' => __( 'Object Cache', 'xspeed' ),
33 34 'icon' => 'Server',
34 - 'description' => 'Configure a persistent object cache (Redis / Memcached) and generate a paste-ready wp-config.php snippet.',
35 + 'description' => __( 'Stores database query results in Redis or Memcached so pages build faster.', 'xspeed' ),
36 + 'group' => 'cache',
35 37 'custom_panel' => 'ObjectCachePanel',
36 38 );
37 39 }
38 40
41 + /**
42 + * @inheritDoc
43 + *
44 + * Nothing exempt. Object caching sits beside a page cache and competes for
45 + * nothing, and the drop-in is only re-synced when ours is already installed
46 + * (see boot()), so the flag installs nothing on its own — all true, and all
47 + * a weak reason to leave a switch on that nobody asked for.
48 + */
49 + public function conflict_safe_exempt(): array {
50 + return array();
51 + }
52 +
39 53 public function settings_schema(): array {
40 54 return array(
41 55 'backend' => array(
42 56 'type' => 'enum',
@@ -45,16 +59,31 @@
45 59 'option_labels' => array(
46 60 'redis' => 'Redis',
47 61 'memcached' => 'Memcached',
48 62 ),
49 - 'label' => 'Backend',
50 - 'description' => 'Which cache server you intend to use. Affects the generated wp-config snippet.',
63 + 'label' => __( 'Cache server', 'xspeed' ),
64 + 'description' => __( 'The cache server your host provides. Ask your host if you are not sure.', 'xspeed' ),
51 65 ),
52 66 'redis_host' => array(
53 - 'type' => 'string',
54 - 'default' => '127.0.0.1',
55 - 'label' => 'Redis Host',
56 - 'description' => 'Hostname or IP of the Redis server. Use 127.0.0.1 for a local socket on the same machine as PHP.',
67 + 'type' => 'string',
68 + 'default' => '127.0.0.1',
69 + // WP_REDIS_PATH is Redis Object Cache's name for a unix socket,
70 + // and it only means one when WP_REDIS_SCHEME says `unix` -- the
71 + // same pair that plugin reads. It ranks above WP_REDIS_HOST
72 + // because that plugin ignores the host once the scheme is unix,
73 + // and hosts that write the pair often leave a stale host
74 + // beside it. (#566)
75 + 'constants' => array( 'XSPEED_OC_HOST', 'WP_REDIS_PATH', 'WP_REDIS_HOST' ),
76 + 'constants_when' => array(
77 + 'WP_REDIS_PATH' => array(
78 + 'constant' => 'WP_REDIS_SCHEME',
79 + 'is' => 'unix',
80 + 'case_insensitive' => true,
81 + ),
82 + ),
83 + 'label' => __( 'Redis host', 'xspeed' ),
84 + 'description' => __( 'Address of the Redis server, or the path to its socket file if your host gave you one (for example /run/redis/redis.sock). Use 127.0.0.1 when Redis runs on the same server as your site.', 'xspeed' ),
85 + 'dependsOn' => array( 'field' => 'backend', 'value' => 'redis' ),
57 86 ),
58 87 'redis_port' => array(
59 88 'type' => 'int',
60 89 'default' => 6379,
@@ -59,16 +88,38 @@
59 88 'type' => 'int',
60 89 'default' => 6379,
61 90 'min' => 1,
62 91 'max' => 65535,
63 - 'label' => 'Redis Port',
64 - 'description' => 'Default Redis port is 6379.',
92 + 'constants' => array( 'XSPEED_OC_PORT', 'WP_REDIS_PORT' ),
93 + 'label' => __( 'Redis port', 'xspeed' ),
94 + 'description' => __( 'The default Redis port is 6379. Not used when the host is a socket path.', 'xspeed' ),
95 + 'advanced' => true,
96 + 'dependsOn' => array( 'field' => 'backend', 'value' => 'redis' ),
65 97 ),
98 + 'redis_user' => array(
99 + 'type' => 'string',
100 + 'default' => '',
101 + // WP_REDIS_PASSWORD trails the dedicated names because in its
102 + // array form -- how xCloud and Cloudways hand out ACL
103 + // credentials -- it carries the username too. It is pair-only:
104 + // as a plain string it is a password, never a username.
105 + // WP_REDIS_USERNAME is Redis Object Cache's name for the same
106 + // thing, and that plugin lets it win over the array form. (#566)
107 + 'constants' => array( 'XSPEED_OC_USER', 'WP_REDIS_USERNAME', 'WP_REDIS_USER', 'WP_REDIS_PASSWORD' ),
108 + 'constants_pair_only' => array( 'WP_REDIS_PASSWORD' ),
109 + 'constant_pair' => 'user',
110 + 'label' => __( 'Redis username', 'xspeed' ),
111 + 'description' => __( 'Only needed if your host gave you a Redis username along with the password. Leave blank otherwise.', 'xspeed' ),
112 + 'dependsOn' => array( 'field' => 'backend', 'value' => 'redis' ),
113 + ),
66 114 'redis_password' => array(
67 - 'type' => 'string',
68 - 'default' => '',
69 - 'label' => 'Redis Password',
70 - 'description' => 'Leave blank if your Redis server runs without auth.',
115 + 'type' => 'secret',
116 + 'default' => '',
117 + 'constants' => array( 'XSPEED_OC_PASSWORD', 'WP_REDIS_PASSWORD' ),
118 + 'constant_pair' => 'password',
119 + 'label' => __( 'Redis password', 'xspeed' ),
120 + 'description' => __( 'Leave blank if your Redis server has no password.', 'xspeed' ),
121 + 'dependsOn' => array( 'field' => 'backend', 'value' => 'redis' ),
71 122 ),
72 123 'redis_database' => array(
73 124 'type' => 'int',
74 125 'default' => 0,
@@ -73,30 +124,95 @@
73 124 'type' => 'int',
74 125 'default' => 0,
75 126 'min' => 0,
76 127 'max' => 15,
77 - 'label' => 'Redis Database',
78 - 'description' => 'Redis logical DB number (0-15). Use a dedicated DB per site if Redis is shared.',
128 + 'constants' => array( 'XSPEED_OC_DATABASE', 'WP_REDIS_DATABASE' ),
129 + 'label' => __( 'Redis database', 'xspeed' ),
130 + 'description' => __( 'Database number, 0 to 15. If several sites share one Redis server, give each site its own number.', 'xspeed' ),
131 + 'advanced' => true,
132 + 'dependsOn' => array( 'field' => 'backend', 'value' => 'redis' ),
79 133 ),
80 134 'memcached_host' => array(
81 135 'type' => 'string',
82 136 'default' => '127.0.0.1',
83 - 'label' => 'Memcached Host',
84 - 'description' => 'Used when Backend = Memcached.',
137 + // Memcached names its OWN constants. Sharing XSPEED_OC_HOST/PORT
138 + // with Redis meant enabling Redis rewrote the Memcached host and
139 + // port with Redis's, and locked them -- switching backend later
140 + // then failed with no way to fix it on screen. (#398)
141 + 'constants' => array( 'XSPEED_OC_MC_HOST', 'XSPEED_OC_HOST' ),
142 + // XSPEED_OC_HOST/PORT are the pre-split names, kept so an install
143 + // configured before the split keeps its host on upgrade. Gated on
144 + // the backend actually being Memcached, or a Redis site would read
145 + // the Redis port here. Fades out on the next save. (#398)
146 + 'constants_when' => array(
147 + 'XSPEED_OC_HOST' => array( 'constant' => 'XSPEED_OC_BACKEND', 'is' => 'memcached' ),
148 + ),
149 +
150 + // Memcached has no constant convention the way Redis has
151 + // WP_REDIS_*; $memcached_servers IS the convention (W3TC, the
152 + // Memcached Object Cache drop-in), and hosts write it. The
153 + // drop-in already honoured it while the panel did not. (#398)
154 + 'global_source' => array(
155 + 'var' => 'memcached_servers',
156 + 'reader' => array( '\\XSpeed\\Object_Cache', 'first_memcached_server' ),
157 + 'slot' => 0,
158 + ),
159 + 'label' => __( 'Memcached host', 'xspeed' ),
160 + 'description' => __( 'Address of the Memcached server. Use 127.0.0.1 when it runs on the same server as your site.', 'xspeed' ),
161 + 'dependsOn' => array( 'field' => 'backend', 'value' => 'memcached' ),
85 162 ),
86 163 'memcached_port' => array(
87 - 'type' => 'int',
88 - 'default' => 11211,
89 - 'min' => 1,
90 - 'max' => 65535,
91 - 'label' => 'Memcached Port',
92 - 'description' => 'Default Memcached port is 11211.',
164 + 'type' => 'int',
165 + 'default' => 11211,
166 + 'min' => 1,
167 + 'max' => 65535,
168 + 'constants' => array( 'XSPEED_OC_MC_PORT', 'XSPEED_OC_PORT' ),
169 + // XSPEED_OC_HOST/PORT are the pre-split names, kept so an install
170 + // configured before the split keeps its host on upgrade. Gated on
171 + // the backend actually being Memcached, or a Redis site would read
172 + // the Redis port here. Fades out on the next save. (#398)
173 + 'constants_when' => array(
174 + 'XSPEED_OC_PORT' => array( 'constant' => 'XSPEED_OC_BACKEND', 'is' => 'memcached' ),
175 + ),
176 +
177 + 'global_source' => array(
178 + 'var' => 'memcached_servers',
179 + 'reader' => array( '\\XSpeed\\Object_Cache', 'first_memcached_server' ),
180 + 'slot' => 1,
181 + ),
182 + 'label' => __( 'Memcached port', 'xspeed' ),
183 + 'description' => __( 'The default Memcached port is 11211.', 'xspeed' ),
184 + 'advanced' => true,
185 + 'dependsOn' => array( 'field' => 'backend', 'value' => 'memcached' ),
93 186 ),
94 187 'key_prefix' => array(
95 188 'type' => 'string',
96 189 'default' => '',
97 - 'label' => 'Cache Key Prefix',
98 - 'description' => 'Unique salt for this site\'s cache keys. Critical when multiple WP sites share one Redis/Memcached server.',
190 + // ONE field for both backends on purpose: the drop-in has one
191 + // salt and applies it identically either way (full_key()), so
192 + // splitting it would be two controls over one value.
193 + //
194 + // Both names are honoured whichever backend is selected.
195 + // WP_REDIS_PREFIX reads oddly on a Memcached site, but a site
196 + // that has defined it has said what namespace it wants, and
197 + // ignoring that to keep the label tidy would be the panel
198 + // disagreeing with the drop-in -- the exact bug this closes.
199 + // The field's own description carries the explanation. (#398)
200 + //
201 + // WP_CACHE_KEY_SALT is deliberately NOT declared here (#430):
202 + // it is WordPress's own cache-uniqueness salt, present and
203 + // random on nearly every install, not a namespace declaration.
204 + // Listing it pinned this field on that random value and locked
205 + // editing -- while the override path already refused to treat
206 + // it as a foreign authority, so "Manage here" could never
207 + // unlock the field. With no salt of our own the field stays
208 + // blank and editable; the drop-in still honours a defined
209 + // WP_CACHE_KEY_SALT as its last-resort salt at runtime.
210 + 'constants' => array( 'XSPEED_OC_SALT', 'WP_REDIS_PREFIX' ),
211 + 'label' => __( 'Cache key prefix', 'xspeed' ),
212 + 'description' => __( 'Leave blank and xSpeed picks a unique prefix for this site. Some managed Redis hosts, such as xCloud, give you a key to paste here, or nothing gets saved.', 'xspeed' ),
213 + // Not advanced: on ACL hosts it is required, and the write-denied
214 + // notice (Object_Cache::write_denied_message) sends users here.
99 215 ),
100 216 'connection_timeout' => array(
101 217 'type' => 'int',
102 218 'default' => 1,
@@ -101,20 +217,417 @@
101 217 'type' => 'int',
102 218 'default' => 1,
103 219 'min' => 0,
104 220 'max' => 60,
105 - 'label' => 'Connection Timeout (seconds)',
106 - 'description' => 'How long to wait for a connection. Keep low (1-2s) so a misconfigured cache never stalls the page.',
221 + 'constants' => array( 'XSPEED_OC_TIMEOUT', 'WP_REDIS_TIMEOUT' ),
222 + 'label' => __( 'Connection timeout (seconds)', 'xspeed' ),
223 + 'unit' => 'seconds',
224 + 'description' => __( 'How long to wait for the cache server. Keep it at 1 or 2 seconds so a broken cache server never holds up a page.', 'xspeed' ),
225 + 'advanced' => true,
107 226 ),
108 227 'persistent' => array(
109 228 'type' => 'bool',
110 229 'default' => true,
111 - 'label' => 'Persistent Connections',
112 - 'description' => 'Reuse the connection across PHP requests when supported. Generally a win unless the cache server complains about idle connections.',
230 + 'constants' => array( 'XSPEED_OC_PERSISTENT', 'WP_REDIS_PERSISTENT' ),
231 + 'label' => __( 'Persistent connections', 'xspeed' ),
232 + 'description' => __( 'Keep the connection open between page loads. Turn off only if your cache server reports too many idle connections.', 'xspeed' ),
233 + 'advanced' => true,
113 234 ),
114 235 );
115 236 }
116 237
238 + /**
239 + * Encrypt the pre-1.1.0 plaintext redis_password on upgrade — it became a
240 + * `secret`-typed field (encrypted at rest). Idempotent. (#115)
241 + */
242 + public function migrations(): array {
243 + return array(
244 + '1.1.0' => static function ( array $opts ): array {
245 + if ( isset( $opts['redis_password'] ) && is_string( $opts['redis_password'] ) && '' !== $opts['redis_password'] ) {
246 + $opts['redis_password'] = \XSpeed\Settings_Manager::encrypt_for_storage( $opts['redis_password'] );
247 + }
248 + return $opts;
249 + },
250 + );
251 + }
252 +
253 + /** Counts failed drop-in sync attempts so a permanent failure stops retrying. */
254 + private const SYNC_ATTEMPTS_OPTION = 'xspeed_oc_sync_attempts';
255 +
256 + /**
257 + * Verdict of the write probe run after the last save. Read by the status
258 + * card so a backend that silently refuses writes cannot keep reporting a
259 + * healthy cache. (#398)
260 + */
261 + private const WRITE_PROBE_OPTION = 'xspeed_oc_write_probe';
262 +
263 + /**
264 + * The recorded write failure, if it is still true right now.
265 + *
266 + * The probe is written on save, so anything that fixes the backend WITHOUT
267 + * a save -- `objcache revert` rescuing a bad key prefix is the case that
268 + * matters -- left the warning standing over a cache that had recovered. Any
269 + * reader would then cry wolf until the next save. Re-checking before
270 + * reporting keeps one stored probe honest for both readers, and clears it
271 + * so the recheck happens once rather than on every status call. (#398)
272 + *
273 + * @return array{ok:bool,message:string,at:int}|null Null when writes are fine.
274 + */
275 + private static function live_write_failure(): ?array {
276 + $probe = get_option( self::WRITE_PROBE_OPTION, array() );
277 + if ( ! is_array( $probe ) || ! array_key_exists( 'ok', $probe ) || ! empty( $probe['ok'] ) ) {
278 + return null;
279 + }
280 + if ( ! Object_Cache::is_our_dropin_present() ) {
281 + delete_option( self::WRITE_PROBE_OPTION );
282 + return null;
283 + }
284 +
285 + /*
286 + * Throttle the recheck. This reader sits on rest_detect(), which the
287 + * panel polls, and on every `objcache status` -- and the recheck is a
288 + * real TCP connect plus a SET/GET/DEL round trip. On a backend that is
289 + * DOWN rather than merely refusing writes the probe never clears, so
290 + * without this every poll blocks for the full timeout and the object
291 + * cache screen feels hung at exactly the moment someone is trying to
292 + * fix it. A stale-by-a-minute warning is the cheaper error. (#398)
293 + */
294 + $checked = (int) ( $probe['rechecked_at'] ?? 0 );
295 + if ( $checked > 0 && ( time() - $checked ) < MINUTE_IN_SECONDS ) {
296 + return array(
297 + 'ok' => false,
298 + 'message' => (string) ( $probe['message'] ?? '' ),
299 + 'at' => (int) ( $probe['at'] ?? 0 ),
300 + );
301 + }
302 +
303 + /*
304 + * Recheck against the STORED row, not the resolved settings. Within the
305 + * request that saved the value, our block has been rewritten but PHP
306 + * has already defined those constants and cannot redefine them -- so
307 + * the resolved read still returns the PRE-save value. Rechecking that
308 + * tests a configuration the site is no longer being asked to use, and a
309 + * healthy answer would clear a probe that is genuinely true, putting
310 + * the silent-dead-cache bug straight back. The row is what the next
311 + * request's block is built from, so it is what the probe describes.
312 + * (#398)
313 + */
314 + $opts = \XSpeed\Settings_Manager::stored_with_defaults( self::SLUG );
315 + $opts['connection_timeout'] = min( 2, max( 1, (int) ( $opts['connection_timeout'] ?? 1 ) ) );
316 + $now = Object_Cache::test_connection( $opts );
317 + if ( ! empty( $now['ok'] ) ) {
318 + delete_option( self::WRITE_PROBE_OPTION );
319 + return null;
320 + }
321 +
322 + $still = array(
323 + 'ok' => false,
324 + 'message' => (string) ( $now['message'] ?? ( $probe['message'] ?? '' ) ),
325 + 'at' => (int) ( $probe['at'] ?? 0 ),
326 + 'rechecked_at' => time(),
327 + );
328 + update_option( self::WRITE_PROBE_OPTION, $still, false );
329 +
330 + unset( $still['rechecked_at'] );
331 + return $still;
332 + }
333 +
334 + /** Give up after this many failed syncs for one plugin version. */
335 + private const MAX_SYNC_ATTEMPTS = 5;
336 +
337 + public function boot(): void {
338 + // A saved override is promoted into our wp-config block, then the field
339 + // re-locks reading OUR constant. This is what makes "edit once, lock
340 + // again" honest: the drop-in loads before WordPress and cannot read an
341 + // option row, so a value that stayed in the database would be a setting
342 + // the panel showed and the runtime ignored. (#398)
343 + /*
344 + * Mirror every save into our own store, so the drop-in -- which loads
345 + * before WordPress and cannot read an option row -- sees what the panel
346 + * just wrote. Only OUR block is rewritten: a define the host owns is
347 + * left alone by wp_config_block()'s pinned_elsewhere() check, and when
348 + * wp-config.php is read-only the write lands in the sidecar instead.
349 + *
350 + * Without this a save updated the option row while the block kept the
351 + * previous value, and the constant outranks the row -- so the panel
352 + * reported success and the site went on using the old setting. (#398)
353 + */
354 + /*
355 + * Answer the backend gate from the sidecar when no constant defines it.
356 + * The legacy Memcached fallback (XSPEED_OC_HOST/PORT) is gated on the
357 + * backend being Memcached, and on a read-only-wp-config host that fact
358 + * lives in the sidecar -- so without this the panel reported the
359 + * default host while the drop-in used the real one. (#398)
360 + */
361 + /*
362 + * Answer settings reads from the sidecar. On a host where wp-config.php
363 + * is read-only the sidecar IS the configuration, so a panel that read
364 + * only the option row would show the stored values while the drop-in
365 + * ran on the sidecar's -- the panel/runtime split this change exists to
366 + * close. Constants still win; this sits between them and the row. (#398)
367 + */
368 + add_filter(
369 + 'xspeed_setting_external_source',
370 + static function ( $value, string $slug, string $key ) {
371 + if ( null !== $value || self::SLUG !== $slug ) {
372 + return $value;
373 + }
374 + $sidecar = Object_Cache::read_sidecar();
375 + return array_key_exists( $key, $sidecar ) ? $sidecar[ $key ] : null;
376 + },
377 + 10,
378 + 3
379 + );
380 +
381 + add_filter(
382 + 'xspeed_constant_gate_value',
383 + static function ( $value, string $constant ) {
384 + if ( null !== $value || 'XSPEED_OC_BACKEND' !== $constant ) {
385 + return $value;
386 + }
387 + $sidecar = Object_Cache::read_sidecar();
388 + return $sidecar['backend'] ?? null;
389 + },
390 + 10,
391 + 2
392 + );
393 +
394 + add_action(
395 + 'xspeed_settings_saved',
396 + static function ( string $slug, array $clean ): void {
397 + if ( self::SLUG !== $slug ) {
398 + return;
399 + }
400 + // Only when we already own a block or a sidecar. Creating one
401 + // on a site that never enabled the object cache would write to
402 + // wp-config.php for a feature that is switched off.
403 + /*
404 + * Mirror whenever the drop-in is installed -- that, not the
405 + * presence of a store, is what makes a DB-only value a setting
406 + * the panel shows and the runtime ignores. Keying on the store
407 + * instead could strand a site whose block write once failed:
408 + * with no block and no sidecar the guard would return early
409 + * forever and saves would stop being mirrored. (#398)
410 + */
411 + if ( ! Object_Cache::is_our_dropin_present() ) {
412 + return;
413 + }
414 + Object_Cache::write_wp_config( $clean );
415 +
416 + /*
417 + * Prove the backend still ACCEPTS WRITES with the settings just
418 + * saved. On a namespaced/ACL Redis a key prefix outside the
419 + * granted namespace is refused with NOPERM, and the drop-in
420 + * swallows that -- so the panel reported success, the status
421 + * card kept saying "On", and the site paid for a cache that
422 + * stored nothing. Only an explicit Test connection revealed it.
423 + *
424 + * Recorded rather than thrown: the save itself DID land, and
425 + * failing it would leave the panel and the row disagreeing. The
426 + * status card reads this and says so. (#398)
427 + */
428 + if ( Object_Cache::is_our_dropin_present() ) {
429 + /*
430 + * Clamp the probe's timeout. It runs inside an admin POST,
431 + * and connection_timeout is the user's own setting -- a
432 + * save that points at a black-holed IP would otherwise
433 + * block the request for as long as they typed. The probe is
434 + * a check, not the connection the site runs on, so a short
435 + * ceiling costs nothing.
436 + */
437 + $probe_opts = $clean;
438 + $probe_opts['connection_timeout'] = min( 2, max( 1, (int) ( $clean['connection_timeout'] ?? 1 ) ) );
439 + $probe = Object_Cache::test_connection( $probe_opts );
440 + update_option(
441 + self::WRITE_PROBE_OPTION,
442 + array(
443 + 'ok' => ! empty( $probe['ok'] ),
444 + 'message' => (string) ( $probe['message'] ?? '' ),
445 + 'at' => time(),
446 + ),
447 + false
448 + );
449 + }
450 + },
451 + 10,
452 + 2
453 + );
454 +
455 + /*
456 + * Ownership of a field changed hands, so our block no longer reflects
457 + * what should be in it. On a revert this is the step that actually
458 + * frees the field: Settings_Manager::revert() has dropped our stored
459 + * value, and rewriting the block from the settings as they NOW resolve
460 + * is what removes our define and lets the host's constant win again.
461 + *
462 + * Without this listener the hook fired into nothing, the define stayed,
463 + * and "Use the host value" was a no-op in the panel while the CLI --
464 + * which did the same work inline -- worked. (#398)
465 + */
466 + add_action(
467 + 'xspeed_setting_override_changed',
468 + static function ( string $slug, string $key, bool $on ): void {
469 + unset( $key );
470 + if ( self::SLUG !== $slug || ! Object_Cache::is_our_dropin_present() ) {
471 + return;
472 + }
473 + /*
474 + * Only on a REVERT, and only when nothing else is mid-flight.
475 + *
476 + * update() also lifts an override as its last act, having just
477 + * promoted the typed value into our block -- a rewrite here
478 + * would resolve the host's constant again and erase the define
479 + * that save had only just written, silently undoing the edit.
480 + * A revert is the one case where erasing our define IS the
481 + * point. (#398)
482 + */
483 + if ( $on || \XSpeed\Settings_Manager::is_promoting( self::SLUG ) ) {
484 + return;
485 + }
486 + Object_Cache::write_wp_config( \XSpeed\Settings_Manager::get( self::SLUG ) );
487 + },
488 + 10,
489 + 3
490 + );
491 +
492 + add_action(
493 + 'xspeed_settings_promote_to_config',
494 + static function ( string $slug, array $values ): void {
495 + if ( self::SLUG !== $slug ) {
496 + return;
497 + }
498 + // The block is CREATED here if absent, unlike the passive
499 + // rewrites elsewhere. This write is the thing the admin just
500 + // asked for and was warned about; refusing it because the
501 + // object cache is not enabled yet would make the save a silent
502 + // no-op -- the failure mode this whole contract exists to
503 + // prevent. (#398)
504 + // Forced: these fields are exactly the ones a foreign define
505 + // still pins, which is why they were overridden in the first
506 + // place. Without the force list pinned_elsewhere() would drop
507 + // them and the save would write nothing.
508 + Object_Cache::write_wp_config(
509 + array_merge( \XSpeed\Settings_Manager::get( self::SLUG ), $values ),
510 + array_keys( $values )
511 + );
512 + },
513 + 10,
514 + 2
515 + );
516 +
517 +
518 + // Keep the deployed drop-in in sync with the shipped template. It is
519 + // copied into wp-content/object-cache.php on enable and then never
520 + // touched again — so a fix shipped in a plugin update (e.g. the
521 + // stale-alloptions eviction on failed backend writes, issue #41)
522 + // would never reach existing installs. Version-gated so the file
523 + // comparison runs once per plugin version, not on every admin load.
524 + add_action(
525 + 'admin_init',
526 + static function (): void {
527 + if ( get_option( 'xspeed_oc_dropin_synced', '' ) === XSPEED_VERSION ) {
528 + return;
529 + }
530 + $ok = true;
531 + if ( Object_Cache::is_our_dropin_present() ) {
532 + // Both steps run regardless of each other — they fail
533 + // independently (drop-in needs wp-content writable, the
534 + // backfill needs wp-config writable), and short-circuiting
535 + // would skip a backfill that could have succeeded.
536 + $installed = Object_Cache::install_dropin();
537 + $salted = self::backfill_key_salt();
538 + $ok = $installed && $salted;
539 + }
540 + // Only stamp the version when the sync actually succeeded. A
541 + // transient failure (wp-config momentarily unwritable, a
542 + // filesystem hiccup) then gets retried on a later admin load
543 + // rather than being recorded as migrated and left unsalted.
544 + //
545 + // Bounded, though: where the failure is permanent — a host that
546 + // ships a read-only wp-config — retrying forever would run
547 + // WP_Filesystem work on every single admin page load for no
548 + // gain. It is safe to stop, because the drop-in derives its own
549 + // salt when the constant is absent, so such an install is
550 + // namespaced either way; the constant is only the faster path.
551 + if ( $ok ) {
552 + update_option( 'xspeed_oc_dropin_synced', XSPEED_VERSION );
553 + delete_option( self::SYNC_ATTEMPTS_OPTION );
554 + return;
555 + }
556 +
557 + $attempts = (int) get_option( self::SYNC_ATTEMPTS_OPTION, 0 ) + 1;
558 + if ( $attempts >= self::MAX_SYNC_ATTEMPTS ) {
559 + update_option( 'xspeed_oc_dropin_synced', XSPEED_VERSION );
560 + delete_option( self::SYNC_ATTEMPTS_OPTION );
561 + return;
562 + }
563 + update_option( self::SYNC_ATTEMPTS_OPTION, $attempts );
564 + }
565 + );
566 + }
567 +
568 + /**
569 + * Backfill a per-site key salt on installs enabled before the salt became
570 + * mandatory.
571 + *
572 + * Enabling with a blank Cache Key Prefix used to write no salt constant at
573 + * all, leaving every key namespaced as `:{blog}:{group}:{key}` — identical
574 + * on every install. Two sites sharing one Redis/Memcached server then read
575 + * each other's `blog-details` / `blog-lookup` entries, and the second site
576 + * resolves to (and redirects to) the first.
577 + *
578 + * Rewriting wp-config re-emits the block with a derived salt.
579 + *
580 + * Order matters, and NOT the way it first appears. Flushing before the
581 + * rewrite looks right — it would drop the old unnamespaced entries rather
582 + * than stranding them — but the cache object serving this request was
583 + * constructed from the OLD, salt-less config. Asking it to flush is asking
584 + * an unsalted object to purge, which on Redis used to mean FLUSHDB and on
585 + * Memcached means flush_all(): either one destroys every neighbouring site
586 + * sharing the server. That is the exact failure this migration exists to
587 + * prevent, so we never flush through the stale object.
588 + *
589 + * Writing the config first means the NEXT request loads a properly salted
590 + * drop-in and simply starts using the new namespace. The old unnamespaced
591 + * keys are orphaned rather than deleted; they expire on their own, and they
592 + * are unreachable in the meantime because nothing builds those keys any
593 + * more.
594 + *
595 + * @return bool True when the install is namespaced afterwards.
596 + */
597 + private static function backfill_key_salt(): bool {
598 + $module = new self();
599 + $settings = $module->get_settings();
600 + $written = defined( 'XSPEED_OC_SALT' ) ? (string) constant( 'XSPEED_OC_SALT' ) : '';
601 +
602 + // Nothing written yet — the original backfill case (an install that
603 + // enabled the object cache before a salt was emitted at all).
604 + if ( '' === $written ) {
605 + return Object_Cache::write_wp_config( $settings );
606 + }
607 +
608 + // A salt IS written. Re-sync only when the user's typed Cache Key
609 + // Prefix disagrees with it, which is the ACL-remediation path: on a
610 + // namespaced host the admin types the host's "Redis Object Cache Key"
611 + // to stop NOPERM denials, but saving settings does not touch
612 + // wp-config — only enable() and this backfill do. Without this
613 + // comparison the drop-in kept reading the old constant while the
614 + // probe key used the new prefix, so Test connection reported success
615 + // while real writes were still denied: the same probe-vs-reality
616 + // divergence issue 2 set out to remove, just on a narrower path.
617 + //
618 + // Deliberately compared against the TYPED prefix, not
619 + // effective_salt(): effective_salt() returns the written constant
620 + // when the prefix is blank (so a warm cache is never orphaned by a
621 + // re-derivation), which would make this a no-op comparison.
622 + $typed = isset( $settings['key_prefix'] ) ? (string) $settings['key_prefix'] : '';
623 + if ( '' === $typed || $typed === $written ) {
624 + return true; // Correctly namespaced — leave the warm cache alone.
625 + }
626 +
627 + return Object_Cache::write_wp_config( $settings );
628 + }
629 +
117 630 public function rest_routes(): array {
118 631 $default = parent::rest_routes();
119 632 return array_merge(
120 633 $default,
@@ -153,9 +666,23 @@
153 666 );
154 667 }
155 668
156 669 public function rest_detect( \WP_REST_Request $request ) {
157 - return rest_ensure_response( Object_Cache::detect() );
670 + /*
671 + * Carry the write probe alongside detection. `detect()` answers "is a
672 + * drop-in installed and which backend" -- it cannot see that the
673 + * backend is CONNECTED but refusing writes, which is what a key prefix
674 + * outside an ACL namespace does (NOPERM, swallowed by the drop-in). The
675 + * probe recorded on save knows; until this it had no reader outside
676 + * WP-CLI, so the panel kept saying "Redis ready" over a cache that
677 + * stored nothing. (#398)
678 + */
679 + $detect = Object_Cache::detect( true );
680 + $failure = self::live_write_failure();
681 + if ( null !== $failure ) {
682 + $detect['write_probe'] = $failure;
683 + }
684 + return rest_ensure_response( $detect );
158 685 }
159 686
160 687 public function rest_flush( \WP_REST_Request $request ) {
161 688 $ok = Object_Cache::flush();
@@ -179,16 +706,55 @@
179 706 * Merge any settings sent in the request body over the saved settings, so
180 707 * the UI can "Test connection" with unsaved values. Only known keys pass.
181 708 */
182 709 private function settings_with_overrides( \WP_REST_Request $request ): array {
183 - $settings = $this->get_settings();
184 - $body = $request->get_json_params();
185 - if ( is_array( $body ) ) {
186 - foreach ( $settings as $key => $value ) {
187 - if ( array_key_exists( $key, $body ) ) {
188 - $settings[ $key ] = $body[ $key ];
189 - }
710 + $body = $request->get_json_params();
711 + return self::merge_overrides(
712 + $this->get_settings(),
713 + is_array( $body ) ? $body : array(),
714 + $this->settings_schema()
715 + );
716 + }
717 +
718 + /**
719 + * Overlay request-body values onto the stored settings for a one-off "Test
720 + * connection" — but NEVER let a masked secret echoed from the panel overwrite
721 + * the real stored value. The panel holds `Redi••••CRET`; without this guard,
722 + * clicking Test connection authenticates Redis with the mask and a correct
723 + * password reports as wrong. A genuinely new (typed) password still applies,
724 + * and an explicit empty value still tests the no-auth case. Static + pure so
725 + * it's unit-testable without a REST request. (QA B3)
726 + *
727 + * @param array<string,mixed> $settings Stored, decrypted settings.
728 + * @param array<string,mixed> $body Request overrides.
729 + * @param array<string,array> $schema The module schema (for secret detection).
730 + * @return array<string,mixed>
731 + */
732 + public static function merge_overrides( array $settings, array $body, array $schema ): array {
733 + foreach ( $settings as $key => $value ) {
734 + if ( ! array_key_exists( $key, $body ) ) {
735 + continue;
190 736 }
737 + if ( isset( $schema[ $key ] )
738 + && \XSpeed\Settings_Manager::is_secret_field( $key, $schema[ $key ] )
739 + && \XSpeed\Settings_Manager::is_masked_secret( (string) $body[ $key ] ) ) {
740 + continue;
741 + }
742 + // A field pinned by a wp-config.php constant is not overridable. Two
743 + // reasons, either sufficient: "Test connection" must exercise the
744 + // config the drop-in actually uses, or it answers a question nobody
745 + // asked; and an overridable host turns this admin endpoint into a
746 + // request-forgery probe against arbitrary internal addresses. The
747 + // panel posts every field it rendered, so the pinned value arrives
748 + // in the body on a normal Test click too. (#398)
749 + // write_blocking_constant(), not effective_constant(): a value we
750 + // wrote ourselves is this module's own storage, so the admin may
751 + // still type over it. Only somebody else's define is protected.
752 + if ( isset( $schema[ $key ] )
753 + && null !== \XSpeed\Settings_Manager::write_blocking_constant( self::SLUG, $key, $schema[ $key ] ) ) {
754 + continue;
755 + }
756 + $settings[ $key ] = $body[ $key ];
191 757 }
192 758 return $settings;
193 759 }
194 760
@@ -198,12 +764,22 @@
198 764
199 765 public function rest_enable( \WP_REST_Request $request ) {
200 766 // Persist any settings sent with the enable call first, then act on them.
201 767 $body = $request->get_json_params();
768 + // `takeover` is an action, not a setting: take it out before the body
769 + // is saved as the settings row. Sent in the body rather than the query
770 + // string because on plain permalinks the REST base already carries
771 + // `?rest_route=`. (#686)
772 + $takeover = is_array( $body ) && ! empty( $body['takeover'] )
773 + ? rest_sanitize_boolean( $body['takeover'] )
774 + : rest_sanitize_boolean( $request->get_param( 'takeover' ) );
775 + if ( is_array( $body ) ) {
776 + unset( $body['takeover'] );
777 + }
202 778 if ( is_array( $body ) && ! empty( $body ) ) {
203 779 \XSpeed\Settings_Manager::update( self::SLUG, $body );
204 780 }
205 - $result = Object_Cache::enable( $this->get_settings() );
781 + $result = Object_Cache::enable( $this->get_settings(), array( 'takeover' => $takeover ) );
206 782
207 783 if ( $result['ok'] && class_exists( '\\XSpeed\\Activity_Log' ) ) {
208 784 \XSpeed\Activity_Log::record(
209 785 'object_cache_enabled',
@@ -214,9 +790,11 @@
214 790 return rest_ensure_response( $result );
215 791 }
216 792
217 793 public function rest_disable( \WP_REST_Request $request ) {
218 - $result = Object_Cache::disable();
794 + $result = Object_Cache::disable(
795 + array( 'restore' => rest_sanitize_boolean( $request->get_param( 'restore' ) ) )
796 + );
219 797 if ( $result['ok'] && class_exists( '\\XSpeed\\Activity_Log' ) ) {
220 798 \XSpeed\Activity_Log::record(
221 799 'object_cache_disabled',
222 800 'Object cache disabled.',
@@ -230,16 +808,39 @@
230 808 return array(
231 809 array(
232 810 'name' => 'xspeed objcache',
233 811 'callback' => array( $this, 'cli_handler' ),
234 - 'shortdesc' => 'Show object cache status, flush, or print the wp-config snippet.',
812 + 'shortdesc' => 'Show object cache status, read or write a setting, flush, or print the wp-config snippet.',
813 + 'ai_hint' => 'Is a persistent object cache (Redis/Memcached) connected and working? Use for slow admin pages, high database load, or "should I add Redis" questions — it reports the backend, connection health and hit rate. `get`/`set <key> [value]` read and write individual settings; `status` also reports whether each value comes from wp-config.php or the database.',
235 814 'synopsis' => array(
236 815 array(
237 816 'type' => 'positional',
238 817 'name' => 'action',
239 - 'options' => array( 'status', 'flush', 'snippet', 'enable', 'disable', 'test' ),
818 + 'options' => array( 'status', 'flush', 'snippet', 'enable', 'disable', 'test', 'get', 'set', 'override', 'revert' ),
240 819 'optional' => true,
241 820 ),
821 + array(
822 + 'type' => 'positional',
823 + 'name' => 'key',
824 + 'optional' => true,
825 + ),
826 + array(
827 + 'type' => 'positional',
828 + 'name' => 'value',
829 + 'optional' => true,
830 + ),
831 + array(
832 + 'type' => 'flag',
833 + 'name' => 'takeover',
834 + 'description' => 'With enable: switch from the plugin that owns object-cache.php (backs it up, turns that plugin off, installs xSpeed, undoes everything on failure).',
835 + 'optional' => true,
836 + ),
837 + array(
838 + 'type' => 'flag',
839 + 'name' => 'restore',
840 + 'description' => 'With disable: put back the plugin xSpeed switched from.',
841 + 'optional' => true,
842 + ),
242 843 ),
243 844 ),
244 845 );
245 846 }
@@ -252,9 +853,139 @@
252 853 \WP_CLI::log( 'drop-in installed: ' . ( $d['drop_in_installed'] ? 'yes' : 'no' ) );
253 854 \WP_CLI::log( 'label: ' . $d['drop_in_label'] );
254 855 \WP_CLI::log( 'backend: ' . $d['backend'] );
255 856 \WP_CLI::log( 'ext object cache: ' . ( $d['wp_cache_active'] ? 'yes' : 'no' ) );
857 +
858 + // A backend that connects but refuses WRITES looks identical to
859 + // a healthy one everywhere else -- that is the whole failure.
860 + $probe = self::live_write_failure();
861 + if ( null !== $probe ) {
862 + \WP_CLI::warning(
863 + 'writes refused: ' . ( $probe['message'] ?: 'unknown error' )
864 + . ' — the cache is connected but storing nothing.'
865 + );
866 + }
867 +
868 + // Per value, where it came from. On a host-provisioned site the
869 + // difference between "wp-config.php" and "database" is the whole
870 + // question when the cache is not behaving. (#398)
871 + \WP_CLI::log( '' );
872 + \WP_CLI::log( 'settings:' );
873 + $settings = \XSpeed\Settings_Manager::get_public( self::SLUG );
874 + $origins = \XSpeed\Settings_Manager::origins( self::SLUG );
875 + $schema = $this->settings_schema();
876 + foreach ( $schema as $key => $spec ) {
877 + $origin = $origins[ $key ] ?? array(
878 + 'source' => 'default',
879 + 'constant' => null,
880 + );
881 + $source = 'constant' === $origin['source']
882 + ? 'wp-config.php: ' . $origin['constant']
883 + : $origin['source'];
884 + \WP_CLI::log(
885 + sprintf(
886 + ' %-20s %-24s [%s]',
887 + $key,
888 + self::scalar_for_display( $settings[ $key ] ?? null ),
889 + $source
890 + )
891 + );
892 + }
256 893 return;
894 + case 'override':
895 + case 'revert':
896 + $key = $args[1] ?? '';
897 + $schema = $this->settings_schema();
898 + if ( '' === $key || ! array_key_exists( $key, $schema ) ) {
899 + \WP_CLI::error( 'Unknown setting: ' . ( '' === $key ? '(none given)' : $key ) . '. Run `wp xspeed objcache status` for the list.' );
900 + }
901 + $on = 'override' === $action;
902 + if ( $on && null === \XSpeed\Settings_Manager::constant_source( $schema[ $key ] ) ) {
903 + \WP_CLI::error( sprintf( '"%s" is not defined in wp-config.php, so there is nothing to override.', $key ) );
904 + }
905 + /*
906 + * Reverting hands a field BACK to the host, so it needs a host
907 + * define to hand it back to, and it has to REMOVE our own
908 + * define rather than only dropping the override entry -- ours
909 + * outranks the host's, so leaving it in place meant the field
910 + * kept our value and a later credential rotation was ignored
911 + * for good. Both rules live in Settings_Manager::revert() so
912 + * this command and the panel's button cannot drift. (#398)
913 + */
914 + $foreign = null;
915 + if ( $on ) {
916 + \XSpeed\Settings_Manager::set_override( self::SLUG, $key, true );
917 + } else {
918 + $reverted = \XSpeed\Settings_Manager::revert( self::SLUG, $key );
919 + if ( is_wp_error( $reverted ) ) {
920 + \WP_CLI::error(
921 + 'xspeed_nothing_to_revert' === $reverted->get_error_code()
922 + ? sprintf(
923 + '"%s" is not set in wp-config.php by your host, so there is nothing to revert to. Set it to the value you want with `wp xspeed objcache set %s <value>`.',
924 + $key,
925 + $key
926 + )
927 + : $reverted->get_error_message()
928 + );
929 + }
930 + $foreign = $reverted;
931 + }
932 +
933 + $origin = \XSpeed\Settings_Manager::origins( self::SLUG )[ $key ] ?? array( 'source' => 'db' );
934 + \WP_CLI::success(
935 + $on
936 + ? sprintf( '%s is now managed here. Set it with `wp xspeed objcache set %s <value>`.', $key, $key )
937 + : sprintf( '%s handed back to your host\'s %s.', $key, (string) $foreign )
938 + );
939 + return;
940 + case 'get':
941 + $key = $args[1] ?? '';
942 + if ( '' === $key || ! array_key_exists( $key, $this->settings_schema() ) ) {
943 + \WP_CLI::error( 'Unknown setting: ' . ( '' === $key ? '(none given)' : $key ) . '. Run `wp xspeed objcache status` for the list.' );
944 + }
945 + // get_public(), so a credential is never printed to a terminal
946 + // or captured in a CI log.
947 + $settings = \XSpeed\Settings_Manager::get_public( self::SLUG );
948 + \WP_CLI::log( self::scalar_for_display( $settings[ $key ] ?? null ) );
949 + return;
950 + case 'set':
951 + $key = $args[1] ?? '';
952 + if ( '' === $key || ! array_key_exists( $key, $this->settings_schema() ) ) {
953 + \WP_CLI::error( 'Unknown setting: ' . ( '' === $key ? '(none given)' : $key ) . '. Run `wp xspeed objcache status` for the list.' );
954 + }
955 + if ( ! array_key_exists( 2, $args ) ) {
956 + \WP_CLI::error( 'No value given. Usage: wp xspeed objcache set <key> <value>' );
957 + }
958 +
959 + // Fail loudly rather than writing a row that get() will never
960 + // read back. A silent no-op is the worst outcome here: the
961 + // automation reports success and nothing changed. (#398)
962 + $locked = \XSpeed\Settings_Manager::locked_in_input( self::SLUG, array( $key => $args[2] ) );
963 + if ( isset( $locked[ $key ] ) ) {
964 + \WP_CLI::error(
965 + sprintf(
966 + '"%1$s" is defined in wp-config.php as %2$s, so it cannot be set here. Edit that constant, or run `wp xspeed objcache override %1$s` to manage it here instead.',
967 + $key,
968 + $locked[ $key ]
969 + )
970 + );
971 + }
972 +
973 + $this->update_settings( array( $key => $args[2] ) );
974 +
975 + // Read back rather than echoing the input: coercion may have
976 + // clamped or rejected it, and reporting the input would claim a
977 + // write that did not land as typed.
978 + //
979 + // From the OPTION ROW, not get_public(): the save also rewrites
980 + // our wp-config block, but PHP has already defined those
981 + // constants for this request and cannot redefine them -- so
982 + // get_public() would resolve the constant and report the value
983 + // from BEFORE the write, making a successful save look ignored.
984 + // The row is what the next request's block was built from. (#398)
985 + $after = \XSpeed\Settings_Manager::get_public( self::SLUG, true );
986 + \WP_CLI::success( $key . ' = ' . self::scalar_for_display( $after[ $key ] ?? null ) );
987 + return;
257 988 case 'flush':
258 989 $ok = Object_Cache::flush();
259 990 $ok ? \WP_CLI::success( 'Flushed.' ) : \WP_CLI::error( 'Flush failed.' );
260 991 return;
@@ -265,16 +996,80 @@
265 996 $t = Object_Cache::test_connection( $this->get_settings() );
266 997 $t['ok'] ? \WP_CLI::success( $t['message'] ) : \WP_CLI::error( $t['message'] );
267 998 return;
268 999 case 'enable':
269 - $r = Object_Cache::enable( $this->get_settings() );
1000 + $r = Object_Cache::enable(
1001 + $this->get_settings(),
1002 + array( 'takeover' => ! empty( $assoc['takeover'] ) )
1003 + );
1004 + if ( ! $r['ok'] && ! empty( $r['needs_takeover'] ) && Object_Cache_Takeover::STRATEGY_REFUSE !== ( $r['owner']['strategy'] ?? '' ) ) {
1005 + // This handler also answers MCP (through Cli_Bridge), where
1006 + // the switch is an argument, not a flag.
1007 + \WP_CLI::error( $r['message'] . ' To switch, run again with --takeover (MCP: takeover: true).' );
1008 + }
270 1009 $r['ok'] ? \WP_CLI::success( $r['message'] ) : \WP_CLI::error( $r['message'] );
271 1010 return;
272 1011 case 'disable':
273 - $r = Object_Cache::disable();
1012 + $r = Object_Cache::disable( array( 'restore' => ! empty( $assoc['restore'] ) ) );
1013 + // Disabling worked; a restore that could not complete is a
1014 + // warning, not a failed command.
1015 + if ( $r['ok'] && false === ( $r['restored'] ?? null ) ) {
1016 + \WP_CLI::warning( $r['message'] );
1017 + \WP_CLI::success( 'Object cache disabled.' );
1018 + return;
1019 + }
274 1020 $r['ok'] ? \WP_CLI::success( $r['message'] ) : \WP_CLI::error( $r['message'] );
275 1021 return;
276 1022 default:
277 1023 \WP_CLI::error( "Unknown action: $action" );
278 1024 }
1025 + }
1026 +
1027 + /**
1028 + * Render one setting for a terminal. Bools read as true/false rather than
1029 + * 1/"", and an empty string is shown as (empty) so a blank line is never
1030 + * mistaken for a missing key.
1031 + *
1032 + * @param mixed $value Setting value, already masked if secret.
1033 + */
1034 + private static function scalar_for_display( $value ): string {
1035 + if ( is_bool( $value ) ) {
1036 + return $value ? 'true' : 'false';
1037 + }
1038 + if ( null === $value ) {
1039 + return '(unset)';
1040 + }
1041 + if ( is_array( $value ) ) {
1042 + return (string) wp_json_encode( $value );
1043 + }
1044 + $value = (string) $value;
1045 + return '' === $value ? '(empty)' : $value;
1046 + }
1047 +
1048 + /**
1049 + * The object cache is on when OUR drop-in is installed and actually
1050 + * persisting -- not when a backend host is merely typed into the
1051 + * settings. `detect()` reads the running instance, so a drop-in that is
1052 + * installed but degraded (connected to nothing) correctly reports off
1053 + * rather than claiming a cache the site is not getting. (#363)
1054 + */
1055 + public function is_active(): ?bool {
1056 + $state = Object_Cache::detect();
1057 + return ! empty( $state['persistent'] );
1058 + }
1059 +
1060 + /**
1061 + * Configured is not the same as working, and the difference is the whole
1062 + * point here -- a drop-in connected to nothing reports on to WordPress
1063 + * while persisting no data. Report what is actually happening.
1064 + */
1065 + public function active_reason(): ?string {
1066 + $state = Object_Cache::detect();
1067 + if ( ! empty( $state['persistent'] ) ) {
1068 + return __( 'The object cache drop-in is installed and storing data. This is measured from the running cache, not from the settings on this page.', 'xspeed' );
1069 + }
1070 + if ( ! empty( $state['degraded'] ) ) {
1071 + return __( 'The drop-in is installed but is not storing anything, so this counts as off. Check the connection settings below.', 'xspeed' );
1072 + }
1073 + return __( 'No object cache is running. Entering a host below does not switch it on by itself -- the drop-in has to be installed and connect successfully.', 'xspeed' );
279 1074 }
280 1075 }