PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.6
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 1.2.0 All 28 releases
xspeed / includes / class-preloader.php

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

574 lines 18.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Preloader — sitemap-driven cache warmer.
4 *
5 * On `start()`:
6 * 1. Fetches the configured sitemap (or auto-detects /wp-sitemap.xml).
7 * 2. Recursively follows nested sitemap indexes.
8 * 3. Filters URLs against the Cache module's excluded_urls list.
9 * 4. Queues the result in a transient.
10 * 5. Schedules the next WP-Cron tick.
11 *
12 * Each `tick()` processes up to `batch_size` URLs from the queue via
13 * `wp_remote_get()` (short timeout, sslverify off for local dev tolerance,
14 * a UA that flags itself so site owners can spot crawler traffic in
15 * access logs). Cache::should_cache() picks up the GET → writes the cache
16 * file on miss. The next visitor sees a HIT.
17 *
18 * State (transient `xspeed_preloader_state`):
19 * { running, started_at, finished_at, queue, processed, total,
20 * last_url, errors[] }
21 *
22 * Configuration comes from PreloaderModule's per-module option
23 * (xspeed_module_preloader) via Settings_Manager.
24 *
25 * @package XSpeed
26 */
27
28 declare(strict_types=1);
29
30 namespace XSpeed;
31
32 defined( 'ABSPATH' ) || exit;
33
34 final class Preloader {
35
36 public const STATE_KEY = 'xspeed_preloader_state';
37 public const STATE_TTL = 86400; // 24h — long enough for slow crawls.
38 public const CRON_HOOK = 'xspeed_preloader_tick';
39 public const USER_AGENT = 'xSpeed-Preloader/1.0 (+cache warmer; admin-initiated)';
40 public const REQUEST_TIMEOUT = 8;
41
42 /**
43 * Why the top-level sitemap fetch failed on this request, or '' when it
44 * succeeded. Set by fetch_sitemap_urls(), read by resolve_queue() — the
45 * reason has to survive the return of an empty array, which is exactly
46 * what it could not do before. Request-scoped; never persisted. (#142)
47 *
48 * @var string
49 */
50 private static $last_sitemap_error = '';
51
52 /**
53 * The sitemap URL the last error refers to. Kept beside the message so
54 * an error entry can carry a real `url` field like every other one,
55 * rather than repeating the URL already inside the message text.
56 */
57 private static $last_sitemap_url = '';
58
59 /**
60 * How the queue for the current crawl was built — 'sitemap', 'fallback'
61 * (enumerated from the database because the sitemap was unreachable), or
62 * 'none'. Surfaced in the state so the panel, REST and CLI can each say
63 * what actually happened instead of reporting a bare zero. (#142)
64 *
65 * @var string
66 */
67 private static $queue_source = 'none';
68
69 /** Largest number of URLs the database fallback will enumerate. */
70 private const FALLBACK_LIMIT = 500;
71
72 /**
73 * Kick off a fresh crawl. Returns the initial state.
74 */
75 public static function start(): array {
76 $opts = Settings_Manager::get( 'preloader' );
77 $urls = self::resolve_queue( $opts );
78
79 // A crawl that queued nothing because the sitemap was unreachable is a
80 // FAILURE, and every layer above needs to be able to say so. It used
81 // to be indistinguishable from success: errors stayed empty, the REST
82 // route returned 200, and the CLI printed a green Success. (#142)
83 $sitemap_error = self::$last_sitemap_error;
84 $errors = array();
85 if ( '' !== $sitemap_error && empty( $urls ) ) {
86 // Same {url, error, ts} shape every other entry uses. A bare
87 // string here fataled `wp xspeed preloader status`, which
88 // destructures `$e['url']` over the list — and took the MCP
89 // `get_preloader_status` tool down with it, so an agent asking
90 // why the preload failed got "Cannot access offset of type
91 // string on string" instead of the reason this code records.
92 // `url` is the sitemap because that is what failed. (QA F1)
93 $errors[] = array(
94 'url' => self::$last_sitemap_url,
95 'error' => $sitemap_error,
96 'ts' => time(),
97 );
98 }
99
100 $state = array(
101 'running' => ! empty( $urls ),
102 'started_at' => time(),
103 'finished_at' => empty( $urls ) ? time() : 0,
104 'queue' => array_values( $urls ),
105 'processed' => 0,
106 'total' => count( $urls ),
107 'last_url' => '',
108 'errors' => $errors,
109 // Consumers render on these: the panel needs to distinguish
110 // "not started" from "ran and found nothing", and to tell the
111 // user when the queue came from the fallback rather than the
112 // sitemap they configured.
113 'source' => self::$queue_source,
114 'sitemap_error' => $sitemap_error,
115 );
116 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
117
118 if ( '' !== $sitemap_error && 'fallback' === self::$queue_source ) {
119 $message = sprintf(
120 /* translators: 1: number of URLs, 2: the sitemap failure detail. */
121 __( 'Preloader queued %1$d URLs from the site content — %2$s', 'xspeed' ),
122 $state['total'],
123 $sitemap_error
124 );
125 $severity = Activity_Log::WARN;
126 } elseif ( '' !== $sitemap_error ) {
127 $message = sprintf(
128 /* translators: %s: the sitemap failure detail. */
129 __( 'Preloader could not start — %s', 'xspeed' ),
130 $sitemap_error
131 );
132 $severity = Activity_Log::WARN;
133 } else {
134 $message = sprintf(
135 /* translators: 1: number of URLs, 2: plural suffix. */
136 __( 'Preloader queued %1$d URL%2$s for warming.', 'xspeed' ),
137 $state['total'],
138 1 === $state['total'] ? '' : 's'
139 );
140 $severity = $state['total'] > 0 ? Activity_Log::INFO : Activity_Log::WARN;
141 }
142 Activity_Log::record( 'preloader_started', $message, $severity );
143
144 // Schedule the first tick ~5 seconds out so the kick-off REST call
145 // returns instantly; wp_schedule_single_event covers the
146 // "process the queue ASAP" path without a heavy synchronous loop.
147 if ( $state['running'] ) {
148 wp_schedule_single_event( time() + 5, self::CRON_HOOK );
149 }
150
151 return $state;
152 }
153
154 /**
155 * Cancel an in-flight crawl. Idempotent.
156 */
157 public static function stop(): array {
158 $state = self::status();
159 if ( $state['running'] ) {
160 Activity_Log::record(
161 'preloader_stopped',
162 sprintf( 'Preloader stopped (%d/%d URLs warmed).', $state['processed'], $state['total'] ),
163 Activity_Log::INFO
164 );
165 }
166
167 // Clear scheduled ticks.
168 wp_clear_scheduled_hook( self::CRON_HOOK );
169
170 $state['running'] = false;
171 $state['finished_at'] = time();
172 $state['queue'] = array();
173 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
174 return $state;
175 }
176
177 public static function status(): array {
178 $raw = get_transient( self::STATE_KEY );
179 if ( ! is_array( $raw ) ) {
180 return self::empty_state();
181 }
182 return wp_parse_args( $raw, self::empty_state() );
183 }
184
185 private static function empty_state(): array {
186 return array(
187 'running' => false,
188 'started_at' => 0,
189 'finished_at' => 0,
190 'queue' => array(),
191 'processed' => 0,
192 'total' => 0,
193 'last_url' => '',
194 'errors' => array(),
195 );
196 }
197
198 /**
199 * Tick handler — pulls up to batch_size URLs off the queue, warms
200 * each, persists state, and reschedules itself until the queue is
201 * empty. Called via the xspeed_preloader_tick action.
202 */
203 public static function tick(): void {
204 $state = self::status();
205 if ( ! $state['running'] || empty( $state['queue'] ) ) {
206 if ( $state['running'] ) {
207 self::mark_complete( $state );
208 }
209 return;
210 }
211
212 $opts = Settings_Manager::get( 'preloader' );
213 $batch = max( 1, min( 50, (int) ( $opts['batch_size'] ?? 5 ) ) );
214
215 $processed_this_tick = 0;
216 while ( $processed_this_tick < $batch && ! empty( $state['queue'] ) ) {
217 $url = array_shift( $state['queue'] );
218 self::warm_url( $url, $state );
219 $state['processed']++;
220 $state['last_url'] = $url;
221 $processed_this_tick++;
222 }
223
224 if ( empty( $state['queue'] ) ) {
225 self::mark_complete( $state );
226 return;
227 }
228
229 // More to do — persist + reschedule. Slight delay to avoid
230 // hammering the origin with parallel batches.
231 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
232 wp_schedule_single_event( time() + 10, self::CRON_HOOK );
233 }
234
235 /**
236 * Fire a single warm request for one URL with no queue / no cron
237 * (the "content warmer" path: new post published → warm its URL
238 * immediately). Records an activity event so the user can see in
239 * the Health log that warming happened.
240 *
241 * Best-effort and non-blocking-feeling — uses a short timeout so a
242 * dead origin can't hang the calling request. Returns true if the
243 * fetch completed with a non-error status, false otherwise.
244 */
245 public static function warm_one( string $url, string $cause = 'manual' ): bool {
246 if ( '' === $url ) {
247 return false;
248 }
249 $response = wp_remote_get(
250 $url,
251 array(
252 'timeout' => self::REQUEST_TIMEOUT,
253 'sslverify' => false,
254 'user-agent' => self::USER_AGENT,
255 'blocking' => true,
256 )
257 );
258 if ( is_wp_error( $response ) ) {
259 Activity_Log::record(
260 'preloader_warm_failed',
261 sprintf( 'Warm %s failed (%s): %s', $cause, $url, $response->get_error_message() ),
262 Activity_Log::WARN
263 );
264 return false;
265 }
266 $code = (int) wp_remote_retrieve_response_code( $response );
267 if ( $code >= 400 ) {
268 Activity_Log::record(
269 'preloader_warm_failed',
270 sprintf( 'Warm %s failed (%s): HTTP %d', $cause, $url, $code ),
271 Activity_Log::WARN
272 );
273 return false;
274 }
275 Activity_Log::record(
276 'preloader_warmed_one',
277 sprintf( 'Warmed %s (%s)', $url, $cause ),
278 Activity_Log::INFO
279 );
280 return true;
281 }
282
283 private static function warm_url( string $url, array &$state ): void {
284 $response = wp_remote_get(
285 $url,
286 array(
287 'timeout' => self::REQUEST_TIMEOUT,
288 'sslverify' => false,
289 'user-agent' => self::USER_AGENT,
290 'headers' => array(
291 'Accept' => 'text/html,application/xhtml+xml',
292 ),
293 'blocking' => true,
294 )
295 );
296 if ( is_wp_error( $response ) ) {
297 $state['errors'][] = array(
298 'url' => $url,
299 'error' => $response->get_error_message(),
300 'ts' => time(),
301 );
302 // Cap retained errors so a broken sitemap doesn't blow the
303 // transient size.
304 $state['errors'] = array_slice( $state['errors'], -20 );
305 return;
306 }
307 $code = (int) wp_remote_retrieve_response_code( $response );
308 if ( $code >= 400 ) {
309 $state['errors'][] = array(
310 'url' => $url,
311 'error' => sprintf( 'HTTP %d', $code ),
312 'ts' => time(),
313 );
314 $state['errors'] = array_slice( $state['errors'], -20 );
315 }
316 }
317
318 private static function mark_complete( array $state ): void {
319 $state['running'] = false;
320 $state['finished_at'] = time();
321 $state['queue'] = array();
322 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
323
324 Activity_Log::record(
325 'preloader_completed',
326 sprintf(
327 'Preloader finished — %d/%d URLs warmed, %d error%s.',
328 $state['processed'],
329 $state['total'],
330 count( $state['errors'] ),
331 1 === count( $state['errors'] ) ? '' : 's'
332 ),
333 empty( $state['errors'] ) ? Activity_Log::SUCCESS : Activity_Log::WARN
334 );
335 }
336
337 /**
338 * Build the URL queue for a fresh crawl: parse the sitemap, follow
339 * nested indexes, drop excluded paths.
340 *
341 * @return string[]
342 */
343 private static function resolve_queue( array $opts ): array {
344 // Request-scoped statics: reset so a previous crawl in the same
345 // process can't leak its verdict into this one.
346 self::$last_sitemap_error = '';
347 self::$last_sitemap_url = '';
348 self::$queue_source = 'none';
349
350 $sitemap = trim( (string) ( $opts['sitemap_url'] ?? '' ) );
351 if ( '' === $sitemap ) {
352 $sitemap = home_url( '/wp-sitemap.xml' );
353 }
354
355 $urls = self::fetch_sitemap_urls( $sitemap, 0 );
356 $from = empty( $urls ) ? 'none' : 'sitemap';
357
358 /*
359 * A missing sitemap must not disable the feature. Two very common
360 * setups produce one with no misconfiguration by the user:
361 * `blog_public = 0` (WordPress disables /wp-sitemap.xml outright,
362 * standard on staging and pre-launch sites), and an SEO plugin
363 * filtering `wp_sitemaps_enabled` to false while serving its own
364 * sitemap at a path we were never told about.
365 *
366 * Enumerate warmable URLs straight from the database instead. Only
367 * on a genuine fetch FAILURE — a sitemap that is reachable and
368 * legitimately empty is a real answer, and silently crawling
369 * something else would be worse than doing nothing. (#142)
370 */
371 if ( empty( $urls ) && '' !== self::$last_sitemap_error ) {
372 $urls = self::fallback_urls();
373 $from = empty( $urls ) ? 'none' : 'fallback';
374 }
375
376 $cache_opts = Settings_Manager::get( 'cache' );
377 $excluded = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
378 if ( ! empty( $excluded ) ) {
379 $urls = array_filter(
380 $urls,
381 static function ( $u ) use ( $excluded ) {
382 $path = (string) wp_parse_url( $u, PHP_URL_PATH );
383 foreach ( $excluded as $needle ) {
384 if ( '' !== $needle && false !== strpos( $path, (string) $needle ) ) {
385 return false;
386 }
387 }
388 return true;
389 }
390 );
391 }
392
393 // Dedup + cap at 5000 to bound the transient size on huge sites.
394 $urls = array_values( array_unique( $urls ) );
395 $urls = array_slice( $urls, 0, 5000 );
396
397 /*
398 * Commit the verdict only now, AFTER the exclusion filter — the
399 * source describes what we ACTUALLY queued, not what we hoped to.
400 * Setting it earlier let a queue that the exclusions stripped to
401 * nothing still claim `fallback`, so the panel announced "Warmed
402 * from site content" over 0 URLs, and `crawlFailed` (which needs
403 * source !== 'fallback' at total 0) could never become true.
404 * One assignment fixes both. (QA F2/F3 on #155)
405 */
406 self::$queue_source = empty( $urls ) ? 'none' : $from;
407
408 return $urls;
409 }
410
411 /**
412 * Enumerate warmable URLs from the database, for sites whose sitemap
413 * can't be fetched. Deliberately modest in scope: the home page, then
414 * the most recently modified public posts across every public post type.
415 * Newest-first is the right bias — those are the URLs most likely to be
416 * requested and least likely to be warm already.
417 *
418 * Uses WP_Query rather than SQL so post-type registration, status
419 * handling and multisite switching all behave the way the rest of
420 * WordPress does.
421 *
422 * @return string[]
423 */
424 private static function fallback_urls(): array {
425 $urls = array();
426 $home = (string) home_url( '/' );
427 if ( '' !== trim( $home, '/' ) ) {
428 $urls[] = $home;
429 }
430
431 $types = get_post_types(
432 array(
433 'public' => true,
434 'publicly_queryable' => true,
435 )
436 );
437 // `page` is public but not publicly_queryable, so the query above
438 // misses it — and pages are exactly what a warm cache wants most.
439 // Only add it when the site has post types at all: an empty list
440 // means there is nothing to enumerate, and constructing a WP_Query
441 // for it would be wasted work.
442 if ( ! empty( $types ) ) {
443 $types['page'] = 'page';
444 unset( $types['attachment'] );
445 }
446
447 if ( empty( $types ) || ! class_exists( '\WP_Query' ) ) {
448 return $urls;
449 }
450
451 $query = new \WP_Query(
452 array(
453 'post_type' => array_values( $types ),
454 'post_status' => 'publish',
455 'posts_per_page' => self::FALLBACK_LIMIT,
456 'orderby' => 'modified',
457 'order' => 'DESC',
458 'ignore_sticky_posts' => true,
459 'no_found_rows' => true,
460 'update_post_meta_cache' => false,
461 'update_post_term_cache' => false,
462 'fields' => 'ids',
463 )
464 );
465
466 foreach ( $query->posts as $post_id ) {
467 $permalink = get_permalink( (int) $post_id );
468 if ( is_string( $permalink ) && '' !== $permalink ) {
469 $urls[] = $permalink;
470 }
471 }
472
473 return array_values( array_unique( $urls ) );
474 }
475
476 /**
477 * Recursive sitemap parser. Depth-limited to 3 so a maliciously
478 * deep index can't stack-overflow.
479 */
480 private static function fetch_sitemap_urls( string $sitemap_url, int $depth ): array {
481 if ( $depth > 3 ) {
482 return array();
483 }
484 $res = wp_remote_get(
485 $sitemap_url,
486 array(
487 'timeout' => self::REQUEST_TIMEOUT,
488 'sslverify' => false,
489 'user-agent' => self::USER_AGENT,
490 )
491 );
492 if ( is_wp_error( $res ) ) {
493 // Record WHY, don't just vanish. "Unreachable" and "valid but
494 // empty" both used to collapse into an empty array here, which is
495 // what made a sitemap-less site look like a successful crawl of
496 // zero URLs. Only the top-level fetch is recorded: a nested index
497 // failing is a partial result, not a dead crawl. (#142)
498 if ( 0 === $depth ) {
499 self::$last_sitemap_error = sprintf(
500 /* translators: 1: sitemap URL, 2: error detail. */
501 __( 'Could not fetch the sitemap at %1$s — %2$s', 'xspeed' ),
502 $sitemap_url,
503 $res->get_error_message()
504 );
505 self::$last_sitemap_url = (string) $sitemap_url;
506 }
507 return array();
508 }
509 $code = (int) wp_remote_retrieve_response_code( $res );
510 if ( $code >= 400 ) {
511 if ( 0 === $depth ) {
512 self::$last_sitemap_error = sprintf(
513 /* translators: 1: sitemap URL, 2: HTTP status code. */
514 __( 'Could not fetch the sitemap at %1$s — the server returned HTTP %2$d.', 'xspeed' ),
515 $sitemap_url,
516 $code
517 );
518 self::$last_sitemap_url = (string) $sitemap_url;
519 }
520 return array();
521 }
522 $body = (string) wp_remote_retrieve_body( $res );
523 if ( '' === $body ) {
524 return array();
525 }
526
527 $urls = array();
528 // Sitemap index → recurse.
529 if ( false !== strpos( $body, '<sitemapindex' ) ) {
530 if ( preg_match_all( '#<loc>([^<]+)</loc>#i', $body, $matches ) ) {
531 foreach ( $matches[1] as $child ) {
532 $urls = array_merge( $urls, self::fetch_sitemap_urls( trim( $child ), $depth + 1 ) );
533 }
534 }
535 return $urls;
536 }
537 // URL set → collect.
538 if ( preg_match_all( '#<loc>([^<]+)</loc>#i', $body, $matches ) ) {
539 foreach ( $matches[1] as $u ) {
540 $u = trim( $u );
541 if ( '' !== $u && false !== filter_var( $u, FILTER_VALIDATE_URL ) ) {
542 $urls[] = $u;
543 }
544 }
545 }
546 return $urls;
547 }
548
549 /**
550 * Apply the user's schedule choice. Called on settings change.
551 * Manual = no cron schedule (user must hit "Start now" to crawl).
552 */
553 public static function apply_schedule( string $schedule ): void {
554 wp_clear_scheduled_hook( 'xspeed_preloader_recurring' );
555 if ( in_array( $schedule, array( 'hourly', 'daily', 'weekly' ), true ) ) {
556 if ( ! wp_next_scheduled( 'xspeed_preloader_recurring' ) ) {
557 wp_schedule_event( time() + 60, $schedule, 'xspeed_preloader_recurring' );
558 }
559 }
560 }
561
562 /**
563 * Recurring schedule hook handler — fires per the user's chosen
564 * cadence and kicks off a fresh crawl unless one is already running.
565 */
566 public static function recurring_kickoff(): void {
567 $state = self::status();
568 if ( $state['running'] ) {
569 return;
570 }
571 self::start();
572 }
573 }
574