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
← All changes | includes/modules/ObjectCache/ObjectCacheModule.php +730 -44 1.2.4 → 1.4.2 View file →
@@ -19,8 +19,9 @@
19 19 defined( 'ABSPATH' ) || exit;
20 20
21 21 use XSpeed\Module;
22 22 use XSpeed\Object_Cache;
23 +use XSpeed\Object_Cache_Takeover;
23 24
24 25 final class ObjectCacheModule extends Module {
25 26
26 27 public const SLUG = 'object-cache';
@@ -28,11 +29,12 @@
28 29 public const VERSION = '1.1.0';
29 30
30 31 public function ui_metadata(): array {
31 32 return array(
32 - 'label' => 'Object Cache',
33 + 'label' => __( 'Object Cache', 'xspeed' ),
33 34 'icon' => 'Server',
34 - 'description' => 'Configure a persistent object cache (Redis / Memcached) and generate a paste-ready wp-config.php snippet.',
35 + 'description' => __( 'Stores database query results in Redis or Memcached so pages build faster.', 'xspeed' ),
36 + 'group' => 'cache',
35 37 'custom_panel' => 'ObjectCachePanel',
36 38 );
37 39 }
38 40
@@ -57,16 +59,31 @@
57 59 'option_labels' => array(
58 60 'redis' => 'Redis',
59 61 'memcached' => 'Memcached',
60 62 ),
61 - 'label' => 'Backend',
62 - 'description' => 'Which cache server you intend to use. Affects the generated wp-config snippet.',
63 + 'label' => __( 'Cache server', 'xspeed' ),
64 + 'description' => __( 'The cache server your host provides. Ask your host if you are not sure.', 'xspeed' ),
63 65 ),
64 66 'redis_host' => array(
65 - 'type' => 'string',
66 - 'default' => '127.0.0.1',
67 - 'label' => 'Redis Host',
68 - 'description' => 'Hostname or IP of the Redis server. Use 127.0.0.1 for a local socket on the same machine as PHP.',
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' ),
69 86 ),
70 87 'redis_port' => array(
71 88 'type' => 'int',
72 89 'default' => 6379,
@@ -71,22 +88,38 @@
71 88 'type' => 'int',
72 89 'default' => 6379,
73 90 'min' => 1,
74 91 'max' => 65535,
75 - 'label' => 'Redis Port',
76 - 'description' => 'Default Redis port is 6379.',
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' ),
77 97 ),
78 98 'redis_user' => array(
79 - 'type' => 'string',
80 - 'default' => '',
81 - 'label' => 'Redis User',
82 - '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).',
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' ),
83 113 ),
84 114 'redis_password' => array(
85 - 'type' => 'secret',
86 - 'default' => '',
87 - 'label' => 'Redis Password',
88 - 'description' => 'Leave blank if your Redis server runs without auth.',
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' ),
89 122 ),
90 123 'redis_database' => array(
91 124 'type' => 'int',
92 125 'default' => 0,
@@ -91,30 +124,95 @@
91 124 'type' => 'int',
92 125 'default' => 0,
93 126 'min' => 0,
94 127 'max' => 15,
95 - 'label' => 'Redis Database',
96 - 'description' => 'Redis logical DB number (0-15). Use a dedicated DB per site if Redis is shared.',
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' ),
97 133 ),
98 134 'memcached_host' => array(
99 135 'type' => 'string',
100 136 'default' => '127.0.0.1',
101 - 'label' => 'Memcached Host',
102 - 'description' => 'Used when Backend = Memcached.',
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' ),
103 162 ),
104 163 'memcached_port' => array(
105 - 'type' => 'int',
106 - 'default' => 11211,
107 - 'min' => 1,
108 - 'max' => 65535,
109 - 'label' => 'Memcached Port',
110 - 'description' => 'Default Memcached port is 11211.',
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' ),
111 186 ),
112 187 'key_prefix' => array(
113 188 'type' => 'string',
114 189 'default' => '',
115 - 'label' => 'Cache Key Prefix',
116 - 'description' => 'Unique salt for this site\'s cache keys. Critical when multiple WP sites share one Redis/Memcached server. On ACL/namespaced Redis (e.g. xCloud), this MUST match the host\'s "Redis Object Cache Key" — otherwise cache writes are denied (NOPERM) and nothing persists.',
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.
117 215 ),
118 216 'connection_timeout' => array(
119 217 'type' => 'int',
120 218 'default' => 1,
@@ -119,17 +217,21 @@
119 217 'type' => 'int',
120 218 'default' => 1,
121 219 'min' => 0,
122 220 'max' => 60,
123 - 'label' => 'Connection Timeout (seconds)',
221 + 'constants' => array( 'XSPEED_OC_TIMEOUT', 'WP_REDIS_TIMEOUT' ),
222 + 'label' => __( 'Connection timeout (seconds)', 'xspeed' ),
124 223 'unit' => 'seconds',
125 - 'description' => 'How long to wait for a connection. Keep low (1-2s) so a misconfigured cache never stalls the page.',
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,
126 226 ),
127 227 'persistent' => array(
128 228 'type' => 'bool',
129 229 'default' => true,
130 - 'label' => 'Persistent Connections',
131 - 'description' => 'Reuse the connection across PHP requests when supported. Generally a win unless the cache server complains about idle connections.',
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,
132 234 ),
133 235 );
134 236 }
135 237
@@ -147,9 +249,273 @@
147 249 },
148 250 );
149 251 }
150 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 +
151 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 +
152 518 // Keep the deployed drop-in in sync with the shipped template. It is
153 519 // copied into wp-content/object-cache.php on enable and then never
154 520 // touched again — so a fix shipped in a plugin update (e.g. the
155 521 // stale-alloptions eviction on failed backend writes, issue #41)
@@ -160,16 +526,108 @@
160 526 static function (): void {
161 527 if ( get_option( 'xspeed_oc_dropin_synced', '' ) === XSPEED_VERSION ) {
162 528 return;
163 529 }
530 + $ok = true;
164 531 if ( Object_Cache::is_our_dropin_present() ) {
165 - Object_Cache::install_dropin();
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;
166 539 }
167 - update_option( 'xspeed_oc_dropin_synced', XSPEED_VERSION );
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 );
168 564 }
169 565 );
170 566 }
171 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 +
172 630 public function rest_routes(): array {
173 631 $default = parent::rest_routes();
174 632 return array_merge(
175 633 $default,
@@ -208,9 +666,23 @@
208 666 );
209 667 }
210 668
211 669 public function rest_detect( \WP_REST_Request $request ) {
212 - return rest_ensure_response( Object_Cache::detect() );
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 );
213 685 }
214 686
215 687 public function rest_flush( \WP_REST_Request $request ) {
216 688 $ok = Object_Cache::flush();
@@ -266,8 +738,22 @@
266 738 && \XSpeed\Settings_Manager::is_secret_field( $key, $schema[ $key ] )
267 739 && \XSpeed\Settings_Manager::is_masked_secret( (string) $body[ $key ] ) ) {
268 740 continue;
269 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 + }
270 756 $settings[ $key ] = $body[ $key ];
271 757 }
272 758 return $settings;
273 759 }
@@ -278,12 +764,22 @@
278 764
279 765 public function rest_enable( \WP_REST_Request $request ) {
280 766 // Persist any settings sent with the enable call first, then act on them.
281 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 + }
282 778 if ( is_array( $body ) && ! empty( $body ) ) {
283 779 \XSpeed\Settings_Manager::update( self::SLUG, $body );
284 780 }
285 - $result = Object_Cache::enable( $this->get_settings() );
781 + $result = Object_Cache::enable( $this->get_settings(), array( 'takeover' => $takeover ) );
286 782
287 783 if ( $result['ok'] && class_exists( '\\XSpeed\\Activity_Log' ) ) {
288 784 \XSpeed\Activity_Log::record(
289 785 'object_cache_enabled',
@@ -294,9 +790,11 @@
294 790 return rest_ensure_response( $result );
295 791 }
296 792
297 793 public function rest_disable( \WP_REST_Request $request ) {
298 - $result = Object_Cache::disable();
794 + $result = Object_Cache::disable(
795 + array( 'restore' => rest_sanitize_boolean( $request->get_param( 'restore' ) ) )
796 + );
299 797 if ( $result['ok'] && class_exists( '\\XSpeed\\Activity_Log' ) ) {
300 798 \XSpeed\Activity_Log::record(
301 799 'object_cache_disabled',
302 800 'Object cache disabled.',
@@ -310,17 +808,39 @@
310 808 return array(
311 809 array(
312 810 'name' => 'xspeed objcache',
313 811 'callback' => array( $this, 'cli_handler' ),
314 - 'shortdesc' => 'Show object cache status, flush, or print the wp-config snippet.',
315 - '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.',
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.',
316 814 'synopsis' => array(
317 815 array(
318 816 'type' => 'positional',
319 817 'name' => 'action',
320 - 'options' => array( 'status', 'flush', 'snippet', 'enable', 'disable', 'test' ),
818 + 'options' => array( 'status', 'flush', 'snippet', 'enable', 'disable', 'test', 'get', 'set', 'override', 'revert' ),
321 819 'optional' => true,
322 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 + ),
323 843 ),
324 844 ),
325 845 );
326 846 }
@@ -333,9 +853,139 @@
333 853 \WP_CLI::log( 'drop-in installed: ' . ( $d['drop_in_installed'] ? 'yes' : 'no' ) );
334 854 \WP_CLI::log( 'label: ' . $d['drop_in_label'] );
335 855 \WP_CLI::log( 'backend: ' . $d['backend'] );
336 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 + }
337 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;
338 988 case 'flush':
339 989 $ok = Object_Cache::flush();
340 990 $ok ? \WP_CLI::success( 'Flushed.' ) : \WP_CLI::error( 'Flush failed.' );
341 991 return;
@@ -346,18 +996,54 @@
346 996 $t = Object_Cache::test_connection( $this->get_settings() );
347 997 $t['ok'] ? \WP_CLI::success( $t['message'] ) : \WP_CLI::error( $t['message'] );
348 998 return;
349 999 case 'enable':
350 - $r = Object_Cache::enable( $this->get_settings() );
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 + }
351 1009 $r['ok'] ? \WP_CLI::success( $r['message'] ) : \WP_CLI::error( $r['message'] );
352 1010 return;
353 1011 case 'disable':
354 - $r = Object_Cache::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 + }
355 1020 $r['ok'] ? \WP_CLI::success( $r['message'] ) : \WP_CLI::error( $r['message'] );
356 1021 return;
357 1022 default:
358 1023 \WP_CLI::error( "Unknown action: $action" );
359 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;
360 1046 }
361 1047
362 1048 /**
363 1049 * The object cache is on when OUR drop-in is installed and actually