PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.4
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.0.4, at includes/class-preloader.php

367 lines 10.4 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 * Kick off a fresh crawl. Returns the initial state.
44 */
45 public static function start(): array {
46 $opts = Settings_Manager::get( 'preloader' );
47 $urls = self::resolve_queue( $opts );
48
49 $state = array(
50 'running' => ! empty( $urls ),
51 'started_at' => time(),
52 'finished_at' => 0,
53 'queue' => array_values( $urls ),
54 'processed' => 0,
55 'total' => count( $urls ),
56 'last_url' => '',
57 'errors' => array(),
58 );
59 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
60
61 Activity_Log::record(
62 'preloader_started',
63 sprintf( 'Preloader queued %d URL%s for warming.', $state['total'], 1 === $state['total'] ? '' : 's' ),
64 $state['total'] > 0 ? Activity_Log::INFO : Activity_Log::WARN
65 );
66
67 // Schedule the first tick ~5 seconds out so the kick-off REST call
68 // returns instantly; wp_schedule_single_event covers the
69 // "process the queue ASAP" path without a heavy synchronous loop.
70 if ( $state['running'] ) {
71 wp_schedule_single_event( time() + 5, self::CRON_HOOK );
72 }
73
74 return $state;
75 }
76
77 /**
78 * Cancel an in-flight crawl. Idempotent.
79 */
80 public static function stop(): array {
81 $state = self::status();
82 if ( $state['running'] ) {
83 Activity_Log::record(
84 'preloader_stopped',
85 sprintf( 'Preloader stopped (%d/%d URLs warmed).', $state['processed'], $state['total'] ),
86 Activity_Log::INFO
87 );
88 }
89
90 // Clear scheduled ticks.
91 wp_clear_scheduled_hook( self::CRON_HOOK );
92
93 $state['running'] = false;
94 $state['finished_at'] = time();
95 $state['queue'] = array();
96 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
97 return $state;
98 }
99
100 public static function status(): array {
101 $raw = get_transient( self::STATE_KEY );
102 if ( ! is_array( $raw ) ) {
103 return self::empty_state();
104 }
105 return wp_parse_args( $raw, self::empty_state() );
106 }
107
108 private static function empty_state(): array {
109 return array(
110 'running' => false,
111 'started_at' => 0,
112 'finished_at' => 0,
113 'queue' => array(),
114 'processed' => 0,
115 'total' => 0,
116 'last_url' => '',
117 'errors' => array(),
118 );
119 }
120
121 /**
122 * Tick handler — pulls up to batch_size URLs off the queue, warms
123 * each, persists state, and reschedules itself until the queue is
124 * empty. Called via the xspeed_preloader_tick action.
125 */
126 public static function tick(): void {
127 $state = self::status();
128 if ( ! $state['running'] || empty( $state['queue'] ) ) {
129 if ( $state['running'] ) {
130 self::mark_complete( $state );
131 }
132 return;
133 }
134
135 $opts = Settings_Manager::get( 'preloader' );
136 $batch = max( 1, min( 50, (int) ( $opts['batch_size'] ?? 5 ) ) );
137
138 $processed_this_tick = 0;
139 while ( $processed_this_tick < $batch && ! empty( $state['queue'] ) ) {
140 $url = array_shift( $state['queue'] );
141 self::warm_url( $url, $state );
142 $state['processed']++;
143 $state['last_url'] = $url;
144 $processed_this_tick++;
145 }
146
147 if ( empty( $state['queue'] ) ) {
148 self::mark_complete( $state );
149 return;
150 }
151
152 // More to do — persist + reschedule. Slight delay to avoid
153 // hammering the origin with parallel batches.
154 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
155 wp_schedule_single_event( time() + 10, self::CRON_HOOK );
156 }
157
158 /**
159 * Fire a single warm request for one URL with no queue / no cron
160 * (the "content warmer" path: new post published → warm its URL
161 * immediately). Records an activity event so the user can see in
162 * the Health log that warming happened.
163 *
164 * Best-effort and non-blocking-feeling — uses a short timeout so a
165 * dead origin can't hang the calling request. Returns true if the
166 * fetch completed with a non-error status, false otherwise.
167 */
168 public static function warm_one( string $url, string $cause = 'manual' ): bool {
169 if ( '' === $url ) {
170 return false;
171 }
172 $response = wp_remote_get(
173 $url,
174 array(
175 'timeout' => self::REQUEST_TIMEOUT,
176 'sslverify' => false,
177 'user-agent' => self::USER_AGENT,
178 'blocking' => true,
179 )
180 );
181 if ( is_wp_error( $response ) ) {
182 Activity_Log::record(
183 'preloader_warm_failed',
184 sprintf( 'Warm %s failed (%s): %s', $cause, $url, $response->get_error_message() ),
185 Activity_Log::WARN
186 );
187 return false;
188 }
189 $code = (int) wp_remote_retrieve_response_code( $response );
190 if ( $code >= 400 ) {
191 Activity_Log::record(
192 'preloader_warm_failed',
193 sprintf( 'Warm %s failed (%s): HTTP %d', $cause, $url, $code ),
194 Activity_Log::WARN
195 );
196 return false;
197 }
198 Activity_Log::record(
199 'preloader_warmed_one',
200 sprintf( 'Warmed %s (%s)', $url, $cause ),
201 Activity_Log::INFO
202 );
203 return true;
204 }
205
206 private static function warm_url( string $url, array &$state ): void {
207 $response = wp_remote_get(
208 $url,
209 array(
210 'timeout' => self::REQUEST_TIMEOUT,
211 'sslverify' => false,
212 'user-agent' => self::USER_AGENT,
213 'headers' => array(
214 'Accept' => 'text/html,application/xhtml+xml',
215 ),
216 'blocking' => true,
217 )
218 );
219 if ( is_wp_error( $response ) ) {
220 $state['errors'][] = array(
221 'url' => $url,
222 'error' => $response->get_error_message(),
223 'ts' => time(),
224 );
225 // Cap retained errors so a broken sitemap doesn't blow the
226 // transient size.
227 $state['errors'] = array_slice( $state['errors'], -20 );
228 return;
229 }
230 $code = (int) wp_remote_retrieve_response_code( $response );
231 if ( $code >= 400 ) {
232 $state['errors'][] = array(
233 'url' => $url,
234 'error' => sprintf( 'HTTP %d', $code ),
235 'ts' => time(),
236 );
237 $state['errors'] = array_slice( $state['errors'], -20 );
238 }
239 }
240
241 private static function mark_complete( array $state ): void {
242 $state['running'] = false;
243 $state['finished_at'] = time();
244 $state['queue'] = array();
245 set_transient( self::STATE_KEY, $state, self::STATE_TTL );
246
247 Activity_Log::record(
248 'preloader_completed',
249 sprintf(
250 'Preloader finished — %d/%d URLs warmed, %d error%s.',
251 $state['processed'],
252 $state['total'],
253 count( $state['errors'] ),
254 1 === count( $state['errors'] ) ? '' : 's'
255 ),
256 empty( $state['errors'] ) ? Activity_Log::SUCCESS : Activity_Log::WARN
257 );
258 }
259
260 /**
261 * Build the URL queue for a fresh crawl: parse the sitemap, follow
262 * nested indexes, drop excluded paths.
263 *
264 * @return string[]
265 */
266 private static function resolve_queue( array $opts ): array {
267 $sitemap = trim( (string) ( $opts['sitemap_url'] ?? '' ) );
268 if ( '' === $sitemap ) {
269 $sitemap = home_url( '/wp-sitemap.xml' );
270 }
271
272 $urls = self::fetch_sitemap_urls( $sitemap, 0 );
273
274 $cache_opts = Settings_Manager::get( 'cache' );
275 $excluded = is_array( $cache_opts['excluded_urls'] ?? null ) ? $cache_opts['excluded_urls'] : array();
276 if ( ! empty( $excluded ) ) {
277 $urls = array_filter(
278 $urls,
279 static function ( $u ) use ( $excluded ) {
280 $path = (string) wp_parse_url( $u, PHP_URL_PATH );
281 foreach ( $excluded as $needle ) {
282 if ( '' !== $needle && false !== strpos( $path, (string) $needle ) ) {
283 return false;
284 }
285 }
286 return true;
287 }
288 );
289 }
290
291 // Dedup + cap at 5000 to bound the transient size on huge sites.
292 $urls = array_values( array_unique( $urls ) );
293 return array_slice( $urls, 0, 5000 );
294 }
295
296 /**
297 * Recursive sitemap parser. Depth-limited to 3 so a maliciously
298 * deep index can't stack-overflow.
299 */
300 private static function fetch_sitemap_urls( string $sitemap_url, int $depth ): array {
301 if ( $depth > 3 ) {
302 return array();
303 }
304 $res = wp_remote_get(
305 $sitemap_url,
306 array(
307 'timeout' => self::REQUEST_TIMEOUT,
308 'sslverify' => false,
309 'user-agent' => self::USER_AGENT,
310 )
311 );
312 if ( is_wp_error( $res ) || (int) wp_remote_retrieve_response_code( $res ) >= 400 ) {
313 return array();
314 }
315 $body = (string) wp_remote_retrieve_body( $res );
316 if ( '' === $body ) {
317 return array();
318 }
319
320 $urls = array();
321 // Sitemap index → recurse.
322 if ( false !== strpos( $body, '<sitemapindex' ) ) {
323 if ( preg_match_all( '#<loc>([^<]+)</loc>#i', $body, $matches ) ) {
324 foreach ( $matches[1] as $child ) {
325 $urls = array_merge( $urls, self::fetch_sitemap_urls( trim( $child ), $depth + 1 ) );
326 }
327 }
328 return $urls;
329 }
330 // URL set → collect.
331 if ( preg_match_all( '#<loc>([^<]+)</loc>#i', $body, $matches ) ) {
332 foreach ( $matches[1] as $u ) {
333 $u = trim( $u );
334 if ( '' !== $u && false !== filter_var( $u, FILTER_VALIDATE_URL ) ) {
335 $urls[] = $u;
336 }
337 }
338 }
339 return $urls;
340 }
341
342 /**
343 * Apply the user's schedule choice. Called on settings change.
344 * Manual = no cron schedule (user must hit "Start now" to crawl).
345 */
346 public static function apply_schedule( string $schedule ): void {
347 wp_clear_scheduled_hook( 'xspeed_preloader_recurring' );
348 if ( in_array( $schedule, array( 'hourly', 'daily', 'weekly' ), true ) ) {
349 if ( ! wp_next_scheduled( 'xspeed_preloader_recurring' ) ) {
350 wp_schedule_event( time() + 60, $schedule, 'xspeed_preloader_recurring' );
351 }
352 }
353 }
354
355 /**
356 * Recurring schedule hook handler — fires per the user's chosen
357 * cadence and kicks off a fresh crawl unless one is already running.
358 */
359 public static function recurring_kickoff(): void {
360 $state = self::status();
361 if ( $state['running'] ) {
362 return;
363 }
364 self::start();
365 }
366 }
367