PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
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
xspeed / includes / class-host-page-caches.php

class-host-page-caches.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.1, at includes/class-host-page-caches.php

272 lines 12.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Host_Page_Caches — forward a purge to a full-page cache that lives in the
4 * WEB SERVER rather than in WordPress.
5 *
6 * The full rationale is on the class below. Kept short here on purpose:
7 * Plugin Check reads only the first 50 lines of a file when looking for the
8 * direct-access guard, so a long header docblock pushes the guard out of its
9 * window and the file reports `missing_direct_file_access_protection` while
10 * being perfectly well guarded.
11 *
12 * @package XSpeed
13 */
14
15 declare(strict_types=1);
16
17 namespace XSpeed;
18
19 defined( 'ABSPATH' ) || exit;
20
21 /**
22 * Forward a purge to a full-page cache that lives in the WEB SERVER rather
23 * than in WordPress.
24 *
25 * Why this exists: `Cache::purge_all()` sweeps the trees xSpeed owns, under
26 * `wp-content/cache/`. On a host that runs its own nginx FastCGI full-page
27 * cache in front of PHP, that sweep reaches none of it — nginx keeps
28 * answering from `/etc/nginx/cache/<site>` until its own `fastcgi_cache_valid`
29 * window expires (an hour on a stock xCloud site). The observable bug is the
30 * one every "purge did nothing" report describes: the admin edits a page,
31 * clicks Purge All, xSpeed reports the files cleared, and the anonymous
32 * visitor still gets yesterday's HTML.
33 *
34 * We do not talk to nginx ourselves, and we do not touch its cache directory.
35 * The purge goes through **Nginx Helper** (rtCamp), which is the plugin the
36 * host installs and configures alongside that cache — xCloud, for one, runs
37 * `wp plugin install nginx-helper --activate`, writes its options and sets
38 * `RT_WP_NGINX_HELPER_CACHE_PATH` when a site owner turns full-page caching
39 * on. Nginx Helper owns the cache path, the key derivation and the options;
40 * all of that is read-only to us, always.
41 *
42 * `rt_nginx_helper_purge_all` is an action Nginx Helper exposes for exactly
43 * this — its own source comments it "expose action to allow other plugins to
44 * purge the cache" (includes/class-nginx-helper.php) — so this is a supported
45 * seam, not a reach into another plugin's internals.
46 *
47 * Detection is by constant and global only, never `is_plugin_active()` on a
48 * path string: a renamed plugin folder must not silently turn the integration
49 * off. Same rule as Render_Caches.
50 *
51 * **This class is the mechanism, not the policy.** It knows how to detect the
52 * server cache and how to ask Nginx Helper to clear it. WHEN to ask is decided
53 * one level up, by `Server_Caches::forward()`, from the `intent` and `scope`
54 * on the purge-event contract — which is also where the reasoning for standing
55 * down on a content purge is written. Nothing here listens to a hook.
56 *
57 * Two calls: purge_nginx_helper() clears the whole zone through the action
58 * above, and purge_nginx_helper_urls() clears named pages through
59 * `$GLOBALS['nginx_purger']->purge_url()`, a method on its purger object
60 * rather than an action. That method checks `is_page()`/`is_single()` before
61 * purging an AMP copy; both only warn when the global `$wp_query` does not
62 * exist, and `wp-settings.php` creates it for every request, so WP-CLI, cron
63 * and REST purges are quiet.
64 *
65 * **Multisite:** nginx keys one cache zone per *install*, not per subsite, so
66 * a purge here clears every site on the network at the nginx layer. That is
67 * accepted rather than worked around — a cold cache costs one slow request
68 * per page, stale HTML costs a wrong page for the whole TTL.
69 */
70 final class Host_Page_Caches {
71
72 /**
73 * The site option Nginx Helper stores its configuration in. Network-wide
74 * on multisite, which is why it is read with `get_site_option()`.
75 */
76 private const NH_OPTION = 'rt_wp_nginx_helper_options';
77
78 /**
79 * The `cache_method` value that means "nginx FastCGI full-page cache".
80 * The other one Nginx Helper supports is `enable_redis`, which is a
81 * page cache in Redis fronted by nginx's `srcache` module — a different
82 * layer, not present on the hosts this integration targets, and not
83 * something a purge from here should reach for.
84 */
85 private const NH_FASTCGI = 'enable_fastcgi';
86
87 /**
88 * Forward a full purge to the server-level cache, when there is one.
89 *
90 * No gate on the purge's reason here — `Server_Caches::forward()` has
91 * already decided this purge should reach the server layer. Detection is
92 * still checked, because it is a fact about the environment rather than
93 * about the purge, and it is only stable at purge time: Nginx Helper
94 * builds `$GLOBALS['nginx_purger']` from its own `plugins_loaded`
95 * callback, so anything asked earlier races its load order.
96 *
97 * Re-entrancy is handled upstream. A third-party listener on
98 * `rt_nginx_helper_purge_all` that calls back into `Cache::purge_all()`
99 * used to recurse here until PHP ran out of stack, which is what the
100 * latch this method once carried was for. The inner purge now re-enters
101 * `Cache::dispatch_purge_event()`, whose `$purge_events_in_flight` guard
102 * is still held by the outer one and returns before any adapter runs.
103 * Pinned by a test that recurses through `Cache::purge_all()` itself
104 * rather than through this method.
105 *
106 * @return bool Whether the purge was forwarded.
107 */
108 public static function purge_nginx_helper(): bool {
109 if ( ! self::nginx_helper_is_fastcgi() ) {
110 return false;
111 }
112
113 try {
114 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Nginx Helper's own public integration hook; an xspeed_-prefixed name would reach nothing.
115 do_action( 'rt_nginx_helper_purge_all' );
116 } catch ( \Throwable $e ) {
117 /*
118 * The same third-party listeners the latch above exists for can
119 * also throw, and everything on this action is code we do not
120 * own. Letting it out would take down `Cache::purge_all()` — the
121 * admin presses Purge All, another plugin's mu-plugin fatals, and
122 * the failure reads as xSpeed's. The local sweep has already
123 * happened by the time we run, so swallowing this costs the
124 * server layer and nothing else. `Server_Caches::forward()`
125 * catches around this call for the same reason and logs it under
126 * WP_DEBUG; this inner catch is what turns a throw into an honest
127 * "not forwarded" return rather than a reported purge.
128 */
129 return false;
130 }
131
132 return true;
133 }
134
135 /**
136 * Purge named pages from the nginx FastCGI cache through Nginx Helper.
137 *
138 * Its purger's `purge_url()` is the same call Nginx Helper makes for its
139 * own post purges. Feeds are not added (`$feed = false`): xSpeed names the
140 * feeds it means. If the purger cannot take a URL, the whole zone goes
141 * instead, because a page left stale is worse than a cold cache. The
142 * same happens when the calls run past `$seconds`: with the `get_request`
143 * method each one is a blocking HTTP request, and a slow purge endpoint
144 * would otherwise hold the request for the whole list.
145 *
146 * @param string[] $urls Absolute URLs on this site.
147 * @param float $seconds Time allowed for the per-URL calls; 0 for no limit.
148 */
149 public static function purge_nginx_helper_urls( array $urls, float $seconds = 0.0 ): bool {
150 if ( array() === $urls || ! self::nginx_helper_is_fastcgi() ) {
151 return false;
152 }
153 $purger = $GLOBALS['nginx_purger'] ?? null;
154 if ( ! is_object( $purger ) || ! method_exists( $purger, 'purge_url' ) ) {
155 return self::purge_nginx_helper();
156 }
157 $started = microtime( true );
158 $left = count( $urls );
159 try {
160 foreach ( $urls as $url ) {
161 $purger->purge_url( (string) $url, false );
162 --$left;
163 if ( $left > 0 && $seconds > 0 && microtime( true ) - $started > $seconds ) {
164 return self::purge_nginx_helper();
165 }
166 }
167 } catch ( \Throwable $e ) {
168 return self::purge_nginx_helper();
169 }
170 return true;
171 }
172
173 /**
174 * Whether Nginx Helper is present AND configured against an nginx FastCGI
175 * cache.
176 *
177 * Three separate facts, because each one alone is a false positive:
178 *
179 * 1. `RT_WP_NGINX_HELPER_CACHE_PATH` — there is a directory to purge, and
180 * we know which one, because Health names it.
181 *
182 * Do NOT read this as evidence the host configured anything. Nginx
183 * Helper defines the constant itself whenever it is not already set,
184 * defaulting to `/var/run/nginx-cache` through its own
185 * `rt_wp_nginx_helper_cache_path` filter (its
186 * `includes/class-nginx-helper.php`, in the constructor). So it is
187 * defined on every install and cannot be missing while the plugin is
188 * loaded — the only way past this check is a WordPress older than the
189 * plugin's minimum, where it returns before the `define`.
190 *
191 * On a site where the host never set a path, that default is what
192 * Nginx Helper would unlink, so it is also what we forward a purge
193 * against and what Health names. That directory usually does not
194 * exist, making its `purge_all()` a no-op we would report as a purge.
195 * Left alone deliberately: it is exactly what the admin gets from
196 * Nginx Helper's own Purge All button, and second-guessing another
197 * plugin's configured path is not ours to do.
198 * 2. `$GLOBALS['nginx_purger']` — the plugin finished booting and built a
199 * purger. The constant can be defined in `wp-config.php` by a host
200 * whose site owner then deactivated the plugin, in which case the
201 * action has no listener and firing it is a silent no-op we would
202 * still report as a purge.
203 * 3. `cache_method === 'enable_fastcgi'` — it is the page cache we mean.
204 * On `enable_redis` the same action clears a Redis key space that
205 * xSpeed's own object-cache flush may already own.
206 *
207 * Note what is deliberately NOT in the gate: `enable_purge`. That option
208 * governs Nginx Helper's own AUTOMATIC purging — its post-save, comment
209 * and term hooks each check it — and does not reach `purge_all()`, which
210 * runs whatever it is set to. An admin who switched automatic purging off
211 * has said "don't purge behind my back"; they have not said "ignore me
212 * when I press Purge All". Reading it as the latter would leave an
213 * explicit, operator-initiated purge silently short of the layer actually
214 * serving the page, which is the exact failure this integration exists to
215 * fix. `nginx_helper_purges_changes()` does read it, only to decide
216 * whether a content purge can be left to Nginx Helper.
217 */
218 public static function nginx_helper_is_fastcgi(): bool {
219 return null !== self::nginx_helper_cache_path();
220 }
221
222 /**
223 * The cache directory Nginx Helper is pointed at, or null when the
224 * integration does not apply. Read-only — we never define the constant
225 * and never write the option.
226 *
227 * Health reports the path; the gate only cares whether there is one.
228 */
229 public static function nginx_helper_cache_path(): ?string {
230 if ( ! defined( 'RT_WP_NGINX_HELPER_CACHE_PATH' ) ) {
231 return null;
232 }
233 $path = (string) constant( 'RT_WP_NGINX_HELPER_CACHE_PATH' );
234 if ( '' === $path ) {
235 return null;
236 }
237 if ( ! isset( $GLOBALS['nginx_purger'] ) || ! is_object( $GLOBALS['nginx_purger'] ) ) {
238 return null;
239 }
240 $options = get_site_option( self::NH_OPTION );
241 if ( ! is_array( $options ) || self::NH_FASTCGI !== ( $options['cache_method'] ?? '' ) ) {
242 return null;
243 }
244 return $path;
245 }
246
247 /**
248 * Whether Nginx Helper purges changed pages by itself.
249 *
250 * Its `enable_purge` option is the one switch in front of all of its
251 * automatic purging: the post, comment and term hooks each return early
252 * without it. It defaults to off, so an install where the host never
253 * turned it on purges nothing when a post is published. Not part of the
254 * detection gate above; `Server_Caches` asks it only to decide whether a
255 * content purge can be left to Nginx Helper.
256 */
257 public static function nginx_helper_purges_changes(): bool {
258 $options = get_site_option( self::NH_OPTION );
259 return is_array( $options ) && ! empty( $options['enable_purge'] );
260 }
261
262 /**
263 * The configured purge method (`unlink_files`, `get_request`, …), or ''
264 * when unset. Health uses it to decide whether the permissions caveat
265 * applies; nothing gates on it.
266 */
267 public static function nginx_helper_purge_method(): string {
268 $options = get_site_option( self::NH_OPTION );
269 return is_array( $options ) ? (string) ( $options['purge_method'] ?? '' ) : '';
270 }
271 }
272