| @@ -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. |