PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 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 All 36 releases
xspeed / includes / class-purge-runner.php

class-purge-runner.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.2, at includes/class-purge-runner.php

470 lines 16.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Purge_Runner — clear every cache xSpeed owns and report, per store, what
4 * was actually cleared.
5 *
6 * The one core function behind `wp xspeed purge`, the REST purge callback
7 * and the MCP `purge_cache` tool, so the three cannot drift. Cache::purge_all()
8 * remains the engine for the local files; this class is the layer that knows
9 * which stores exist, which of them are switched on, and how to say so.
10 *
11 * The distinction that matters everywhere below is skipped vs failed. A store
12 * that is not configured has nothing to clear, so the run still succeeded —
13 * a CI script that fails because a site has no Cloudflare zone is a script
14 * that gets disabled. A store that IS configured and refused the purge is a
15 * failure, because the stale bytes are still being served.
16 *
17 * @package XSpeed
18 */
19
20 namespace XSpeed;
21
22 defined( 'ABSPATH' ) || exit;
23
24 final class Purge_Runner {
25
26 /** A store that was cleared — `entries`/`bytes` say how much, and may be 0. */
27 public const CLEARED = 'cleared';
28
29 /** Nothing to clear: the store is off, unconfigured, or has no backend. */
30 public const SKIPPED = 'skipped';
31
32 /** The store is configured and the purge was refused or errored. */
33 public const FAILED = 'failed';
34
35 /** Built-in target slugs, in the order a full purge runs them. */
36 public const TYPES = array( 'page', 'object', 'cloudflare', 'cdn' );
37
38 /**
39 * Set for the duration of a run so CloudflareModule::on_xspeed_purge()
40 * can stand down: with auto_purge_on_update on, the `xspeed_after_purge_all`
41 * fired by the page step would hit the Cloudflare API a second time, and
42 * the second call's result — not the one we reported — would be the one
43 * written to the module's health record.
44 *
45 * @var array<int,string>|null
46 */
47 private static $running_slugs = null;
48
49 /** Whether a purge is currently being coordinated by this class. */
50 public static function is_running(): bool {
51 return null !== self::$running_slugs;
52 }
53
54 /**
55 * Whether the run in progress will purge a given store itself.
56 *
57 * The question a listener on `xspeed_after_purge_all` has to ask before
58 * standing down. `is_running()` alone is not enough: on `--type=page` the
59 * action still fires, but no edge target runs — a listener that stood
60 * down on `is_running()` would leave the edge stale AND unreported.
61 *
62 * @param string $slug Target slug, e.g. `cloudflare`.
63 */
64 public static function covers( string $slug ): bool {
65 return null !== self::$running_slugs && in_array( $slug, self::$running_slugs, true );
66 }
67
68 /**
69 * Purge the requested stores.
70 *
71 * @param array<int,string> $types Target slugs, or `all` for everything.
72 * @param string $cause Who asked, for the purge log.
73 * @return array{ok:bool,cause:string,requested:array<int,string>,types:array<string,array{label:string,group:string,status:string,entries:int|null,bytes:int|null,reason:string}>}
74 */
75 public static function run( array $types, string $cause = 'manual' ): array {
76 $targets = self::targets();
77 $slugs = self::expand( $types, $targets );
78
79 $report = array(
80 'ok' => true,
81 'cause' => $cause,
82 'requested' => array_values( $slugs ),
83 'types' => array(),
84 );
85
86 $outer = self::$running_slugs;
87 self::$running_slugs = $slugs;
88 try {
89 foreach ( $slugs as $slug ) {
90 $report['types'][ $slug ] = self::run_target( $slug, $targets[ $slug ], $cause );
91 if ( self::FAILED === $report['types'][ $slug ]['status'] ) {
92 $report['ok'] = false;
93 }
94 }
95 } finally {
96 // Restore rather than null out: a registered target that runs its
97 // own purge would otherwise end the OUTER run's guard halfway
98 // through, and every listener after it would fire twice.
99 self::$running_slugs = $outer;
100 }
101
102 // Render caches belong to "Purge All" — the user saying they trust
103 // nothing stored anywhere — and not to a scoped run. purge_all()
104 // itself deliberately skips them because it also fires on every post
105 // publish; the same reasoning applies to `--type=page`. Matching
106 // Cache::purge_type( 'all' ), which is the other caller.
107 if ( $slugs === array_keys( $targets ) ) {
108 Cache::purge_render_caches( $cause );
109 }
110
111 return $report;
112 }
113
114 /**
115 * Run one target, turning anything it throws into a `failed` row.
116 *
117 * A store that fatals must not take the rest of the purge down with it:
118 * the whole point of the command is that one call clears everything, and
119 * an edge provider timing out is no reason to leave the page cache warm.
120 *
121 * @param string $slug Target slug.
122 * @param array $target Target definition from targets().
123 * @param string $cause Who asked.
124 * @return array{label:string,group:string,status:string,entries:int|null,bytes:int|null,reason:string}
125 */
126 private static function run_target( string $slug, array $target, string $cause ): array {
127 $row = array(
128 'label' => (string) $target['label'],
129 'group' => (string) ( $target['group'] ?? 'other' ),
130 'status' => self::SKIPPED,
131 'entries' => null,
132 'bytes' => null,
133 'reason' => '',
134 );
135
136 // `enabled` is optional in the filter contract — a store with nothing
137 // to gate on just omits it. Reading the key unconditionally turned
138 // that into an undefined-index warning AND a permanent skip, so the
139 // callback never ran and the warning landed on STDOUT, where it
140 // corrupts `--format=json`.
141 $gate = array_key_exists( 'enabled', $target ) ? $target['enabled'] : '__return_true';
142 $enabled = is_callable( $gate ) ? call_user_func( $gate ) : $gate;
143 if ( true !== $enabled ) {
144 $row['reason'] = is_string( $enabled ) && '' !== $enabled
145 ? $enabled
146 /* translators: %s: cache store name, e.g. "Cloudflare edge". */
147 : sprintf( __( '%s is not enabled', 'xspeed' ), $target['label'] );
148 return $row;
149 }
150
151 try {
152 $result = call_user_func( $target['callback'], $cause );
153 } catch ( \Throwable $e ) {
154 $row['status'] = self::FAILED;
155 $row['reason'] = $e->getMessage();
156 return $row;
157 }
158
159 $result = is_array( $result ) ? $result : array();
160 if ( array_key_exists( 'ok', $result ) && ! $result['ok'] ) {
161 $row['status'] = self::FAILED;
162 $row['reason'] = (string) ( $result['reason'] ?? __( 'the purge was refused', 'xspeed' ) );
163 return $row;
164 }
165 if ( ! empty( $result['skipped'] ) ) {
166 $row['reason'] = (string) ( $result['reason'] ?? '' );
167 return $row;
168 }
169
170 $row['status'] = self::CLEARED;
171 $row['entries'] = isset( $result['entries'] ) ? (int) $result['entries'] : null;
172 $row['bytes'] = isset( $result['bytes'] ) ? (int) $result['bytes'] : null;
173 $row['reason'] = (string) ( $result['reason'] ?? '' );
174
175 return $row;
176 }
177
178 /**
179 * Resolve the requested type list to concrete target slugs, in run order.
180 *
181 * `cdn` is a GROUP, not a single target, so a Pro or third-party provider
182 * registered through `xspeed_purge_targets` is reachable by the same flag
183 * without the caller needing to know its slug.
184 *
185 * @param array<int,string> $types Requested types.
186 * @param array $targets Target definitions.
187 * @return array<int,string>
188 */
189 private static function expand( array $types, array $targets ): array {
190 $types = array_filter( array_map( 'strval', $types ) );
191 if ( ! $types || in_array( 'all', $types, true ) ) {
192 return array_keys( $targets );
193 }
194
195 $slugs = array();
196 foreach ( $types as $type ) {
197 if ( isset( $targets[ $type ] ) ) {
198 $slugs[] = $type;
199 continue;
200 }
201 // A group name: select every target in it.
202 foreach ( $targets as $slug => $target ) {
203 if ( $type === ( $target['group'] ?? 'other' ) ) {
204 $slugs[] = $slug;
205 }
206 }
207 }
208
209 // array_keys() order, not request order, so `--type=object,page`
210 // still sweeps the files before flushing the object cache.
211 return array_values( array_intersect( array_keys( $targets ), array_unique( $slugs ) ) );
212 }
213
214 /**
215 * Every type name `--type` accepts: the target slugs plus their groups.
216 *
217 * @return array<int,string>
218 */
219 public static function accepted_types(): array {
220 $names = array( 'all' );
221 foreach ( self::targets() as $slug => $target ) {
222 $names[] = $slug;
223 $names[] = (string) ( $target['group'] ?? 'other' );
224 }
225
226 return array_values( array_unique( $names ) );
227 }
228
229 /**
230 * The purgeable stores.
231 *
232 * Each entry is `label`, `group` (page|object|edge|cdn|other), `enabled`
233 * (a callable returning true, or a string saying why not) and `callback`
234 * (a callable taking the cause and returning
235 * `{entries?:int, bytes?:int, ok?:bool, skipped?:bool, reason?:string}`).
236 *
237 * @return array<string,array{label:string,group:string,enabled:callable,callback:callable}>
238 */
239 public static function targets(): array {
240 $targets = array(
241 'page' => array(
242 // Named for the flag users reach for, but it is the whole
243 // local sweep: flat HTML, the static tree and cached REST
244 // responses. Splitting those into three line items would be
245 // three rows that always move together. Minified assets are
246 // not in it: they are named by content, so a site purge
247 // leaves them (see `wp xspeed minify purge`).
248 'label' => __( 'Page cache (HTML, static, REST)', 'xspeed' ),
249 'group' => 'page',
250 // Always attempted. Files outlive the setting that wrote them,
251 // so a site that has just turned page caching OFF is exactly
252 // the site with stale HTML still on disk — refusing to sweep
253 // it would be the wrong answer to the only question the user
254 // is asking. The sweep is idempotent and reports 0.
255 'enabled' => '__return_true',
256 'callback' => array( self::class, 'purge_page' ),
257 ),
258 'object' => array(
259 'label' => __( 'Object cache', 'xspeed' ),
260 'group' => 'object',
261 'enabled' => array( self::class, 'object_enabled' ),
262 'callback' => array( self::class, 'purge_object' ),
263 ),
264 'cloudflare' => array(
265 'label' => __( 'Cloudflare edge', 'xspeed' ),
266 'group' => 'edge',
267 'enabled' => array( self::class, 'cloudflare_enabled' ),
268 'callback' => array( self::class, 'purge_cloudflare' ),
269 ),
270 );
271 // Only where xCloud's purge plugin is installed, so every other site's
272 // report is not padded with a row that can never apply. An installed
273 // copy too old to take purges still gets the row: its reason is the
274 // instruction to update.
275 if ( Managed_Edge_Purge::installed() ) {
276 $targets['xcloud'] = array(
277 'label' => __( 'Cloudflare Enterprise (xCloud)', 'xspeed' ),
278 'group' => 'edge',
279 'enabled' => array( Managed_Edge_Purge::class, 'status' ),
280 'callback' => array( Managed_Edge_Purge::class, 'purge_everything' ),
281 );
282 }
283 $targets += array(
284 'cdn' => array(
285 'label' => __( 'CDN', 'xspeed' ),
286 'group' => 'cdn',
287 // xSpeed's own CDN module rewrites asset URLs; it holds no
288 // cache and has no purge API to call. It stays in the list so
289 // `--type=cdn` answers the question rather than erroring on an
290 // unknown type, and so a provider that DOES have a purge API
291 // can register one through xspeed_purge_targets.
292 'enabled' => array( self::class, 'cdn_enabled' ),
293 'callback' => '__return_empty_array',
294 ),
295 );
296
297 /**
298 * Register a purgeable store with `wp xspeed purge`.
299 *
300 * Add-ons hook this to be included in a full purge and reachable via
301 * `--type=<slug>`. Registering a target does NOT replace hooking
302 * `xspeed_after_purge_all` — that action still fires — it is how a
303 * store gets its own line in the report, with its own count and its
304 * own success or failure.
305 *
306 * @param array<string,array{label:string,group:string,enabled:callable,callback:callable}> $targets Store definitions keyed by slug.
307 */
308 $targets = (array) apply_filters( 'xspeed_purge_targets', $targets );
309
310 return array_filter(
311 $targets,
312 static function ( $target ) {
313 return is_array( $target ) && isset( $target['label'], $target['callback'] ) && is_callable( $target['callback'] );
314 }
315 );
316 }
317
318 /**
319 * Local files: the flat tree, the static tree and REST responses, and,
320 * through `xspeed_after_purge_all`, whatever modules clear alongside
321 * them.
322 *
323 * @param string $cause Who asked.
324 * @return array{entries:int,bytes:int,reason:string}
325 */
326 public static function purge_page( string $cause ): array {
327 $removed = Cache::purge_local();
328 $entries = (int) $removed['pages'] + (int) $removed['rest'] + (int) $removed['assets'];
329
330 Cache::update_stats( array( 'last_purge' => time() ) );
331
332 // Isolated per listener, and the throw swallowed: this sweep is
333 // already done, and one listener (Critical CSS, Unused CSS, the
334 // Cloudflare edge purge) failing must not cancel the ones queued
335 // behind it or stop the announcement below. Same handling
336 // purge_all() gives this action. (QA #348)
337 Cache::do_action_isolated( 'xspeed_after_purge_all', $cause );
338
339 // The public purge contract, which purge_all() publishes but
340 // purge_local() does not — without it `wp xspeed purge`, the
341 // dashboard button and the MCP purge tool would clear our files and
342 // leave LiteSpeed serving the stale page, having reported success.
343 // The announce also reaches caches in front of PHP, such as nginx
344 // through Nginx Helper; the row names them so the operator can tell
345 // they were reached. (#654)
346 $run = Cache::report_forwarding(
347 static function () use ( $cause, $entries ): void {
348 Cache::announce_purge( $cause, $entries );
349 }
350 );
351
352 $reason = '';
353 if ( array() !== $run['forwarded'] ) {
354 $reason = sprintf(
355 /* translators: %s: names of the caches in front of the site, e.g. "Nginx Helper". */
356 __( 'also sent the purge to %s', 'xspeed' ),
357 implode( ', ', $run['forwarded'] )
358 );
359 }
360
361 Cache_Inventory::invalidate();
362
363 Activity_Log::record(
364 'cache_purged',
365 sprintf(
366 /* translators: 1: what asked for the purge, 2: number of files removed. */
367 __( 'Cache purged (%1$s) — %2$d file(s) removed', 'xspeed' ),
368 $cause,
369 $entries
370 ),
371 Activity_Log::INFO
372 );
373
374 return array(
375 'entries' => $entries,
376 'bytes' => (int) $removed['bytes'],
377 'reason' => $reason,
378 );
379 }
380
381 /**
382 * Whether flushing the object cache would do anything.
383 *
384 * A degraded drop-in — installed, connected to nothing — is a skip and
385 * not a failure: there is no persisted data to clear, and the drop-in's
386 * own health is what `wp xspeed objcache status` is for.
387 *
388 * @return true|string
389 */
390 public static function object_enabled() {
391 if ( ! class_exists( '\\XSpeed\\Object_Cache' ) ) {
392 return __( 'the object cache engine is unavailable', 'xspeed' );
393 }
394 $state = Object_Cache::detect();
395 if ( empty( $state['wp_cache_active'] ) ) {
396 return __( 'no persistent object cache drop-in is in use', 'xspeed' );
397 }
398 if ( ! empty( $state['degraded'] ) ) {
399 return __( 'the drop-in is installed but is not persisting anything', 'xspeed' );
400 }
401
402 return true;
403 }
404
405 /**
406 * Flush the object cache.
407 *
408 * @param string $cause Who asked.
409 * @return array{ok:bool,entries:null,reason:string}
410 */
411 public static function purge_object( string $cause ): array {
412 $ok = Cache::flush_object_cache();
413 if ( $ok ) {
414 // Entries stay null: neither Redis nor Memcached reports how many
415 // keys a FLUSHALL dropped, and inventing a number would be worse
416 // than the honest blank.
417 Activity_Log::record(
418 'cache_purged',
419 sprintf(
420 /* translators: %s: what asked for the purge. */
421 __( 'Purged object cache (%s)', 'xspeed' ),
422 $cause
423 ),
424 Activity_Log::INFO
425 );
426 }
427
428 return array(
429 'ok' => $ok,
430 'entries' => null,
431 'reason' => $ok ? '' : __( 'the backend refused the flush', 'xspeed' ),
432 );
433 }
434
435 /**
436 * Whether the Cloudflare integration is configured well enough to purge.
437 *
438 * @return true|string
439 */
440 public static function cloudflare_enabled() {
441 $module = Module_Registry::get( 'cloudflare' );
442 if ( ! $module || ! method_exists( $module, 'can_purge_edge' ) ) {
443 return __( 'the Cloudflare module is not available', 'xspeed' );
444 }
445
446 return $module->can_purge_edge();
447 }
448
449 /**
450 * Purge the Cloudflare edge.
451 *
452 * @param string $cause Who asked.
453 * @return array{ok:bool,reason:string}
454 */
455 public static function purge_cloudflare( string $cause ): array {
456 $module = Module_Registry::get( 'cloudflare' );
457
458 return $module->purge_edge( $cause );
459 }
460
461 /**
462 * Whether any CDN with a purge API is configured. See the `cdn` target.
463 *
464 * @return true|string
465 */
466 public static function cdn_enabled() {
467 return __( 'no CDN with a purge API is configured — xSpeed\'s CDN module rewrites asset URLs and holds no cache of its own', 'xspeed' );
468 }
469 }
470