PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 All 36 releases
xspeed / includes / modules / ObjectCache / ObjectCacheModule.php

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

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