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.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 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/modules/Cloudflare/CloudflareModule.php +0 -368 1.3.31.3.2 View file →
@@ -34,45 +34,8 @@
34 34 * warning when Cloudflare is silently failing. (#119)
35 35 */
36 36 private const HEALTH_OPTION = 'xspeed_cloudflare_health';
37 37
38 - /** Cron event that does the edge call for a batch of purged URLs. */
39 - private const PURGE_URLS_EVENT = 'xspeed_cloudflare_purge_urls';
40 -
41 - /** Cron event for the zone-wide fallback when a batch is too large. */
42 - private const PURGE_ALL_EVENT = 'xspeed_cloudflare_purge_edge_all';
43 -
44 - /**
45 - * Above this many URLs, purge the zone instead of naming every page.
46 - *
47 - * The batch travels as the cron event's ARGUMENT, and the cron table is
48 - * an autoloaded option, so an unbounded batch is an unbounded payload in
49 - * `alloptions` for as long as the event is pending. A bulk product
50 - * import, or an `xspeed_purge_product_urls` filter that expands to a few
51 - * hundred URLs, is enough. Past the threshold the zone purge is one call
52 - * with no payload, and it is what the site would have got from
53 - * `purge_all()` anyway.
54 - */
55 - private const MAX_DEFERRED_URLS = 100;
56 -
57 - /**
58 - * URLs purged this request, awaiting a batched call at shutdown.
59 - *
60 - * Keyed blog id => URL => true. By URL so the same page arriving twice —
61 - * a post and the archive that lists it can resolve to the same address —
62 - * is sent once. By BLOG because one module instance serves the whole
63 - * process: a `Cache::purge_url()` raised inside `switch_to_blog()` would
64 - * otherwise land in a batch sent against whatever blog happened to be
65 - * current at shutdown, merging several sites' URLs into one zone with one
66 - * site's token, and writing the health record and activity log to the
67 - * wrong site too. Nothing does that today — Pro's network purge goes
68 - * through `purge_all()` — but the re-entry guard in Cache anticipates a
69 - * network purge that loops blogs in one request.
70 - *
71 - * @var array<int,array<string,true>>
72 - */
73 - private array $pending_edge_urls = array();
74 -
75 38 public function ui_metadata(): array {
76 39 return array(
77 40 'label' => __( 'Cloudflare', 'xspeed' ),
78 41 'icon' => 'Cloud',
@@ -224,25 +187,8 @@
224 187 add_action( 'init', array( $this, 'boot_on_init' ) );
225 188 }
226 189
227 190 /**
228 - * Leave no queued edge calls behind.
229 - *
230 - * A batch scheduled seconds before the module was switched off would
231 - * otherwise fire against a zone the site no longer manages, and the
232 - * event would sit in the cron table with no listener after that.
233 - */
234 - public function deactivate(): void {
235 - // `wp_unschedule_hook()`, not `wp_clear_scheduled_hook()`. The latter
236 - // keys on `md5( serialize( $args ) )` and defaults `$args` to an
237 - // empty array, so it only ever clears the no-arguments key. Every
238 - // event this module schedules carries the URL batch as its argument,
239 - // so clear_scheduled_hook cleared nothing at all here.
240 - wp_unschedule_hook( self::PURGE_URLS_EVENT );
241 - wp_unschedule_hook( self::PURGE_ALL_EVENT );
242 - }
243 -
244 - /**
245 191 * The real boot body — see boot() for why it runs on `init`.
246 192 */
247 193 public function boot_on_init(): void {
248 194 $opts = $this->get_settings();
@@ -253,11 +199,8 @@
253 199 // xSpeed fires this action whenever it purges its own
254 200 // cache (see Cache::purge_all). Listening here keeps
255 201 // CF in sync without any new wiring elsewhere.
256 202 add_action( 'xspeed_after_purge_all', array( $this, 'on_xspeed_purge' ), 10, 0 );
257 - add_action( 'xspeed_after_purge_url', array( $this, 'on_xspeed_purge_url' ), 10, 1 );
258 - add_action( self::PURGE_URLS_EVENT, array( $this, 'purge_edge_urls' ), 10, 1 );
259 - add_action( self::PURGE_ALL_EVENT, array( $this, 'purge_edge_all' ), 10, 0 );
260 203 }
261 204 }
262 205
263 206 public function on_xspeed_purge(): void {
@@ -279,319 +222,8 @@
279 222 if ( true !== $this->can_purge_edge() ) {
280 223 return;
281 224 }
282 225 $this->purge_edge( 'auto-purge' );
283 - }
284 -
285 - /**
286 - * Mirror a single-URL purge at the edge.
287 - *
288 - * NOT about post edits — `on_save_post()` calls `purge_all()`, so those
289 - * have always reached Cloudflare through the full-purge listener above.
290 - * What reaches `purge_url()` is the narrower set: the two admin purge
291 - * buttons, an approved comment, a user change, a WooCommerce product or
292 - * stock change, `--url` on the CLI and REST, and MCP. Every one of those
293 - * cleared xSpeed's copy and left Cloudflare's, so the page stayed stale
294 - * at the edge until its lifetime ran out or somebody pressed Purge All —
295 - * which is a whole-zone purge to fix one page.
296 - *
297 - * Single-file purge is also the cheap call, which is the opposite of how
298 - * it looks. Cloudflare's tightest documented purge limit is the one on
299 - * purge-everything, hostname, tag and prefix; file purges are metered
300 - * separately and far more generously. The `purge_all` listener above is
301 - * the one near a limit, not this.
302 - *
303 - * @param array<string,mixed> $context The event payload. See the
304 - * `xspeed_after_purge_url` docblock.
305 - */
306 - public function on_xspeed_purge_url( $context ): void {
307 - if ( ! is_array( $context ) || 'urls' !== ( $context['scope'] ?? '' ) ) {
308 - return;
309 - }
310 - $urls = array_filter( array_map( 'strval', (array) ( $context['urls'] ?? array() ) ) );
311 - if ( array() === $urls ) {
312 - return;
313 - }
314 - // Same guard as the full-purge listener: `wp xspeed purge` reports
315 - // the edge as its own line item, and purging here as well would make
316 - // the outcome nobody reported the one that lands in the health record.
317 - if ( class_exists( '\\XSpeed\\Purge_Runner' ) && \XSpeed\Purge_Runner::covers( 'cloudflare' ) ) {
318 - return;
319 - }
320 - if ( true !== $this->can_purge_edge() ) {
321 - return;
322 - }
323 -
324 - // Collected and sent once, not one API call per URL. `Purge_Ui`'s
325 - // post purge and the WooCommerce product path both fire a handful of
326 - // these in a loop, and a round trip each would be a wait each.
327 - if ( array() === $this->pending_edge_urls ) {
328 - add_action( 'shutdown', array( $this, 'flush_edge_url_purges' ), 20 );
329 - }
330 - $blog = function_exists( 'get_current_blog_id' ) ? (int) get_current_blog_id() : 0;
331 - foreach ( $urls as $url ) {
332 - $this->pending_edge_urls[ $blog ][ $url ] = true;
333 - }
334 - }
335 -
336 - /**
337 - * Hand whatever `on_xspeed_purge_url()` collected to cron.
338 - *
339 - * Three of the callers are ordinary visitor traffic — an approved
340 - * comment, a user registration, a WooCommerce stock change during
341 - * checkout — and none of them made an outbound request before this
342 - * listener existed. Doing the HTTPS inline would put a blocking round
343 - * trip to Cloudflare on the end of a shopper's checkout, once per
344 - * request, with the timeout as the worst case. So the batch is scheduled
345 - * and the request ends.
346 - *
347 - * Inline when there is nothing to defer to: cron cannot defer to itself,
348 - * and a CLI run exits before a spawned cron request would be served.
349 - * Both are contexts where a blocking call is the right answer anyway.
350 - *
351 - * Deliberately not what WP Rocket does — its Cloudflare add-on calls
352 - * `purge_files()` straight from `after_rocket_clean_post`, so a visitor
353 - * leaving a comment waits on Cloudflare. LiteSpeed sidesteps it by never
354 - * purging Cloudflare per URL at all. Deferring is the same thing
355 - * `Preloader` and `Cookie_Inspector` already do here for the same
356 - * reason: outbound HTTP belongs in a later request, not on the one that
357 - * happened to trigger it.
358 - *
359 - * Three ways a batch can still be lost, all silent because the health
360 - * record is only written inside the flush: a PHP fatal (WordPress's own
361 - * fatal handler is registered before `shutdown_action_hook` and ends the
362 - * process first), another plugin calling `exit` from a `shutdown`
363 - * callback at a priority below 20, and a `purge_url()` raised during
364 - * `shutdown` ABOVE priority 20, which re-arms a hook that has already
365 - * dispatched. Rare, but this is the note that saves the next person
366 - * debugging "the edge kept a stale page" from rediscovering them.
367 - *
368 - * Public because it is a `shutdown` callback; not part of the module's
369 - * contract.
370 - */
371 - public function flush_edge_url_purges(): void {
372 - $batches = $this->pending_edge_urls;
373 - $this->pending_edge_urls = array();
374 - $current = function_exists( 'get_current_blog_id' ) ? (int) get_current_blog_id() : 0;
375 -
376 - foreach ( $batches as $blog => $keyed ) {
377 - $urls = array_keys( $keyed );
378 - if ( array() === $urls ) {
379 - continue;
380 - }
381 - // Each batch is scheduled and sent as the site that raised it,
382 - // because the cron table, the settings, the health record and the
383 - // activity log are all per-site.
384 - $switched = (int) $blog !== $current && function_exists( 'switch_to_blog' );
385 - if ( $switched ) {
386 - switch_to_blog( (int) $blog );
387 - }
388 - try {
389 - $this->dispatch_edge_url_batch( $urls );
390 - } finally {
391 - // A throwing adapter must not leave the rest of shutdown
392 - // running as the wrong site.
393 - if ( $switched ) {
394 - restore_current_blog();
395 - }
396 - }
397 - }
398 - }
399 -
400 - /** Schedule one site's batch, or send it now where there is nothing to defer to. */
401 - private function dispatch_edge_url_batch( array $urls ): void {
402 - // Too many to name. Purge the zone instead of carrying every URL in
403 - // an autoloaded option, and say so, because a zone purge costs more
404 - // origin traffic than the page purges it replaces and nobody should
405 - // have to infer that it happened.
406 - if ( count( $urls ) > self::max_deferred_urls() ) {
407 - $this->dispatch_edge_purge_all( count( $urls ) );
408 - return;
409 - }
410 -
411 - if ( ! self::must_purge_inline() && function_exists( 'wp_schedule_single_event' ) ) {
412 - // `$wp_error = true`, because the bare form returns false for two
413 - // opposite situations and only one of them is a failure.
414 - //
415 - // Scheduling at `time()` puts the timestamp in the past by the
416 - // time core compares it, which sets core's `$min_timestamp` to 0
417 - // (wp-includes/cron.php) — so ANY identical event anywhere in the
418 - // cron table, however old, counts as a duplicate and the call
419 - // returns false. Two comments on the same post produce
420 - // byte-identical args, so the second one would have taken the
421 - // inline fallback: a blocking call to Cloudflare on a visitor's
422 - // request, which is the exact thing this deferral exists to
423 - // avoid, while the already-queued event fired anyway and sent
424 - // the batch twice.
425 - //
426 - // A duplicate means the work is already queued. That is success.
427 - $scheduled = wp_schedule_single_event( time(), self::PURGE_URLS_EVENT, array( $urls ), true );
428 - if ( true === $scheduled ) {
429 - return;
430 - }
431 - if ( is_wp_error( $scheduled ) && 'duplicate_event' === $scheduled->get_error_code() ) {
432 - return;
433 - }
434 - // Anything else — a filter vetoing the event, a broken cron
435 - // table — is a real refusal, and dropping the purge silently
436 - // would leave the edge stale with nothing to say so.
437 - }
438 -
439 - $this->purge_edge_urls( $urls );
440 - }
441 -
442 - /** Is this a context with no later request to defer the edge call to? */
443 - private static function must_purge_inline(): bool {
444 - if ( defined( 'WP_CLI' ) && WP_CLI ) {
445 - return true;
446 - }
447 - return function_exists( 'wp_doing_cron' ) && wp_doing_cron();
448 - }
449 -
450 - /**
451 - * How many URLs may ride along in a deferred batch.
452 - *
453 - * Filterable because the right answer depends on how long a site's cron
454 - * backlog sits: the cost is the payload's time in `alloptions`, not the
455 - * URL count itself.
456 - */
457 - private static function max_deferred_urls(): int {
458 - if ( ! function_exists( 'apply_filters' ) ) {
459 - return self::MAX_DEFERRED_URLS;
460 - }
461 -
462 - /**
463 - * Filter the batch size above which a zone purge replaces named URLs.
464 - *
465 - * @param int $max URLs per deferred batch.
466 - */
467 - $max = (int) apply_filters( 'xspeed_cloudflare_max_deferred_purge_urls', self::MAX_DEFERRED_URLS );
468 -
469 - // A filter of zero would send every single-page purge to the zone.
470 - return $max > 0 ? $max : self::MAX_DEFERRED_URLS;
471 - }
472 -
473 - /** Queue the zone-wide fallback, or run it now where cron cannot. */
474 - private function dispatch_edge_purge_all( int $url_count ): void {
475 - if ( class_exists( '\\XSpeed\\Activity_Log' ) ) {
476 - \XSpeed\Activity_Log::record(
477 - 'cache_purged',
478 - sprintf(
479 - /* translators: %d: number of URLs that changed at once. */
480 - __( 'Purging the whole Cloudflare zone: %d URLs changed at once, too many to purge individually', 'xspeed' ),
481 - $url_count
482 - )
483 - );
484 - }
485 -
486 - if ( ! self::must_purge_inline() && function_exists( 'wp_schedule_single_event' ) ) {
487 - // No arguments, so every oversized batch in a request collapses
488 - // onto one event. `duplicate_event` is the wanted outcome here,
489 - // not a failure.
490 - $scheduled = wp_schedule_single_event( time(), self::PURGE_ALL_EVENT, array(), true );
491 - if ( true === $scheduled ) {
492 - return;
493 - }
494 - if ( is_wp_error( $scheduled ) && 'duplicate_event' === $scheduled->get_error_code() ) {
495 - return;
496 - }
497 - }
498 -
499 - $this->purge_edge_all();
500 - }
501 -
502 - /**
503 - * Purge the whole zone, as the fallback for an oversized batch.
504 - *
505 - * Public because it is the `PURGE_ALL_EVENT` cron callback. Re-checks the
506 - * connection for the same reason the URL batch does: this runs in a later
507 - * request than the one that queued it.
508 - */
509 - public function purge_edge_all(): void {
510 - if ( true !== $this->can_purge_edge() ) {
511 - return;
512 - }
513 - $this->purge_edge( 'auto-purge' );
514 - }
515 -
516 - /**
517 - * Purge a batch of URLs at the edge and record the outcome.
518 - *
519 - * Public because it is the `PURGE_URLS_EVENT` cron callback.
520 - *
521 - * @param string[] $urls
522 - */
523 - public function purge_edge_urls( $urls ): void {
524 - $urls = array_values( array_filter( array_map( 'strval', (array) $urls ) ) );
525 - if ( array() === $urls ) {
526 - return;
527 - }
528 - // Re-checked here rather than trusted from collect time: a scheduled
529 - // batch runs in a later request, and the credentials or the switch
530 - // may have changed between the two.
531 - if ( true !== $this->can_purge_edge() ) {
532 - // Said out loud, because otherwise "the credentials were removed
533 - // between queueing and running" and "the purge succeeded" look
534 - // identical from the panel, and the pages stay stale at the edge
535 - // either way.
536 - if ( class_exists( '\\XSpeed\\Activity_Log' ) ) {
537 - \XSpeed\Activity_Log::record(
538 - 'cache_purge_skipped',
539 - sprintf(
540 - /* translators: %d: number of URLs. */
541 - _n(
542 - 'Skipped a queued Cloudflare purge of %d URL: the connection is no longer available',
543 - 'Skipped a queued Cloudflare purge of %d URLs: the connection is no longer available',
544 - count( $urls ),
545 - 'xspeed'
546 - ),
547 - count( $urls )
548 - ),
549 - \XSpeed\Activity_Log::WARN
550 - );
551 - }
552 - return;
553 - }
554 -
555 - $result = Cloudflare::purge_urls( $this->get_settings(), $urls );
556 - $ok = ! empty( $result['ok'] );
557 - $reason = $ok ? '' : $this->message_of( $result );
558 -
559 - // Recorded for the same reason the full purge is: a token that passes
560 - // verify can still lack "Zone → Cache Purge", and a silent auth
561 - // failure here means stale pages at the edge with nothing to say so.
562 - $this->record_health( $ok, 'purge', $reason );
563 -
564 - if ( ! class_exists( '\\XSpeed\\Activity_Log' ) ) {
565 - return;
566 - }
567 - if ( $ok ) {
568 - \XSpeed\Activity_Log::record(
569 - 'cache_purged',
570 - sprintf(
571 - /* translators: %d: number of URLs purged. */
572 - _n(
573 - 'Purged %d URL from the Cloudflare edge cache',
574 - 'Purged %d URLs from the Cloudflare edge cache',
575 - count( $urls ),
576 - 'xspeed'
577 - ),
578 - count( $urls )
579 - ),
580 - \XSpeed\Activity_Log::INFO
581 - );
582 - return;
583 - }
584 - \XSpeed\Activity_Log::record(
585 - 'cloudflare_purge_failed',
586 - sprintf(
587 - /* translators: 1: number of URLs, 2: failure reason. */
588 - __( 'Cloudflare URL purge failed (%1$d URL(s)): %2$s', 'xspeed' ),
589 - count( $urls ),
590 - $reason ? $reason : __( 'unknown error', 'xspeed' )
591 - ),
592 - \XSpeed\Activity_Log::WARN
593 - );
594 226 }
595 227
596 228 /**
597 229 * Whether this site can purge its Cloudflare zone right now.