PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-purge-runner.php

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

437 lines 15.1 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, cached REST
244 // responses and minified assets. Splitting those into four
245 // line items would be four rows that always move together.
246 'label' => __( 'Page cache (HTML, static, REST, minified assets)', 'xspeed' ),
247 'group' => 'page',
248 // Always attempted. Files outlive the setting that wrote them,
249 // so a site that has just turned page caching OFF is exactly
250 // the site with stale HTML still on disk — refusing to sweep
251 // it would be the wrong answer to the only question the user
252 // is asking. The sweep is idempotent and reports 0.
253 'enabled' => '__return_true',
254 'callback' => array( self::class, 'purge_page' ),
255 ),
256 'object' => array(
257 'label' => __( 'Object cache', 'xspeed' ),
258 'group' => 'object',
259 'enabled' => array( self::class, 'object_enabled' ),
260 'callback' => array( self::class, 'purge_object' ),
261 ),
262 'cloudflare' => array(
263 'label' => __( 'Cloudflare edge', 'xspeed' ),
264 'group' => 'edge',
265 'enabled' => array( self::class, 'cloudflare_enabled' ),
266 'callback' => array( self::class, 'purge_cloudflare' ),
267 ),
268 'cdn' => array(
269 'label' => __( 'CDN', 'xspeed' ),
270 'group' => 'cdn',
271 // xSpeed's own CDN module rewrites asset URLs; it holds no
272 // cache and has no purge API to call. It stays in the list so
273 // `--type=cdn` answers the question rather than erroring on an
274 // unknown type, and so a provider that DOES have a purge API
275 // can register one through xspeed_purge_targets.
276 'enabled' => array( self::class, 'cdn_enabled' ),
277 'callback' => '__return_empty_array',
278 ),
279 );
280
281 /**
282 * Register a purgeable store with `wp xspeed purge`.
283 *
284 * Add-ons hook this to be included in a full purge and reachable via
285 * `--type=<slug>`. Registering a target does NOT replace hooking
286 * `xspeed_after_purge_all` — that action still fires — it is how a
287 * store gets its own line in the report, with its own count and its
288 * own success or failure.
289 *
290 * @param array<string,array{label:string,group:string,enabled:callable,callback:callable}> $targets Store definitions keyed by slug.
291 */
292 $targets = (array) apply_filters( 'xspeed_purge_targets', $targets );
293
294 return array_filter(
295 $targets,
296 static function ( $target ) {
297 return is_array( $target ) && isset( $target['label'], $target['callback'] ) && is_callable( $target['callback'] );
298 }
299 );
300 }
301
302 /**
303 * Local files: the flat tree, the static tree, REST responses, minified
304 * assets — and, through `xspeed_after_purge_all`, whatever modules clear
305 * alongside them.
306 *
307 * @param string $cause Who asked.
308 * @return array{entries:int,bytes:int}
309 */
310 public static function purge_page( string $cause ): array {
311 $removed = Cache::purge_local();
312 $entries = (int) $removed['pages'] + (int) $removed['rest'] + (int) $removed['assets'];
313
314 Cache::update_stats( array( 'last_purge' => time() ) );
315
316 // Isolated per listener, and the throw swallowed: this sweep is
317 // already done, and one listener (Critical CSS, Unused CSS, the
318 // Cloudflare edge purge) failing must not cancel the ones queued
319 // behind it or stop the announcement below. Same handling
320 // purge_all() gives this action. (QA #348)
321 Cache::do_action_isolated( 'xspeed_after_purge_all', $cause );
322
323 // The public purge contract, which purge_all() publishes but
324 // purge_local() does not — without it `wp xspeed purge`, the
325 // dashboard button and the MCP purge tool would clear our files and
326 // leave LiteSpeed serving the stale page, having reported success.
327 Cache::announce_purge( $cause, $entries );
328
329 Cache_Inventory::invalidate();
330
331 Activity_Log::record(
332 'cache_purged',
333 sprintf(
334 /* translators: 1: what asked for the purge, 2: number of files removed. */
335 __( 'Cache purged (%1$s) — %2$d file(s) removed', 'xspeed' ),
336 $cause,
337 $entries
338 ),
339 Activity_Log::INFO
340 );
341
342 return array(
343 'entries' => $entries,
344 'bytes' => (int) $removed['bytes'],
345 );
346 }
347
348 /**
349 * Whether flushing the object cache would do anything.
350 *
351 * A degraded drop-in — installed, connected to nothing — is a skip and
352 * not a failure: there is no persisted data to clear, and the drop-in's
353 * own health is what `wp xspeed objcache status` is for.
354 *
355 * @return true|string
356 */
357 public static function object_enabled() {
358 if ( ! class_exists( '\\XSpeed\\Object_Cache' ) ) {
359 return __( 'the object cache engine is unavailable', 'xspeed' );
360 }
361 $state = Object_Cache::detect();
362 if ( empty( $state['wp_cache_active'] ) ) {
363 return __( 'no persistent object cache drop-in is in use', 'xspeed' );
364 }
365 if ( ! empty( $state['degraded'] ) ) {
366 return __( 'the drop-in is installed but is not persisting anything', 'xspeed' );
367 }
368
369 return true;
370 }
371
372 /**
373 * Flush the object cache.
374 *
375 * @param string $cause Who asked.
376 * @return array{ok:bool,entries:null,reason:string}
377 */
378 public static function purge_object( string $cause ): array {
379 $ok = Cache::flush_object_cache();
380 if ( $ok ) {
381 // Entries stay null: neither Redis nor Memcached reports how many
382 // keys a FLUSHALL dropped, and inventing a number would be worse
383 // than the honest blank.
384 Activity_Log::record(
385 'cache_purged',
386 sprintf(
387 /* translators: %s: what asked for the purge. */
388 __( 'Purged object cache (%s)', 'xspeed' ),
389 $cause
390 ),
391 Activity_Log::INFO
392 );
393 }
394
395 return array(
396 'ok' => $ok,
397 'entries' => null,
398 'reason' => $ok ? '' : __( 'the backend refused the flush', 'xspeed' ),
399 );
400 }
401
402 /**
403 * Whether the Cloudflare integration is configured well enough to purge.
404 *
405 * @return true|string
406 */
407 public static function cloudflare_enabled() {
408 $module = Module_Registry::get( 'cloudflare' );
409 if ( ! $module || ! method_exists( $module, 'can_purge_edge' ) ) {
410 return __( 'the Cloudflare module is not available', 'xspeed' );
411 }
412
413 return $module->can_purge_edge();
414 }
415
416 /**
417 * Purge the Cloudflare edge.
418 *
419 * @param string $cause Who asked.
420 * @return array{ok:bool,reason:string}
421 */
422 public static function purge_cloudflare( string $cause ): array {
423 $module = Module_Registry::get( 'cloudflare' );
424
425 return $module->purge_edge( $cause );
426 }
427
428 /**
429 * Whether any CDN with a purge API is configured. See the `cdn` target.
430 *
431 * @return true|string
432 */
433 public static function cdn_enabled() {
434 return __( 'no CDN with a purge API is configured — xSpeed\'s CDN module rewrites asset URLs and holds no cache of its own', 'xspeed' );
435 }
436 }
437