PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
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 1.1.3 1.1.4 All 33 releases
xspeed / includes / modules / ObjectCache / ObjectCacheModule.php

ObjectCacheModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.7, at includes/modules/ObjectCache/ObjectCacheModule.php

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