PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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
← All changes | includes/modules/ObjectCache/ObjectCacheModule.php +837 -32 1.0.31.3.2 View file →
@@ -15,8 +15,10 @@
15 15 declare(strict_types=1);
16 16
17 17 namespace XSpeed\Modules\ObjectCache;
18 18
19 +defined( 'ABSPATH' ) || exit;
20 +
19 21 use XSpeed\Module;
20 22 use XSpeed\Object_Cache;
21 23
22 24 final class ObjectCacheModule extends Module {
@@ -22,19 +24,31 @@
22 24 final class ObjectCacheModule extends Module {
23 25
24 26 public const SLUG = 'object-cache';
25 27 public const TIER = self::TIER_FREE;
26 - public const VERSION = '1.0.0';
28 + public const VERSION = '1.1.0';
27 29
28 30 public function ui_metadata(): array {
29 31 return array(
30 - 'label' => 'Object Cache',
32 + 'label' => __( 'Object Cache', 'xspeed' ),
31 33 'icon' => 'Server',
32 - 'description' => 'Configure a persistent object cache (Redis / Memcached) and generate a paste-ready wp-config.php snippet.',
34 + 'description' => __( 'Configure a persistent object cache (Redis / Memcached) and generate a paste-ready wp-config.php snippet.', 'xspeed' ),
33 35 'custom_panel' => 'ObjectCachePanel',
34 36 );
35 37 }
36 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 +
37 51 public function settings_schema(): array {
38 52 return array(
39 53 'backend' => array(
40 54 'type' => 'enum',
@@ -43,16 +57,17 @@
43 57 'option_labels' => array(
44 58 'redis' => 'Redis',
45 59 'memcached' => 'Memcached',
46 60 ),
47 - 'label' => 'Backend',
48 - 'description' => 'Which cache server you intend to use. Affects the generated wp-config snippet.',
61 + 'label' => __( 'Backend', 'xspeed' ),
62 + 'description' => __( 'Which cache server you intend to use. Affects the generated wp-config snippet.', 'xspeed' ),
49 63 ),
50 64 'redis_host' => array(
51 65 'type' => 'string',
52 66 'default' => '127.0.0.1',
53 - 'label' => 'Redis Host',
54 - 'description' => 'Hostname or IP of the Redis server. Use 127.0.0.1 for a local socket on the same machine as PHP.',
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' ),
55 70 ),
56 71 'redis_port' => array(
57 72 'type' => 'int',
58 73 'default' => 6379,
@@ -57,16 +72,32 @@
57 72 'type' => 'int',
58 73 'default' => 6379,
59 74 'min' => 1,
60 75 'max' => 65535,
61 - 'label' => 'Redis Port',
62 - 'description' => 'Default Redis port is 6379.',
76 + 'constants' => array( 'XSPEED_OC_PORT', 'WP_REDIS_PORT' ),
77 + 'label' => __( 'Redis Port', 'xspeed' ),
78 + 'description' => __( 'Default Redis port is 6379.', 'xspeed' ),
63 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 + ),
64 93 'redis_password' => array(
65 - 'type' => 'string',
66 - 'default' => '',
67 - 'label' => 'Redis Password',
68 - 'description' => 'Leave blank if your Redis server runs without auth.',
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' ),
69 100 ),
70 101 'redis_database' => array(
71 102 'type' => 'int',
72 103 'default' => 0,
@@ -71,30 +102,88 @@
71 102 'type' => 'int',
72 103 'default' => 0,
73 104 'min' => 0,
74 105 'max' => 15,
75 - 'label' => 'Redis Database',
76 - 'description' => 'Redis logical DB number (0-15). Use a dedicated DB per site if Redis is shared.',
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' ),
77 109 ),
78 110 'memcached_host' => array(
79 111 'type' => 'string',
80 112 'default' => '127.0.0.1',
81 - 'label' => 'Memcached Host',
82 - 'description' => 'Used when Backend = Memcached.',
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' ),
83 137 ),
84 138 'memcached_port' => array(
85 - 'type' => 'int',
86 - 'default' => 11211,
87 - 'min' => 1,
88 - 'max' => 65535,
89 - 'label' => 'Memcached Port',
90 - 'description' => 'Default Memcached port is 11211.',
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' ),
91 159 ),
92 160 'key_prefix' => array(
93 161 'type' => 'string',
94 162 'default' => '',
95 - 'label' => 'Cache Key Prefix',
96 - 'description' => 'Unique salt for this site\'s cache keys. Critical when multiple WP sites share one Redis/Memcached server.',
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' ),
97 186 ),
98 187 'connection_timeout' => array(
99 188 'type' => 'int',
100 189 'default' => 1,
@@ -99,20 +188,415 @@
99 188 'type' => 'int',
100 189 'default' => 1,
101 190 'min' => 0,
102 191 'max' => 60,
103 - 'label' => 'Connection Timeout (seconds)',
104 - 'description' => 'How long to wait for a connection. Keep low (1-2s) so a misconfigured cache never stalls the page.',
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' ),
105 196 ),
106 197 'persistent' => array(
107 198 'type' => 'bool',
108 199 'default' => true,
109 - 'label' => 'Persistent Connections',
110 - 'description' => 'Reuse the connection across PHP requests when supported. Generally a win unless the cache server complains about idle connections.',
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' ),
111 203 ),
112 204 );
113 205 }
114 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 +
115 599 public function rest_routes(): array {
116 600 $default = parent::rest_routes();
117 601 return array_merge(
118 602 $default,
@@ -131,14 +615,43 @@
131 615 'path' => '/snippet',
132 616 'methods' => 'GET',
133 617 'callback' => array( $this, 'rest_snippet' ),
134 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 + ),
135 634 )
136 635 );
137 636 }
138 637
139 638 public function rest_detect( \WP_REST_Request $request ) {
140 - return rest_ensure_response( Object_Cache::detect() );
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 );
141 654 }
142 655
143 656 public function rest_flush( \WP_REST_Request $request ) {
144 657 $ok = Object_Cache::flush();
@@ -157,21 +670,122 @@
157 670 array( 'snippet' => Object_Cache::render_config_snippet( $this->get_settings() ) )
158 671 );
159 672 }
160 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 +
161 764 public function cli_commands(): array {
162 765 return array(
163 766 array(
164 767 'name' => 'xspeed objcache',
165 768 'callback' => array( $this, 'cli_handler' ),
166 - 'shortdesc' => 'Show object cache status, flush, or print the wp-config snippet.',
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.',
167 771 'synopsis' => array(
168 772 array(
169 773 'type' => 'positional',
170 774 'name' => 'action',
171 - 'options' => array( 'status', 'flush', 'snippet' ),
775 + 'options' => array( 'status', 'flush', 'snippet', 'enable', 'disable', 'test', 'get', 'set', 'override', 'revert' ),
172 776 'optional' => true,
173 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 + ),
174 788 ),
175 789 ),
176 790 );
177 791 }
@@ -184,9 +798,139 @@
184 798 \WP_CLI::log( 'drop-in installed: ' . ( $d['drop_in_installed'] ? 'yes' : 'no' ) );
185 799 \WP_CLI::log( 'label: ' . $d['drop_in_label'] );
186 800 \WP_CLI::log( 'backend: ' . $d['backend'] );
187 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 + }
188 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;
189 933 case 'flush':
190 934 $ok = Object_Cache::flush();
191 935 $ok ? \WP_CLI::success( 'Flushed.' ) : \WP_CLI::error( 'Flush failed.' );
192 936 return;
@@ -192,9 +936,70 @@
192 936 return;
193 937 case 'snippet':
194 938 \WP_CLI::log( Object_Cache::render_config_snippet( $this->get_settings() ) );
195 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;
196 952 default:
197 953 \WP_CLI::error( "Unknown action: $action" );
198 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' );
199 1004 }
200 1005 }