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

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

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