PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.1 1.2.2 1.2.3
xspeed / includes / modules / ObjectCache / ObjectCacheModule.php

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

390 lines 13.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Object Cache module — status, settings, wp-config snippet, flush.
4 *
5 * Tier: Free per FEATURES.md "Object Cache" §1-11 (LiteSpeed parity).
6 *
7 * We don't ship our own object-cache.php drop-in in this Free release
8 * (see Object_Cache class docblock for rationale). The settings here
9 * are surfaced via the wp-config snippet; advanced consumers (Pro,
10 * external drop-ins like Redis Object Cache) can read them too.
11 *
12 * @package XSpeed
13 */
14
15 declare(strict_types=1);
16
17 namespace XSpeed\Modules\ObjectCache;
18
19 defined( 'ABSPATH' ) || exit;
20
21 use XSpeed\Module;
22 use XSpeed\Object_Cache;
23
24 final class ObjectCacheModule extends Module {
25
26 public const SLUG = 'object-cache';
27 public const TIER = self::TIER_FREE;
28 public const VERSION = '1.1.0';
29
30 public function ui_metadata(): array {
31 return array(
32 'label' => 'Object Cache',
33 'icon' => 'Server',
34 'description' => 'Configure a persistent object cache (Redis / Memcached) and generate a paste-ready wp-config.php snippet.',
35 'custom_panel' => 'ObjectCachePanel',
36 );
37 }
38
39 /**
40 * @inheritDoc
41 *
42 * Nothing exempt. Object caching sits beside a page cache and competes for
43 * nothing, and the drop-in is only re-synced when ours is already installed
44 * (see boot()), so the flag installs nothing on its own — all true, and all
45 * a weak reason to leave a switch on that nobody asked for.
46 */
47 public function conflict_safe_exempt(): array {
48 return array();
49 }
50
51 public function settings_schema(): array {
52 return array(
53 'backend' => array(
54 'type' => 'enum',
55 'default' => 'redis',
56 'options' => array( 'redis', 'memcached' ),
57 'option_labels' => array(
58 'redis' => 'Redis',
59 'memcached' => 'Memcached',
60 ),
61 'label' => 'Backend',
62 'description' => 'Which cache server you intend to use. Affects the generated wp-config snippet.',
63 ),
64 '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.',
69 ),
70 'redis_port' => array(
71 'type' => 'int',
72 'default' => 6379,
73 'min' => 1,
74 'max' => 65535,
75 'label' => 'Redis Port',
76 'description' => 'Default Redis port is 6379.',
77 ),
78 '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).',
83 ),
84 'redis_password' => array(
85 'type' => 'secret',
86 'default' => '',
87 'label' => 'Redis Password',
88 'description' => 'Leave blank if your Redis server runs without auth.',
89 ),
90 'redis_database' => array(
91 'type' => 'int',
92 'default' => 0,
93 'min' => 0,
94 'max' => 15,
95 'label' => 'Redis Database',
96 'description' => 'Redis logical DB number (0-15). Use a dedicated DB per site if Redis is shared.',
97 ),
98 'memcached_host' => array(
99 'type' => 'string',
100 'default' => '127.0.0.1',
101 'label' => 'Memcached Host',
102 'description' => 'Used when Backend = Memcached.',
103 ),
104 '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.',
111 ),
112 'key_prefix' => array(
113 'type' => 'string',
114 '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.',
117 ),
118 'connection_timeout' => array(
119 'type' => 'int',
120 'default' => 1,
121 'min' => 0,
122 'max' => 60,
123 'label' => 'Connection Timeout (seconds)',
124 'unit' => 'seconds',
125 'description' => 'How long to wait for a connection. Keep low (1-2s) so a misconfigured cache never stalls the page.',
126 ),
127 'persistent' => array(
128 'type' => 'bool',
129 '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.',
132 ),
133 );
134 }
135
136 /**
137 * Encrypt the pre-1.1.0 plaintext redis_password on upgrade — it became a
138 * `secret`-typed field (encrypted at rest). Idempotent. (#115)
139 */
140 public function migrations(): array {
141 return array(
142 '1.1.0' => static function ( array $opts ): array {
143 if ( isset( $opts['redis_password'] ) && is_string( $opts['redis_password'] ) && '' !== $opts['redis_password'] ) {
144 $opts['redis_password'] = \XSpeed\Settings_Manager::encrypt_for_storage( $opts['redis_password'] );
145 }
146 return $opts;
147 },
148 );
149 }
150
151 public function boot(): void {
152 // Keep the deployed drop-in in sync with the shipped template. It is
153 // copied into wp-content/object-cache.php on enable and then never
154 // touched again — so a fix shipped in a plugin update (e.g. the
155 // stale-alloptions eviction on failed backend writes, issue #41)
156 // would never reach existing installs. Version-gated so the file
157 // comparison runs once per plugin version, not on every admin load.
158 add_action(
159 'admin_init',
160 static function (): void {
161 if ( get_option( 'xspeed_oc_dropin_synced', '' ) === XSPEED_VERSION ) {
162 return;
163 }
164 if ( Object_Cache::is_our_dropin_present() ) {
165 Object_Cache::install_dropin();
166 }
167 update_option( 'xspeed_oc_dropin_synced', XSPEED_VERSION );
168 }
169 );
170 }
171
172 public function rest_routes(): array {
173 $default = parent::rest_routes();
174 return array_merge(
175 $default,
176 array(
177 array(
178 'path' => '/detect',
179 'methods' => 'GET',
180 'callback' => array( $this, 'rest_detect' ),
181 ),
182 array(
183 'path' => '/flush',
184 'methods' => 'POST',
185 'callback' => array( $this, 'rest_flush' ),
186 ),
187 array(
188 'path' => '/snippet',
189 'methods' => 'GET',
190 'callback' => array( $this, 'rest_snippet' ),
191 ),
192 array(
193 'path' => '/test-connection',
194 'methods' => 'POST',
195 'callback' => array( $this, 'rest_test_connection' ),
196 ),
197 array(
198 'path' => '/enable',
199 'methods' => 'POST',
200 'callback' => array( $this, 'rest_enable' ),
201 ),
202 array(
203 'path' => '/disable',
204 'methods' => 'POST',
205 'callback' => array( $this, 'rest_disable' ),
206 ),
207 )
208 );
209 }
210
211 public function rest_detect( \WP_REST_Request $request ) {
212 return rest_ensure_response( Object_Cache::detect() );
213 }
214
215 public function rest_flush( \WP_REST_Request $request ) {
216 $ok = Object_Cache::flush();
217 if ( $ok && class_exists( '\\XSpeed\\Activity_Log' ) ) {
218 \XSpeed\Activity_Log::record(
219 'object_cache_flushed',
220 'Object cache flushed.',
221 \XSpeed\Activity_Log::INFO
222 );
223 }
224 return rest_ensure_response( array( 'ok' => $ok ) );
225 }
226
227 public function rest_snippet( \WP_REST_Request $request ) {
228 return rest_ensure_response(
229 array( 'snippet' => Object_Cache::render_config_snippet( $this->get_settings() ) )
230 );
231 }
232
233 /**
234 * Merge any settings sent in the request body over the saved settings, so
235 * the UI can "Test connection" with unsaved values. Only known keys pass.
236 */
237 private function settings_with_overrides( \WP_REST_Request $request ): array {
238 $body = $request->get_json_params();
239 return self::merge_overrides(
240 $this->get_settings(),
241 is_array( $body ) ? $body : array(),
242 $this->settings_schema()
243 );
244 }
245
246 /**
247 * Overlay request-body values onto the stored settings for a one-off "Test
248 * connection" — but NEVER let a masked secret echoed from the panel overwrite
249 * the real stored value. The panel holds `Redi••••CRET`; without this guard,
250 * clicking Test connection authenticates Redis with the mask and a correct
251 * password reports as wrong. A genuinely new (typed) password still applies,
252 * and an explicit empty value still tests the no-auth case. Static + pure so
253 * it's unit-testable without a REST request. (QA B3)
254 *
255 * @param array<string,mixed> $settings Stored, decrypted settings.
256 * @param array<string,mixed> $body Request overrides.
257 * @param array<string,array> $schema The module schema (for secret detection).
258 * @return array<string,mixed>
259 */
260 public static function merge_overrides( array $settings, array $body, array $schema ): array {
261 foreach ( $settings as $key => $value ) {
262 if ( ! array_key_exists( $key, $body ) ) {
263 continue;
264 }
265 if ( isset( $schema[ $key ] )
266 && \XSpeed\Settings_Manager::is_secret_field( $key, $schema[ $key ] )
267 && \XSpeed\Settings_Manager::is_masked_secret( (string) $body[ $key ] ) ) {
268 continue;
269 }
270 $settings[ $key ] = $body[ $key ];
271 }
272 return $settings;
273 }
274
275 public function rest_test_connection( \WP_REST_Request $request ) {
276 return rest_ensure_response( Object_Cache::test_connection( $this->settings_with_overrides( $request ) ) );
277 }
278
279 public function rest_enable( \WP_REST_Request $request ) {
280 // Persist any settings sent with the enable call first, then act on them.
281 $body = $request->get_json_params();
282 if ( is_array( $body ) && ! empty( $body ) ) {
283 \XSpeed\Settings_Manager::update( self::SLUG, $body );
284 }
285 $result = Object_Cache::enable( $this->get_settings() );
286
287 if ( $result['ok'] && class_exists( '\\XSpeed\\Activity_Log' ) ) {
288 \XSpeed\Activity_Log::record(
289 'object_cache_enabled',
290 'Object cache enabled (' . ( $result['test']['backend'] ?? '' ) . ').',
291 \XSpeed\Activity_Log::INFO
292 );
293 }
294 return rest_ensure_response( $result );
295 }
296
297 public function rest_disable( \WP_REST_Request $request ) {
298 $result = Object_Cache::disable();
299 if ( $result['ok'] && class_exists( '\\XSpeed\\Activity_Log' ) ) {
300 \XSpeed\Activity_Log::record(
301 'object_cache_disabled',
302 'Object cache disabled.',
303 \XSpeed\Activity_Log::INFO
304 );
305 }
306 return rest_ensure_response( $result );
307 }
308
309 public function cli_commands(): array {
310 return array(
311 array(
312 'name' => 'xspeed objcache',
313 '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.',
316 'synopsis' => array(
317 array(
318 'type' => 'positional',
319 'name' => 'action',
320 'options' => array( 'status', 'flush', 'snippet', 'enable', 'disable', 'test' ),
321 'optional' => true,
322 ),
323 ),
324 ),
325 );
326 }
327
328 public function cli_handler( array $args, array $assoc ): void {
329 $action = $args[0] ?? 'status';
330 switch ( $action ) {
331 case 'status':
332 $d = Object_Cache::detect();
333 \WP_CLI::log( 'drop-in installed: ' . ( $d['drop_in_installed'] ? 'yes' : 'no' ) );
334 \WP_CLI::log( 'label: ' . $d['drop_in_label'] );
335 \WP_CLI::log( 'backend: ' . $d['backend'] );
336 \WP_CLI::log( 'ext object cache: ' . ( $d['wp_cache_active'] ? 'yes' : 'no' ) );
337 return;
338 case 'flush':
339 $ok = Object_Cache::flush();
340 $ok ? \WP_CLI::success( 'Flushed.' ) : \WP_CLI::error( 'Flush failed.' );
341 return;
342 case 'snippet':
343 \WP_CLI::log( Object_Cache::render_config_snippet( $this->get_settings() ) );
344 return;
345 case 'test':
346 $t = Object_Cache::test_connection( $this->get_settings() );
347 $t['ok'] ? \WP_CLI::success( $t['message'] ) : \WP_CLI::error( $t['message'] );
348 return;
349 case 'enable':
350 $r = Object_Cache::enable( $this->get_settings() );
351 $r['ok'] ? \WP_CLI::success( $r['message'] ) : \WP_CLI::error( $r['message'] );
352 return;
353 case 'disable':
354 $r = Object_Cache::disable();
355 $r['ok'] ? \WP_CLI::success( $r['message'] ) : \WP_CLI::error( $r['message'] );
356 return;
357 default:
358 \WP_CLI::error( "Unknown action: $action" );
359 }
360 }
361
362 /**
363 * The object cache is on when OUR drop-in is installed and actually
364 * persisting -- not when a backend host is merely typed into the
365 * settings. `detect()` reads the running instance, so a drop-in that is
366 * installed but degraded (connected to nothing) correctly reports off
367 * rather than claiming a cache the site is not getting. (#363)
368 */
369 public function is_active(): ?bool {
370 $state = Object_Cache::detect();
371 return ! empty( $state['persistent'] );
372 }
373
374 /**
375 * Configured is not the same as working, and the difference is the whole
376 * point here -- a drop-in connected to nothing reports on to WordPress
377 * while persisting no data. Report what is actually happening.
378 */
379 public function active_reason(): ?string {
380 $state = Object_Cache::detect();
381 if ( ! empty( $state['persistent'] ) ) {
382 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' );
383 }
384 if ( ! empty( $state['degraded'] ) ) {
385 return __( 'The drop-in is installed but is not storing anything, so this counts as off. Check the connection settings below.', 'xspeed' );
386 }
387 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' );
388 }
389 }
390