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