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