PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
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 1.1.2 All 35 releases
← All changes | includes/class-server-caches.php +460 -3 1.3.5 → 1.4.0 View file →
@@ -4,10 +4,16 @@
4 4 *
5 5 * xSpeed owns one cache. A LiteSpeed stack has two: ours, and LSCache holding
6 6 * its own copy of the same URL at the server. Purging ours and stopping there
7 7 * left the server still serving the page we had just invalidated — measured on
8 - * OpenLiteSpeed before this existed.
8 + * OpenLiteSpeed before this existed. Two adapters ship: LiteSpeed, and the
9 + * nginx FastCGI cache reached through the Nginx Helper plugin.
9 10 *
11 + * Each adapter decides for itself which purges are worth forwarding, from the
12 + * `intent` and `scope` on the context. They do not answer alike, and the
13 + * reasoning for each lives on the adapter — see `forward_nginx_helper()`,
14 + * which stands down on a content purge where `forward_litespeed()` does not.
15 + *
10 16 * This is the counterpart to Render_Caches. That one clears caches of RENDERED
11 17 * OUTPUT owned by page builders; this one clears caches of whole RESPONSES
12 18 * owned by the web server. Both are integrations with software we do not ship,
13 19 * and both hang off a public seam so a site can add its own.
@@ -33,10 +39,10 @@
33 39
34 40 final class Server_Caches {
35 41
36 42 /*
37 - * There is deliberately no boot()/add_action here. `Cache` calls forward()
38 - * directly, before it fires the public purge actions.
43 + * Forwarding itself is deliberately not a listener. `Cache` calls
44 + * forward() directly, before it fires the public purge actions.
39 45 *
40 46 * As a listener this would be one callback among many, and WordPress stops
41 47 * dispatching an action's remaining callbacks when an earlier one throws —
42 48 * so an unrelated third-party listener's bug could silently skip our
@@ -43,11 +49,59 @@
43 49 * LiteSpeed forwarding, leaving the server serving stale HTML while xSpeed
44 50 * reported a successful purge. Shipped behaviour should not be hostage to
45 51 * that. Third parties still extend through `xspeed_purge_server_caches`
46 52 * below, which runs after we have done our own work.
53 + *
54 + * Both built-in adapters are reached only from forward(). Neither
55 + * registers a hook of its own, so this is the single place that decides
56 + * whether a given purge reaches a server cache.
57 + *
58 + * boot() below is the one exception, and it registers nothing that
59 + * forwards — only the end-of-import purge that forward_nginx_helper()'s
60 + * import gate depends on.
47 61 */
48 62
49 63 /**
64 + * Register the end-of-import purge.
65 + *
66 + * `forward_nginx_helper()` stands down for the length of an import: a
67 + * WXR run fires hundreds of individually-justified purges, and clearing
68 + * the whole nginx zone once per imported post is the waste that gate
69 + * exists to stop. That trade is only correct if a single purge follows
70 + * the import — otherwise the install finishes with nginx still serving
71 + * every pre-import page for the rest of its TTL, which is worse than the
72 + * waste. This is that purge, and nothing else issues it.
73 + *
74 + * `import_end` is WordPress's own signal, fired by the WXR importer and
75 + * by every importer that follows its lead. An importer that fires
76 + * `import_start` and then dies without `import_end` leaves the zone
77 + * stale — the same outcome as not having the gate, so no worse than
78 + * before, and not worth a `shutdown` fallback that would fire a full
79 + * purge on every request that ever touched an importer.
80 + */
81 + public static function boot(): void {
82 + if ( ! function_exists( 'add_action' ) ) {
83 + return;
84 + }
85 + add_action( 'import_end', array( __CLASS__, 'purge_after_import' ) );
86 + }
87 +
88 + /**
89 + * Clear everything once, now that the import is done.
90 + *
91 + * `complete` intent, which is what `Cache::purge_all()` announces by
92 + * default — and the one intent the import gate lets through, so this
93 + * reaches the server layer even though `did_action( 'import_start' )` is
94 + * still true for the rest of the request.
95 + */
96 + public static function purge_after_import(): void {
97 + if ( ! class_exists( __NAMESPACE__ . '\\Cache' ) ) {
98 + return;
99 + }
100 + Cache::purge_all( 'import finished' );
101 + }
102 +
103 + /**
50 104 * Forward one purge to every server cache we recognise.
51 105 *
52 106 * The public context carries the action an adapter should take:
53 107 * `urls` purges only the listed response URLs, `site` purges this site's
@@ -71,8 +125,20 @@
71 125 error_log( '[xspeed] LiteSpeed response purge failed: ' . $e->getMessage() );
72 126 }
73 127 }
74 128
129 + // Its own try, for the same reason the LiteSpeed one has its own: two
130 + // server caches can be in front of one site, and a bad day for one
131 + // adapter must not leave the other serving stale HTML.
132 + try {
133 + self::forward_nginx_helper( $context );
134 + } catch ( \Throwable $e ) {
135 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'error_log' ) ) {
136 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- built-in integration failure after local invalidation.
137 + error_log( '[xspeed] nginx FastCGI response purge failed: ' . $e->getMessage() );
138 + }
139 + }
140 +
75 141 /**
76 142 * Fires so a site can invalidate a server cache xSpeed does not know.
77 143 *
78 144 * Same context as the event that triggered it. Use this rather than
@@ -104,8 +170,12 @@
104 170 *
105 171 * Detected by constant, not plugin path. `LSCWP_V` is defined by the
106 172 * plugin bootstrap and survives a renamed folder.
107 173 *
174 + * Forwards on every intent, including `content` — unlike the nginx
175 + * adapter, which stands down there. See `forward_nginx_helper()` for why
176 + * the two differ.
177 + *
108 178 * @param array<string,mixed> $context Public purge context.
109 179 */
110 180 private static function forward_litespeed( array $context ): void {
111 181 if ( ! defined( 'LSCWP_V' ) ) {
@@ -139,8 +209,9 @@
139 209 if ( 'network' === $scope && ! defined( 'LSWCP_EMPTYCACHE' ) ) {
140 210 define( 'LSWCP_EMPTYCACHE', true );
141 211 }
142 212 \LiteSpeed\Purge::purge_all_lscache( 'xSpeed response invalidation' );
213 + self::note_forwarded( 'LiteSpeed Cache' );
143 214 }
144 215 return;
145 216 }
146 217
@@ -156,8 +227,394 @@
156 227 }
157 228 foreach ( array_values( array_unique( $targets ) ) as $target ) {
158 229 do_action( 'litespeed_purge_url', $target );
159 230 }
231 + if ( array() !== $targets ) {
232 + self::note_forwarded( 'LiteSpeed Cache' );
233 + }
234 + }
235 +
236 + /**
237 + * nginx FastCGI full-page cache, through the Nginx Helper plugin.
238 + *
239 + * Forwards on every intent, and on `content` only when Nginx Helper is not
240 + * purging for itself or `xspeed_nginx_helper_defer_content_purge` says
241 + * to. That asymmetry with `forward_litespeed()`, which forwards on all of
242 + * them, is deliberate.
243 + *
244 + * The two server caches are not alike in what a purge costs. LSCache is
245 + * per-site and tag-based: a site purge bumps one tag for one blog. The
246 + * nginx FastCGI zone is ONE directory per WordPress install, and clearing
247 + * it is a recursive unlink of every cached page — on multisite, of every
248 + * site on the network. So the blast radius of forwarding is an order of
249 + * magnitude apart for the same event.
250 + *
251 + * The other half is that we are not the only one purging. Nginx Helper
252 + * hooks `transition_post_status`, `before_delete_post` and the comment
253 + * hooks itself and purges only the URLs the edit touched (the post, the
254 + * homepage, the post's archives), behind its own `enable_purge` option and
255 + * an import guard. Its term hooks purge the homepage alone, which is why
256 + * a renamed or deleted term is `presentation` and still forwards. On a content
257 + * purge it has already done the narrow, correct thing. Forwarding on top
258 + * of that replaced targeted purging with a whole-install wipe at the same
259 + * frequency: publishing one post cleared every cached page on the site,
260 + * and an import cost one full wipe per post. (QA #444.)
261 + *
262 + * That argument only holds while Nginx Helper's `enable_purge` is on. It
263 + * defaults to off, and with it off Nginx Helper purges nothing on a
264 + * content edit. Standing down there left the edited post stale at the
265 + * server for the whole TTL, where before this adapter existed it was
266 + * cleared. So a content purge forwards when Nginx Helper is not purging
267 + * for itself. (QA #448)
268 + *
269 + * The trade that stays: Nginx Helper purges the post, the homepage and
270 + * the post's archives. An ordinary page that lists recent posts is none of
271 + * those, and keeps its old list until the server TTL expires. When xSpeed
272 + * knows that happened, because a narrow purge fell back to the whole site
273 + * for a list the theme draws everywhere, the context says so in
274 + * `fallback` and the zone is cleared (fallback_needs_whole_zone()). The
275 + * `xspeed_nginx_helper_defer_content_purge` filter returns to clearing
276 + * the whole zone on every content purge outside an import, for a site that
277 + * needs those pages current.
278 + *
279 + * `presentation` and `complete` still forward, because neither of those is
280 + * something Nginx Helper covers. It has no hook for `switch_theme`,
281 + * `activated_plugin` or `wp_update_nav_menu`, and no notion of a settings
282 + * write or a core update — and each of those changes the markup of every
283 + * page, not a listed few. An unrecognised intent forwards too: a purge
284 + * whose reason we do not know is likelier to need the server layer than
285 + * not, and a redundant purge costs a cold cache while a skipped one costs
286 + * wrong HTML for the whole TTL.
287 + *
288 + * Whether LiteSpeed should also stand down on `content` is a fair question
289 + * and was deliberately not revisited here — it has no targeted self-purge
290 + * to fall back on, so standing it down would leave LSCache stale where
291 + * nginx is merely over-cleared.
292 + *
293 + * @param array<string,mixed> $context Public purge context.
294 + */
295 + private static function forward_nginx_helper( array $context ): void {
296 + // Guarded rather than assumed: Free is upgraded as a unit, but a
297 + // half-copied update can leave this file newer than that one.
298 + if ( ! class_exists( __NAMESPACE__ . '\\Host_Page_Caches' ) ) {
299 + return;
300 + }
301 +
302 + $url = isset( $context['url'] ) && is_string( $context['url'] ) ? $context['url'] : '';
303 + $scope = isset( $context['scope'] ) && is_string( $context['scope'] )
304 + ? $context['scope']
305 + : ( '' !== $url ? 'urls' : 'site' );
306 +
307 + if ( 'none' === $scope ) {
308 + return;
309 + }
310 +
311 + // Named pages go to Nginx Helper's own per-URL purge, whether or not
312 + // its automatic purging is on. Its own rules clear the post, the home
313 + // page and the post's archives, but no archive page past the first and
314 + // no neighbouring post, so leaving a narrow purge to it would leave
315 + // those stale. Only this site's URLs: with the `get_request` method
316 + // Nginx Helper keeps the path and swaps in its own host. Collected
317 + // and sent once, at shutdown (flush_nginx_helper()).
318 + if ( 'urls' === $scope ) {
319 + if ( self::is_importing() || ! Host_Page_Caches::nginx_helper_is_fastcgi() ) {
320 + return;
321 + }
322 + $urls = isset( $context['urls'] ) && is_array( $context['urls'] ) ? $context['urls'] : array( $url );
323 + foreach ( $urls as $target ) {
324 + if ( is_string( $target ) && '' !== $target && self::is_this_site( $target ) ) {
325 + self::queue_nginx_url( $target );
326 + }
327 + }
328 + return;
329 + }
330 +
331 + $intent = isset( $context['intent'] ) && is_string( $context['intent'] ) && '' !== $context['intent']
332 + ? $context['intent']
333 + : 'complete';
334 +
335 + // Nothing to decide on a site with no nginx zone, so the filter below
336 + // is only asked when there is one.
337 + if ( ! Host_Page_Caches::nginx_helper_is_fastcgi() ) {
338 + return;
339 + }
340 +
341 + if ( 'content' === $intent ) {
342 + /**
343 + * Whether a content purge (a post saved, a comment approved, a
344 + * term added) is left to Nginx Helper instead of clearing the
345 + * whole nginx cache.
346 + *
347 + * Defaults to true when Nginx Helper's automatic purging is on,
348 + * since it has already purged the post, the homepage and the
349 + * post's archives. Defaults to false when `fallback` in the
350 + * context says pages outside those changed (`theme_list`,
351 + * `pending`, `filter`, `listing`). Return false to clear the
352 + * whole zone instead, for a site whose pages list posts
353 + * somewhere Nginx Helper does not purge.
354 + *
355 + * @param bool $defer Whether to leave it to Nginx Helper.
356 + * @param array<string,mixed> $context Public purge context.
357 + */
358 + $defer = (bool) apply_filters(
359 + 'xspeed_nginx_helper_defer_content_purge',
360 + Host_Page_Caches::nginx_helper_purges_changes() && ! self::fallback_needs_whole_zone( $context ),
361 + $context
362 + );
363 + if ( $defer ) {
364 + return;
365 + }
366 + }
367 +
368 + // An import is a long run of legitimate purges that each individually
369 + // justify a forward — new terms, new menu items — and together clear
370 + // the install's cache hundreds of times for one operation. Nginx
371 + // Helper stands its own purging down for exactly this (its
372 + // `is_import_request()`), and a single purge after the import is both
373 + // cheaper and more correct. An explicit `complete` still goes through:
374 + // an operator who presses Purge All mid-import means it.
375 + if ( 'complete' !== $intent && self::is_importing() ) {
376 + return;
377 + }
378 +
379 + // No host check, deliberately — the mirror of the one in
380 + // forward_litespeed(). There, a purge aimed at another blog must not
381 + // flush THIS request's LSCache, because LSCache is per-site. nginx
382 + // keys one zone per install, so the other blog's cached pages live in
383 + // the same directory as ours: skipping on a foreign host would leave
384 + // the pages the purge was actually for still being served. Pro's
385 + // Multisite::purge_site() runs inside switch_to_blog() and reaches
386 + // here with that blog's host.
387 + self::purge_nginx_zone();
388 + }
389 +
390 + /**
391 + * Most URLs sent to Nginx Helper one by one in a request. Past this the
392 + * zone is cleared once instead. A typical save names about 35 pages.
393 + */
394 + private const NGINX_URL_LIMIT = 100;
395 +
396 + /**
397 + * Seconds of per-URL purging after which the rest of the batch becomes
398 + * one zone purge. Nginx Helper's `get_request` method sends a blocking
399 + * GET per URL with WordPress's 5-second default timeout.
400 + */
401 + private const NGINX_URL_SECONDS = 3.0;
402 +
403 + /**
404 + * This request's nginx work, sent once at shutdown.
405 + *
406 + * `urls` is keyed by URL, so a page that two saves in one request both
407 + * name (a bulk edit, where every post shares the home page and the
408 + * archives) is sent once. `overflow` means more than the limit arrived.
409 + * `zone_done` means the zone was cleared earlier in this request, and
410 + * `zone_again` that another clear was asked for after it.
411 + *
412 + * @var array{urls:array<string,bool>,overflow:bool,zone_done:bool,zone_again:bool,armed:bool}
413 + */
414 + private static $nginx = array(
415 + 'urls' => array(),
416 + 'overflow' => false,
417 + 'zone_done' => false,
418 + 'zone_again' => false,
419 + 'armed' => false,
420 + );
421 +
422 + /** Test seam: forget this request's nginx work. */
423 + public static function reset(): void {
424 + self::$nginx = array(
425 + 'urls' => array(),
426 + 'overflow' => false,
427 + 'zone_done' => false,
428 + 'zone_again' => false,
429 + 'armed' => false,
430 + );
431 + }
432 +
433 + /**
434 + * Clear the whole nginx zone, at most once now and once more at the end
435 + * of the request.
436 + *
437 + * The first clear runs at once, as it always has, so an operator's Purge
438 + * All lands before the response. A later one in the same request (a bulk
439 + * edit of ten posts that each fall back to the whole site) waits for
440 + * shutdown and runs once, after every change the request makes. Clearing
441 + * the zone covers every URL queued before it, so those are dropped.
442 + */
443 + private static function purge_nginx_zone(): void {
444 + self::note_forwarded( 'Nginx Helper' );
445 + self::$nginx['urls'] = array();
446 + self::$nginx['overflow'] = false;
447 + if ( self::$nginx['zone_done'] ) {
448 + self::$nginx['zone_again'] = true;
449 + self::arm_nginx_flush();
450 + return;
451 + }
452 + if ( Host_Page_Caches::purge_nginx_helper() ) {
453 + self::$nginx['zone_done'] = true;
454 + }
455 + }
456 +
457 + /**
458 + * Tell an operator's purge report which cache took the purge. The
459 + * nginx batch is sent at shutdown, after the caller has printed its
460 + * answer, so this records the hand-off rather than the send.
461 + *
462 + * @param string $layer Cache name.
463 + */
464 + private static function note_forwarded( string $layer ): void {
465 + if ( class_exists( __NAMESPACE__ . '\\Cache' ) ) {
466 + Cache::note_purge_forwarded( $layer );
467 + }
468 + }
469 +
470 + /** Add one URL to this request's nginx batch. */
471 + private static function queue_nginx_url( string $url ): void {
472 + self::note_forwarded( 'Nginx Helper' );
473 + if ( self::$nginx['zone_again'] ) {
474 + // The zone is cleared at shutdown anyway.
475 + return;
476 + }
477 + if ( ! self::$nginx['overflow'] ) {
478 + self::$nginx['urls'][ $url ] = true;
479 + if ( count( self::$nginx['urls'] ) > self::nginx_url_limit() ) {
480 + self::$nginx['overflow'] = true;
481 + self::$nginx['urls'] = array();
482 + }
483 + }
484 + self::arm_nginx_flush();
485 + }
486 +
487 + /**
488 + * Send the batch at shutdown, or now when shutdown is already running.
489 + *
490 + * Inside `shutdown` a callback added at a priority that has already run
491 + * would never fire, so a purge raised there (Cache::flush_pending_saves()
492 + * runs at priority 1) is sent straight away.
493 + */
494 + private static function arm_nginx_flush(): void {
495 + if ( function_exists( 'did_action' ) && did_action( 'shutdown' ) ) {
496 + self::flush_nginx_helper();
497 + return;
498 + }
499 + if ( self::$nginx['armed'] || ! function_exists( 'add_action' ) ) {
500 + return;
501 + }
502 + self::$nginx['armed'] = true;
503 + add_action( 'shutdown', array( __CLASS__, 'flush_nginx_helper' ), 20, 0 );
504 + }
505 +
506 + /**
507 + * Send this request's nginx work: one zone clear, or each queued URL.
508 + *
509 + * Sent in the request that raised it, not handed to WP-Cron the way the
510 + * Cloudflare module defers its edge calls. On a site behind an nginx page
511 + * cache, anonymous visits are answered by nginx and never run PHP, so
512 + * WP-Cron can go a long time without a request to run on, and the pages
513 + * would stay stale until it did. The visitor requests that reach here (a
514 + * comment, a stock change at checkout) name about four URLs, and Nginx
515 + * Helper's own comment and post hooks already purge inline in those same
516 + * requests. NGINX_URL_SECONDS bounds the wait when the purge endpoint is
517 + * slow.
518 + *
519 + * Public because it is a `shutdown` callback; not part of the contract.
520 + */
521 + public static function flush_nginx_helper(): void {
522 + $work = self::$nginx;
523 + self::$nginx['urls'] = array();
524 + self::$nginx['overflow'] = false;
525 + self::$nginx['zone_again'] = false;
526 + self::$nginx['armed'] = false;
527 +
528 + if ( ! Host_Page_Caches::nginx_helper_is_fastcgi() ) {
529 + return;
530 + }
531 + if ( $work['zone_again'] ) {
532 + Host_Page_Caches::purge_nginx_helper();
533 + return;
534 + }
535 + if ( $work['overflow'] ) {
536 + self::record_nginx_zone_fallback();
537 + if ( Host_Page_Caches::purge_nginx_helper() ) {
538 + self::$nginx['zone_done'] = true;
539 + }
540 + return;
541 + }
542 + if ( array() === $work['urls'] ) {
543 + return;
544 + }
545 + Host_Page_Caches::purge_nginx_helper_urls( array_keys( $work['urls'] ), self::NGINX_URL_SECONDS );
546 + }
547 +
548 + /**
549 + * How many URLs a request may send to Nginx Helper one by one.
550 + */
551 + private static function nginx_url_limit(): int {
552 + if ( ! function_exists( 'apply_filters' ) ) {
553 + return self::NGINX_URL_LIMIT;
554 + }
555 + /**
556 + * Filter how many URLs one request sends to Nginx Helper one by one
557 + * before clearing the whole nginx zone instead.
558 + *
559 + * @param int $limit URLs per request.
560 + */
561 + $limit = (int) apply_filters( 'xspeed_nginx_helper_url_purge_limit', self::NGINX_URL_LIMIT );
562 + return $limit > 0 ? $limit : self::NGINX_URL_LIMIT;
563 + }
564 +
565 + /** Say in the activity log that a batch became a zone clear. */
566 + private static function record_nginx_zone_fallback(): void {
567 + if ( ! class_exists( __NAMESPACE__ . '\\Activity_Log' ) || ! function_exists( '__' ) ) {
568 + return;
569 + }
570 + Activity_Log::record(
571 + 'cache_purged',
572 + sprintf(
573 + /* translators: %d: most URLs purged one by one. */
574 + __( 'Cleared the whole nginx cache: more than %d pages changed in one request, too many to purge one by one', 'xspeed' ),
575 + self::nginx_url_limit()
576 + )
577 + );
578 + }
579 +
580 + /**
581 + * Whether a site-wide content purge fell back from a narrow one for a
582 + * reason Nginx Helper's own rules do not cover.
583 + *
584 + * Nginx Helper purges the post, the homepage and the first page of the
585 + * post's archives. That is enough for `limit`: the pages were all named,
586 + * only too many of them, and the ones past the first archive page wait
587 + * for the server TTL, as every content purge did before narrow purges.
588 + * It is not enough for the others:
589 + *
590 + * - `theme_list`: a list the theme draws on every page changed.
591 + * - `pending`: the save could not be worked out at all.
592 + * - `filter`: a site's own code said the named pages are not enough.
593 + * - `listing`: pages that run a post list of their own (a page builder
594 + * grid) may have changed, and they are not ones Nginx Helper purges.
595 + *
596 + * An excluded post type (a WooCommerce product) carries no reason and
597 + * stays with Nginx Helper.
598 + *
599 + * @param array<string,mixed> $context Public purge context.
600 + */
601 + private static function fallback_needs_whole_zone( array $context ): bool {
602 + $reason = isset( $context['fallback'] ) && is_string( $context['fallback'] ) ? $context['fallback'] : '';
603 + return in_array( $reason, array( Cache::FALLBACK_THEME_LIST, Cache::FALLBACK_PENDING, Cache::FALLBACK_FILTER, Cache::FALLBACK_LISTING ), true );
604 + }
605 +
606 + /**
607 + * Whether WordPress is importing content right now.
608 + */
609 + private static function is_importing(): bool {
610 + if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
611 + return true;
612 + }
613 +
614 + // The WXR importer defines WP_IMPORTING, but not every importer does;
615 + // `import_start` is the signal the others share.
616 + return function_exists( 'did_action' ) && did_action( 'import_start' ) > 0;
160 617 }
161 618
162 619 /**
163 620 * Build LiteSpeed targets for one same-site URL.