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