PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
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 1.1.5 All 32 releases
← All changes | includes/class-server-caches.php +219 -3 1.3.5 → 1.3.6 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' ) ) {
@@ -156,8 +226,154 @@
156 226 }
157 227 foreach ( array_values( array_unique( $targets ) ) as $target ) {
158 228 do_action( 'litespeed_purge_url', $target );
159 229 }
230 + }
231 +
232 + /**
233 + * nginx FastCGI full-page cache, through the Nginx Helper plugin.
234 + *
235 + * Forwards on every intent, and on `content` only when Nginx Helper is not
236 + * purging for itself or `xspeed_nginx_helper_defer_content_purge` says
237 + * to. That asymmetry with `forward_litespeed()`, which forwards on all of
238 + * them, is deliberate.
239 + *
240 + * The two server caches are not alike in what a purge costs. LSCache is
241 + * per-site and tag-based: a site purge bumps one tag for one blog. The
242 + * nginx FastCGI zone is ONE directory per WordPress install, and clearing
243 + * it is a recursive unlink of every cached page — on multisite, of every
244 + * site on the network. So the blast radius of forwarding is an order of
245 + * magnitude apart for the same event.
246 + *
247 + * The other half is that we are not the only one purging. Nginx Helper
248 + * hooks `transition_post_status`, `before_delete_post` and the comment
249 + * hooks itself and purges only the URLs the edit touched (the post, the
250 + * homepage, the post's archives), behind its own `enable_purge` option and
251 + * an import guard. Its term hooks purge the homepage alone, which is why
252 + * a renamed or deleted term is `presentation` and still forwards. On a content
253 + * purge it has already done the narrow, correct thing. Forwarding on top
254 + * of that replaced targeted purging with a whole-install wipe at the same
255 + * frequency: publishing one post cleared every cached page on the site,
256 + * and an import cost one full wipe per post. (QA #444.)
257 + *
258 + * That argument only holds while Nginx Helper's `enable_purge` is on. It
259 + * defaults to off, and with it off Nginx Helper purges nothing on a
260 + * content edit. Standing down there left the edited post stale at the
261 + * server for the whole TTL, where before this adapter existed it was
262 + * cleared. So a content purge forwards when Nginx Helper is not purging
263 + * for itself. (QA #448)
264 + *
265 + * The trade that stays: Nginx Helper purges the post, the homepage and
266 + * the post's archives. An ordinary page that lists recent posts is none of
267 + * those, and keeps its old list until the server TTL expires. The
268 + * `xspeed_nginx_helper_defer_content_purge` filter returns to clearing
269 + * the whole zone on every content purge outside an import, for a site that
270 + * needs those pages current.
271 + *
272 + * `presentation` and `complete` still forward, because neither of those is
273 + * something Nginx Helper covers. It has no hook for `switch_theme`,
274 + * `activated_plugin` or `wp_update_nav_menu`, and no notion of a settings
275 + * write or a core update — and each of those changes the markup of every
276 + * page, not a listed few. An unrecognised intent forwards too: a purge
277 + * whose reason we do not know is likelier to need the server layer than
278 + * not, and a redundant purge costs a cold cache while a skipped one costs
279 + * wrong HTML for the whole TTL.
280 + *
281 + * Whether LiteSpeed should also stand down on `content` is a fair question
282 + * and was deliberately not revisited here — it has no targeted self-purge
283 + * to fall back on, so standing it down would leave LSCache stale where
284 + * nginx is merely over-cleared.
285 + *
286 + * @param array<string,mixed> $context Public purge context.
287 + */
288 + private static function forward_nginx_helper( array $context ): void {
289 + // Guarded rather than assumed: Free is upgraded as a unit, but a
290 + // half-copied update can leave this file newer than that one.
291 + if ( ! class_exists( __NAMESPACE__ . '\\Host_Page_Caches' ) ) {
292 + return;
293 + }
294 +
295 + $url = isset( $context['url'] ) && is_string( $context['url'] ) ? $context['url'] : '';
296 + $scope = isset( $context['scope'] ) && is_string( $context['scope'] )
297 + ? $context['scope']
298 + : ( '' !== $url ? 'urls' : 'site' );
299 +
300 + // `urls` is a per-URL purge, which this integration does not do yet —
301 + // see Host_Page_Caches. Standing down is the honest answer: the
302 + // alternative, treating a one-page purge as a reason to clear the
303 + // whole install, is the bug this method exists to fix.
304 + if ( 'urls' === $scope || 'none' === $scope ) {
305 + return;
306 + }
307 +
308 + $intent = isset( $context['intent'] ) && is_string( $context['intent'] ) && '' !== $context['intent']
309 + ? $context['intent']
310 + : 'complete';
311 +
312 + // Nothing to decide on a site with no nginx zone, so the filter below
313 + // is only asked when there is one.
314 + if ( ! Host_Page_Caches::nginx_helper_is_fastcgi() ) {
315 + return;
316 + }
317 +
318 + if ( 'content' === $intent ) {
319 + /**
320 + * Whether a content purge (a post saved, a comment approved, a
321 + * term added) is left to Nginx Helper instead of clearing the
322 + * whole nginx cache.
323 + *
324 + * Defaults to true when Nginx Helper's automatic purging is on,
325 + * since it has already purged the post, the homepage and the
326 + * post's archives. Return false to clear the whole zone instead,
327 + * for a site whose pages list posts somewhere Nginx Helper does
328 + * not purge.
329 + *
330 + * @param bool $defer Whether to leave it to Nginx Helper.
331 + * @param array<string,mixed> $context Public purge context.
332 + */
333 + $defer = (bool) apply_filters(
334 + 'xspeed_nginx_helper_defer_content_purge',
335 + Host_Page_Caches::nginx_helper_purges_changes(),
336 + $context
337 + );
338 + if ( $defer ) {
339 + return;
340 + }
341 + }
342 +
343 + // An import is a long run of legitimate purges that each individually
344 + // justify a forward — new terms, new menu items — and together clear
345 + // the install's cache hundreds of times for one operation. Nginx
346 + // Helper stands its own purging down for exactly this (its
347 + // `is_import_request()`), and a single purge after the import is both
348 + // cheaper and more correct. An explicit `complete` still goes through:
349 + // an operator who presses Purge All mid-import means it.
350 + if ( 'complete' !== $intent && self::is_importing() ) {
351 + return;
352 + }
353 +
354 + // No host check, deliberately — the mirror of the one in
355 + // forward_litespeed(). There, a purge aimed at another blog must not
356 + // flush THIS request's LSCache, because LSCache is per-site. nginx
357 + // keys one zone per install, so the other blog's cached pages live in
358 + // the same directory as ours: skipping on a foreign host would leave
359 + // the pages the purge was actually for still being served. Pro's
360 + // Multisite::purge_site() runs inside switch_to_blog() and reaches
361 + // here with that blog's host.
362 + Host_Page_Caches::purge_nginx_helper();
363 + }
364 +
365 + /**
366 + * Whether WordPress is importing content right now.
367 + */
368 + private static function is_importing(): bool {
369 + if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
370 + return true;
371 + }
372 +
373 + // The WXR importer defines WP_IMPORTING, but not every importer does;
374 + // `import_start` is the signal the others share.
375 + return function_exists( 'did_action' ) && did_action( 'import_start' ) > 0;
160 376 }
161 377
162 378 /**
163 379 * Build LiteSpeed targets for one same-site URL.