PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.13.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.13.0
2.13.0 2.13.1 2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 All 80 releases
ablocks / includes / classes / page-cache / scheduler.php

scheduler.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.13.0, at includes/classes/page-cache/scheduler.php

536 lines 15.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace ABlocks\Classes\PageCache;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 use ABlocks\Helper;
9 use ABlocks\Performance\StyleConsolidator;
10
11 /**
12 * Page Cache — background maintenance.
13 *
14 * Two jobs need to happen without anyone watching:
15 *
16 * 1. Expiring cached pages once the configured TTL passes. Without this the
17 * "Expire after (hours)" setting is decorative — nothing prunes, and entries
18 * live until something invalidates them.
19 * 2. Reclaiming consolidated stylesheets. They are content-addressed and
20 * referenced by cached HTML, so they cannot be purged alongside pages; they
21 * have to age out instead, or the directory grows for the life of the site.
22 *
23 * ## On Action Scheduler
24 *
25 * Recurring maintenance uses WP-Cron. It is built in, needs no dependency, and
26 * the work is two small sweeps — Action Scheduler would buy nothing here, and
27 * requiring a second plugin to make a setting work is a bad trade for a free
28 * plugin that must run standalone.
29 *
30 * Bulk warming is different: it is a queue of potentially thousands of URLs that
31 * wants throttling, retries and progress. Action Scheduler is genuinely better
32 * at that, so it is used *when present* — it commonly already is, bundled inside
33 * WooCommerce, StoreEngine and others — and WP-Cron is used otherwise. Detection
34 * is by function, never by plugin: a bundled copy is as good as an active
35 * plugin, and Action Scheduler is designed to be loaded that way.
36 */
37 class Scheduler {
38
39 const PRUNE_HOOK = 'ablocks/page_cache/prune';
40 const WARM_HOOK = 'ablocks/page_cache/warm_url';
41 const CRAWL_HOOK = 'ablocks/page_cache/crawl';
42 const SKIP_OPTION = 'ablocks_crawl_skip';
43
44 /**
45 * Days after which an unused consolidated stylesheet is reclaimed.
46 */
47 const CSS_MAX_AGE_DAYS = 30;
48
49 /**
50 * Seconds between warm requests, so warming never stampedes the site it is
51 * trying to make fast.
52 */
53 const WARM_SPACING = 5;
54
55 /**
56 * URLs the background crawler queues per run.
57 */
58 const CRAWL_BATCH = 20;
59
60 public static function init() {
61 add_action( self::PRUNE_HOOK, [ __CLASS__, 'run_prune' ] );
62 add_action( self::WARM_HOOK, [ __CLASS__, 'warm_url' ], 10, 1 );
63 add_action( self::CRAWL_HOOK, [ __CLASS__, 'run_crawl' ] );
64 add_action( 'init', [ __CLASS__, 'ensure_events' ] );
65 }
66
67 /**
68 * Keep the recurring jobs registered, and drop them when unused.
69 */
70 public static function ensure_events() {
71 $wanted = Rules::is_enabled() || (bool) Helper::get_settings( 'perf_consolidate_css', false );
72 $next = wp_next_scheduled( self::PRUNE_HOOK );
73
74 if ( $wanted && ! $next ) {
75 wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::PRUNE_HOOK );
76 } elseif ( ! $wanted && $next ) {
77 // Leave no orphan cron entry behind when both features are off.
78 wp_unschedule_event( $next, self::PRUNE_HOOK );
79 }
80
81 $crawl_wanted = Rules::is_enabled() && (bool) Helper::get_settings( 'perf_page_cache_crawler', false );
82 $crawl_next = wp_next_scheduled( self::CRAWL_HOOK );
83
84 if ( $crawl_wanted && ! $crawl_next ) {
85 wp_schedule_event( time() + ( 5 * MINUTE_IN_SECONDS ), 'hourly', self::CRAWL_HOOK );
86 } elseif ( ! $crawl_wanted && $crawl_next ) {
87 wp_unschedule_event( $crawl_next, self::CRAWL_HOOK );
88 }
89 }
90
91 /**
92 * Top up the cache with pages that are not in it yet.
93 *
94 * Deliberately incremental rather than a full crawl. It queues a small batch
95 * of *uncached* URLs each hour, so a large site fills in gradually instead of
96 * the site being hit with thousands of renders at once — and once the cache
97 * is warm the job finds nothing to do and costs almost nothing.
98 *
99 * This is also why nothing auto-crawls after a site-wide purge. Rebuilding
100 * every page the instant a header is edited is a self-inflicted load spike at
101 * exactly the wrong moment; letting the crawler refill it over the following
102 * hours, while visitors organically warm the popular pages, is gentler and
103 * reaches the same place.
104 *
105 * @return int URLs queued.
106 */
107 public static function run_crawl() {
108 if ( ! Rules::is_enabled() ) {
109 return 0;
110 }
111
112 $batch = (int) apply_filters(
113 'ablocks/perf/page_cache/crawl_batch',
114 (int) Helper::get_settings( 'perf_page_cache_crawl_batch', self::CRAWL_BATCH )
115 );
116 $batch = max( 1, min( 200, $batch ) );
117
118 // Pull a wider slice than the batch so that, once the head of the list is
119 // cached, the crawler can still find uncached pages further down instead
120 // of re-checking the same first N every hour.
121 $candidates = self::warmable_urls( $batch * 10 );
122 $skip = self::skip_list();
123 $now = time();
124 $missing = [];
125
126 foreach ( $candidates as $url ) {
127 if ( self::is_cached( $url ) ) {
128 // Cached now, so any earlier failures are irrelevant.
129 unset( $skip[ $url ] );
130 continue;
131 }
132
133 // Some URLs can never be cached — a login or account page sets a
134 // cookie, so the write path correctly refuses it. Without a memory of
135 // that, the crawler re-requests those same pages every single hour
136 // forever, which is pure waste on a job designed to run unattended.
137 if ( isset( $skip[ $url ]['until'] ) && $skip[ $url ]['until'] > $now ) {
138 continue;
139 }
140
141 $missing[] = $url;
142
143 if ( count( $missing ) >= $batch ) {
144 break;
145 }
146 }//end foreach
147
148 if ( empty( $missing ) ) {
149 self::save_skip_list( $skip );
150 return 0;
151 }
152
153 foreach ( $missing as $url ) {
154 $attempts = isset( $skip[ $url ]['n'] ) ? (int) $skip[ $url ]['n'] + 1 : 1;
155
156 $skip[ $url ] = [
157 'n' => $attempts,
158 // Three attempts is enough to distinguish "not visited yet" from
159 // "will never cache"; after that, back off for a day so a page
160 // that becomes cacheable is still picked up eventually.
161 'until' => $attempts >= 3 ? $now + DAY_IN_SECONDS : 0,
162 ];
163 }
164
165 self::save_skip_list( $skip );
166
167 return self::queue_warm( $missing, 0 );
168 }
169
170 /**
171 * Per-URL crawl attempt record.
172 *
173 * @return array<string, array{n:int, until:int}>
174 */
175 private static function skip_list() {
176 $list = get_option( self::SKIP_OPTION, [] );
177 return is_array( $list ) ? $list : [];
178 }
179
180 /**
181 * Persist the crawl attempt record, bounded.
182 *
183 * @param array $list Attempt record.
184 */
185 private static function save_skip_list( $list ) {
186 // Bounded so a site with a very large sitemap cannot grow this option
187 // without limit. Entries still in backoff are kept in preference to ones
188 // that have simply not been reached yet.
189 if ( count( $list ) > 500 ) {
190 uasort(
191 $list,
192 function ( $a, $b ) {
193 return ( isset( $b['until'] ) ? $b['until'] : 0 ) <=> ( isset( $a['until'] ) ? $a['until'] : 0 );
194 }
195 );
196 $list = array_slice( $list, 0, 500, true );
197 }
198
199 update_option( self::SKIP_OPTION, $list, false );
200 }
201
202 /**
203 * Does a cache entry already exist for a URL?
204 *
205 * @param string $url URL to check.
206 * @return bool
207 */
208 public static function is_cached( $url ) {
209 $parts = wp_parse_url( $url );
210 if ( empty( $parts['host'] ) ) {
211 return false;
212 }
213
214 $file = Store::file_path(
215 $parts['host'],
216 isset( $parts['path'] ) ? $parts['path'] : '/',
217 ''
218 );
219
220 return $file && file_exists( $file );
221 }
222
223 /**
224 * Remove every scheduled job. Called on deactivation.
225 */
226 public static function clear_events() {
227 wp_clear_scheduled_hook( self::PRUNE_HOOK );
228
229 if ( self::has_action_scheduler() ) {
230 as_unschedule_all_actions( self::WARM_HOOK );
231 }
232 }
233
234 /**
235 * Expire stale cached pages and reclaim unused stylesheets.
236 *
237 * @return array{pages:int, css:int}
238 */
239 public static function run_prune() {
240 $pages = Store::prune();
241
242 $css_days = (int) apply_filters( 'ablocks/perf/page_cache/css_max_age_days', self::CSS_MAX_AGE_DAYS );
243 $css = StyleConsolidator::prune( $css_days );
244
245 return [
246 'pages' => (int) $pages,
247 'css' => (int) $css,
248 ];
249 }
250
251 /**
252 * Queue a set of URLs to be rendered into the cache.
253 *
254 * Requests are spaced rather than fired at once. Warming exists to spare
255 * visitors a render; doing it in one burst would hand the site the exact
256 * load spike the cache is meant to prevent.
257 *
258 * @param string[] $urls URLs to warm.
259 * @param int $delay Seconds before the first request.
260 * @return int Number of jobs queued.
261 */
262 public static function queue_warm( array $urls, $delay = 10 ) {
263 $urls = array_values( array_unique( array_filter( array_map( 'esc_url_raw', $urls ) ) ) );
264 if ( empty( $urls ) ) {
265 return 0;
266 }
267
268 $spacing = max( 1, (int) apply_filters( 'ablocks/perf/page_cache/warm_spacing', self::WARM_SPACING ) );
269 $queued = 0;
270
271 foreach ( $urls as $index => $url ) {
272 $when = time() + (int) $delay + ( $index * $spacing );
273
274 if ( self::has_action_scheduler() ) {
275 as_schedule_single_action( $when, self::WARM_HOOK, [ $url ], 'ablocks' );
276 } else {
277 // WP-Cron dedupes on hook + args, so a URL already queued is not
278 // queued twice — which is the behaviour we want anyway.
279 if ( ! wp_next_scheduled( self::WARM_HOOK, [ $url ] ) ) {
280 wp_schedule_single_event( $when, self::WARM_HOOK, [ $url ] );
281 }
282 }
283
284 $queued++;
285 }
286
287 return $queued;
288 }
289
290 /**
291 * Render one URL so its cache entry exists before a visitor asks for it.
292 *
293 * A plain anonymous GET: the write path decides on its own whether the
294 * response is cacheable, so warming needs no special privileges and cannot
295 * cache something a visitor would not have.
296 *
297 * @param string $url URL to fetch.
298 */
299 public static function warm_url( $url ) {
300 $url = esc_url_raw( (string) $url );
301 if ( empty( $url ) ) {
302 return;
303 }
304
305 // Same-origin only. This runs from a queue whose contents could be
306 // filtered by other code, and it must never become a way to make the
307 // server issue arbitrary outbound requests.
308 $home = wp_parse_url( home_url() );
309 $want = wp_parse_url( $url );
310 if ( empty( $want['host'] ) || empty( $home['host'] ) || strtolower( $want['host'] ) !== strtolower( $home['host'] ) ) {
311 return;
312 }
313
314 wp_remote_get(
315 $url,
316 [
317 'timeout' => 15,
318 'blocking' => false,
319 'sslverify' => false,
320 'user-agent' => 'aBlocks-Cache-Warmer/' . ABLOCKS_VERSION,
321 'headers' => [ 'X-ABlocks-Warm' => '1' ],
322 ]
323 );
324 }
325
326 /**
327 * URLs worth warming.
328 *
329 * The site's own sitemap is preferred: it is the site's statement of what
330 * matters, and it includes archives, taxonomy and author pages that a post
331 * query alone would miss — which is what the other preloaders in this space
332 * read too. The post query remains as a fallback for sites that have the
333 * sitemap disabled or replaced.
334 *
335 * @param int $limit Maximum URLs to return.
336 * @return string[]
337 */
338 public static function warmable_urls( $limit = 100 ) {
339 $limit = max( 1, (int) $limit );
340
341 $urls = self::sitemap_urls( $limit );
342 if ( count( $urls ) > 1 ) {
343 return array_slice( $urls, 0, $limit );
344 }
345
346 return self::queried_urls( $limit );
347 }
348
349 /**
350 * URLs read from the WordPress sitemap index.
351 *
352 * Fetched over HTTP rather than by calling the sitemap provider directly,
353 * because SEO plugins commonly replace core's sitemap with their own at the
354 * same address; going through the URL gets whichever one the site actually
355 * publishes.
356 *
357 * @param int $limit Stop after this many URLs.
358 * @return string[]
359 */
360 public static function sitemap_urls( $limit = 100 ) {
361 // Several candidates, because the address depends on what publishes the
362 // sitemap: core uses wp-sitemap.xml, Yoast and RankMath use
363 // sitemap_index.xml, others use sitemap.xml. Probing costs one request
364 // each and only until the first that answers, which beats making the
365 // user configure it. A site with none of them falls back to the query.
366 $candidates = (array) apply_filters(
367 'ablocks/perf/page_cache/sitemap_urls',
368 [
369 home_url( '/wp-sitemap.xml' ),
370 home_url( '/sitemap_index.xml' ),
371 home_url( '/sitemap.xml' ),
372 ]
373 );
374
375 $children = [];
376 foreach ( $candidates as $candidate ) {
377 $children = self::read_sitemap( $candidate );
378 if ( ! empty( $children ) ) {
379 break;
380 }
381 }
382
383 if ( empty( $children ) ) {
384 return [];
385 }
386
387 // An index lists sub-sitemaps; a flat sitemap lists pages. Both arrive as
388 // <loc> values, so tell them apart by whether they look like sitemaps.
389 $sub = array_values(
390 array_filter(
391 $children,
392 function ( $url ) {
393 return (bool) preg_match( '#\.xml(\?|$)#i', $url );
394 }
395 )
396 );
397
398 if ( empty( $sub ) ) {
399 return array_slice( $children, 0, $limit );
400 }
401
402 $urls = [ home_url( '/' ) ];
403 foreach ( $sub as $child ) {
404 $urls = array_merge( $urls, self::read_sitemap( $child ) );
405 if ( count( $urls ) >= $limit ) {
406 break;
407 }
408 }
409
410 return array_values( array_unique( $urls ) );
411 }
412
413 /**
414 * Extract <loc> values from one sitemap document.
415 *
416 * @param string $url Sitemap URL.
417 * @return string[]
418 */
419 private static function read_sitemap( $url ) {
420 if ( ! self::same_origin( $url ) ) {
421 return [];
422 }
423
424 $response = wp_remote_get(
425 $url,
426 [
427 'timeout' => 20,
428 'sslverify' => false,
429 'user-agent' => 'aBlocks-Cache-Warmer/' . ABLOCKS_VERSION,
430 ]
431 );
432
433 if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
434 return [];
435 }
436
437 $body = wp_remote_retrieve_body( $response );
438 if ( empty( $body ) || ! preg_match_all( '#<loc>\s*([^<]+?)\s*</loc>#i', $body, $matches ) ) {
439 return [];
440 }
441
442 $urls = [];
443 foreach ( $matches[1] as $loc ) {
444 $loc = esc_url_raw( html_entity_decode( trim( $loc ), ENT_QUOTES, 'UTF-8' ) );
445 if ( $loc && self::same_origin( $loc ) ) {
446 $urls[] = $loc;
447 }
448 }
449
450 return array_values( array_unique( $urls ) );
451 }
452
453 /**
454 * Is a URL on this site?
455 *
456 * @param string $url Candidate URL.
457 * @return bool
458 */
459 private static function same_origin( $url ) {
460 $home = wp_parse_url( home_url() );
461 $want = wp_parse_url( $url );
462
463 return ! empty( $want['host'] )
464 && ! empty( $home['host'] )
465 && strtolower( $want['host'] ) === strtolower( $home['host'] );
466 }
467
468 /**
469 * Fallback URL list: recently modified public posts, plus the front page.
470 *
471 * @param int $limit Maximum URLs to return.
472 * @return string[]
473 */
474 private static function queried_urls( $limit = 100 ) {
475 $limit = max( 1, (int) $limit );
476
477 $post_types = array_values(
478 array_filter(
479 get_post_types( [ 'public' => true ], 'names' ),
480 function ( $type ) {
481 return 'attachment' !== $type;
482 }
483 )
484 );
485
486 $query = new \WP_Query(
487 [
488 'post_type' => $post_types,
489 'post_status' => 'publish',
490 'posts_per_page' => $limit,
491 'orderby' => 'modified',
492 'order' => 'DESC',
493 'has_password' => false,
494 'ignore_sticky_posts' => true,
495 'no_found_rows' => true,
496 'update_post_meta_cache' => false,
497 'update_post_term_cache' => false,
498 'fields' => 'ids',
499 ]
500 );
501
502 $urls = [ home_url( '/' ) ];
503 foreach ( $query->posts as $post_id ) {
504 $permalink = get_permalink( $post_id );
505 if ( $permalink ) {
506 $urls[] = $permalink;
507 }
508 }
509
510 return array_values( array_unique( $urls ) );
511 }
512
513 /**
514 * Is Action Scheduler available to this request?
515 *
516 * Checked by function, not by plugin: Action Scheduler is normally bundled
517 * inside another plugin's vendor directory rather than activated on its own,
518 * and a bundled copy works exactly as well.
519 *
520 * @return bool
521 */
522 public static function has_action_scheduler() {
523 return function_exists( 'as_schedule_single_action' )
524 && function_exists( 'as_unschedule_all_actions' );
525 }
526
527 /**
528 * Which backend bulk warming will use, for display.
529 *
530 * @return string
531 */
532 public static function warm_backend() {
533 return self::has_action_scheduler() ? 'action-scheduler' : 'wp-cron';
534 }
535 }
536