PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.1
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 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / modules / ObjectCache / ObjectCacheModule.php

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

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