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
xspeed / includes / class-purge-runner.php

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

453 lines 15.8 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}
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 Cache::announce_purge( $cause, $entries );
344
345 Cache_Inventory::invalidate();
346
347 Activity_Log::record(
348 'cache_purged',
349 sprintf(
350 /* translators: 1: what asked for the purge, 2: number of files removed. */
351 __( 'Cache purged (%1$s) — %2$d file(s) removed', 'xspeed' ),
352 $cause,
353 $entries
354 ),
355 Activity_Log::INFO
356 );
357
358 return array(
359 'entries' => $entries,
360 'bytes' => (int) $removed['bytes'],
361 );
362 }
363
364 /**
365 * Whether flushing the object cache would do anything.
366 *
367 * A degraded drop-in — installed, connected to nothing — is a skip and
368 * not a failure: there is no persisted data to clear, and the drop-in's
369 * own health is what `wp xspeed objcache status` is for.
370 *
371 * @return true|string
372 */
373 public static function object_enabled() {
374 if ( ! class_exists( '\\XSpeed\\Object_Cache' ) ) {
375 return __( 'the object cache engine is unavailable', 'xspeed' );
376 }
377 $state = Object_Cache::detect();
378 if ( empty( $state['wp_cache_active'] ) ) {
379 return __( 'no persistent object cache drop-in is in use', 'xspeed' );
380 }
381 if ( ! empty( $state['degraded'] ) ) {
382 return __( 'the drop-in is installed but is not persisting anything', 'xspeed' );
383 }
384
385 return true;
386 }
387
388 /**
389 * Flush the object cache.
390 *
391 * @param string $cause Who asked.
392 * @return array{ok:bool,entries:null,reason:string}
393 */
394 public static function purge_object( string $cause ): array {
395 $ok = Cache::flush_object_cache();
396 if ( $ok ) {
397 // Entries stay null: neither Redis nor Memcached reports how many
398 // keys a FLUSHALL dropped, and inventing a number would be worse
399 // than the honest blank.
400 Activity_Log::record(
401 'cache_purged',
402 sprintf(
403 /* translators: %s: what asked for the purge. */
404 __( 'Purged object cache (%s)', 'xspeed' ),
405 $cause
406 ),
407 Activity_Log::INFO
408 );
409 }
410
411 return array(
412 'ok' => $ok,
413 'entries' => null,
414 'reason' => $ok ? '' : __( 'the backend refused the flush', 'xspeed' ),
415 );
416 }
417
418 /**
419 * Whether the Cloudflare integration is configured well enough to purge.
420 *
421 * @return true|string
422 */
423 public static function cloudflare_enabled() {
424 $module = Module_Registry::get( 'cloudflare' );
425 if ( ! $module || ! method_exists( $module, 'can_purge_edge' ) ) {
426 return __( 'the Cloudflare module is not available', 'xspeed' );
427 }
428
429 return $module->can_purge_edge();
430 }
431
432 /**
433 * Purge the Cloudflare edge.
434 *
435 * @param string $cause Who asked.
436 * @return array{ok:bool,reason:string}
437 */
438 public static function purge_cloudflare( string $cause ): array {
439 $module = Module_Registry::get( 'cloudflare' );
440
441 return $module->purge_edge( $cause );
442 }
443
444 /**
445 * Whether any CDN with a purge API is configured. See the `cdn` target.
446 *
447 * @return true|string
448 */
449 public static function cdn_enabled() {
450 return __( 'no CDN with a purge API is configured — xSpeed\'s CDN module rewrites asset URLs and holds no cache of its own', 'xspeed' );
451 }
452 }
453