| 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 |
|