PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.1 All 27 releases
xspeed / includes / class-cache-inventory.php

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

558 lines 19.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cache_Inventory — what is actually in the cache, and what it costs.
4 *
5 * The four stat cards on the dashboard answer "how much"; this answers
6 * "which". A number like "412 cached pages" is only trustworthy if you
7 * can open it and see the 412, and a size of "8.4 MB" is only actionable
8 * if you can see what is taking the space.
9 *
10 * Two storage shapes have to be reconciled here:
11 *
12 * flat XSPEED_CACHE_DIR/<md5>.html written by the PHP path
13 * static XSPEED_CACHE_STATIC_DIR/<host><uri>/index.html
14 * served by the web server
15 *
16 * The static tree encodes the URL in its path, so those entries are exact.
17 * The flat tree is keyed by md5( host . uri . device . … ), which cannot be
18 * reversed — so for those we read the first HEAD_BYTES of the body and pull
19 * the canonical/og:url out of the markup. That resolves the overwhelming
20 * majority of real pages, and an entry we cannot name is reported with a
21 * null URL rather than a guess.
22 *
23 * Cost control: scanning is capped (SCAN_CAP) and the result memoized for
24 * CACHE_TTL, because this runs on an admin click, not on a page render.
25 *
26 * @package XSpeed
27 */
28
29 declare(strict_types=1);
30
31 namespace XSpeed;
32
33 defined( 'ABSPATH' ) || exit;
34
35 final class Cache_Inventory {
36
37 /**
38 * Hard ceiling on entries examined in one pass. A site with 50k cached
39 * pages must not turn one dashboard click into a 50k-file stat storm;
40 * the response says `capped: true` so the UI can say so out loud
41 * instead of quietly showing a partial list as if it were the whole.
42 */
43 public const SCAN_CAP = 750;
44
45 /** Bytes of each flat entry read to look for a canonical URL. */
46 public const HEAD_BYTES = 16384;
47
48 /** Memoization window for a full scan, in seconds. */
49 public const CACHE_TTL = 60;
50
51 public const TRANSIENT_KEY = 'xspeed_cache_inventory';
52
53 /** Activity-log event types that represent a purge. */
54 private const PURGE_TYPES = array( 'cache_purged', 'cache_purge_url', 'cache_purged_url' );
55
56 /**
57 * Resolve a static-tree file back to the URL that produced it.
58 *
59 * `<root>/example.com/blog/post/index.html` → `https://example.com/blog/post/`.
60 * Returns null for anything that isn't shaped like a static entry, so a
61 * stray file in the tree can't become a bogus row.
62 *
63 * Pure — no filesystem access, so the path logic is testable on its own.
64 */
65 public static function url_from_static_path( string $file, string $root, bool $https = true ): ?string {
66 $root = rtrim( str_replace( '\\', '/', $root ), '/' );
67 $file = str_replace( '\\', '/', $file );
68
69 if ( '' === $root || 0 !== strpos( $file, $root . '/' ) ) {
70 return null;
71 }
72 if ( substr( $file, -11 ) !== '/index.html' ) {
73 return null;
74 }
75
76 $rel = substr( $file, strlen( $root ) + 1, -11 );
77 if ( '' === $rel ) {
78 return null;
79 }
80
81 $segments = explode( '/', $rel );
82 $host = array_shift( $segments );
83 // Same allowlist store_static() writes with — anything else is not
84 // ours and must not be presented as a cached page.
85 if ( null === $host || '' === $host || preg_match( '/[^a-zA-Z0-9.\-]/', $host ) ) {
86 return null;
87 }
88
89 $path = '' === implode( '/', $segments ) ? '/' : '/' . implode( '/', $segments ) . '/';
90
91 return ( $https ? 'https://' : 'http://' ) . $host . $path;
92 }
93
94 /**
95 * Pull a page URL out of the top of a cached document.
96 *
97 * Prefers `<link rel="canonical">` — WordPress emits it for singular
98 * views and it is what the site itself considers the page's address.
99 * Falls back to `og:url`. Returns null rather than guessing from, say,
100 * the first anchor in the body.
101 *
102 * Pure.
103 */
104 public static function extract_url_from_html( string $head ): ?string {
105 if ( preg_match( '#<link[^>]+rel=["\']canonical["\'][^>]*>#i', $head, $tag ) ) {
106 if ( preg_match( '#href=["\']([^"\']+)["\']#i', $tag[0], $href ) ) {
107 $url = html_entity_decode( trim( $href[1] ), ENT_QUOTES );
108 if ( 0 === stripos( $url, 'http' ) ) {
109 return $url;
110 }
111 }
112 }
113 if ( preg_match( '#<meta[^>]+property=["\']og:url["\'][^>]*>#i', $head, $tag ) ) {
114 if ( preg_match( '#content=["\']([^"\']+)["\']#i', $tag[0], $content ) ) {
115 $url = html_entity_decode( trim( $content[1] ), ENT_QUOTES );
116 if ( 0 === stripos( $url, 'http' ) ) {
117 return $url;
118 }
119 }
120 }
121 return null;
122 }
123
124 /**
125 * Merge the two storage shapes into one row per page.
126 *
127 * A page served by the web-server rewrite usually exists in BOTH trees.
128 * Listing it twice would make the drill-down disagree with the stat card
129 * it was opened from, so rows are keyed by URL when we know it: the
130 * newest mtime wins for "age", disk bytes add up, and `stored_in` records
131 * which copies exist.
132 *
133 * Entries whose URL is unknown can't be merged with anything — they stay
134 * distinct, keyed by their own path.
135 *
136 * Pure: takes already-collected rows, returns merged rows.
137 *
138 * @param array<int,array<string,mixed>> $rows Raw rows from either tree.
139 * @return array<int,array<string,mixed>> Merged, newest-first.
140 */
141 public static function merge_rows( array $rows ): array {
142 $merged = array();
143
144 foreach ( $rows as $row ) {
145 $url = isset( $row['url'] ) && is_string( $row['url'] ) ? $row['url'] : null;
146 $key = null !== $url ? 'u:' . $url : 'p:' . (string) ( $row['path'] ?? '' );
147
148 if ( ! isset( $merged[ $key ] ) ) {
149 $merged[ $key ] = $row;
150 continue;
151 }
152
153 $existing = $merged[ $key ];
154 $existing['bytes'] = (int) $existing['bytes'] + (int) $row['bytes'];
155 $existing['mtime'] = max( (int) $existing['mtime'], (int) $row['mtime'] );
156 $existing['stored_in'] = array_values( array_unique( array_merge( (array) $existing['stored_in'], (array) $row['stored_in'] ) ) );
157 $existing['compressed'] += (int) $row['compressed'];
158 sort( $existing['stored_in'] );
159 $merged[ $key ] = $existing;
160 }
161
162 $out = array_values( $merged );
163 usort(
164 $out,
165 static function ( array $a, array $b ): int {
166 return (int) $b['mtime'] <=> (int) $a['mtime'];
167 }
168 );
169
170 return $out;
171 }
172
173 /**
174 * The cached-pages drill-down.
175 *
176 * @param int $limit Rows returned.
177 * @param int $offset Rows skipped.
178 * @param bool $fresh Bypass the memoized scan.
179 * @return array{entries:array<int,array<string,mixed>>,total:int,capped:bool,generated:int}
180 */
181 public static function entries( int $limit = 50, int $offset = 0, bool $fresh = false ): array {
182 $rows = $fresh ? null : get_transient( self::TRANSIENT_KEY );
183 if ( ! is_array( $rows ) || ! isset( $rows['entries'] ) ) {
184 $rows = self::scan();
185 set_transient( self::TRANSIENT_KEY, $rows, self::CACHE_TTL );
186 }
187
188 $all = is_array( $rows['entries'] ) ? $rows['entries'] : array();
189 $limit = max( 1, min( 200, $limit ) );
190 $offset = max( 0, $offset );
191 $now = time();
192
193 $page = array_slice( $all, $offset, $limit );
194 foreach ( $page as $i => $entry ) {
195 $page[ $i ]['age'] = max( 0, $now - (int) $entry['mtime'] );
196 }
197
198 return array(
199 'entries' => array_values( $page ),
200 'total' => count( $all ),
201 'capped' => (bool) ( $rows['capped'] ?? false ),
202 'generated' => (int) ( $rows['generated'] ?? $now ),
203 );
204 }
205
206 /**
207 * Walk both trees. Separated from entries() so the memoization and the
208 * pagination stay readable, and so a test can drive the walk directly.
209 *
210 * @return array{entries:array<int,array<string,mixed>>,capped:bool,generated:int}
211 */
212 public static function scan(): array {
213 $rows = array();
214 $scanned = 0;
215 $capped = false;
216
217 // Flat tree — md5-keyed, URL recovered from the markup.
218 // Flat entries live in per-site buckets since #6
219 // (XSPEED_CACHE_DIR/<host>/<md5>.html); the legacy top-level layout
220 // is still globbed so pre-#6 entries remain visible until they age out.
221 $flat = defined( 'XSPEED_CACHE_DIR' )
222 ? array_merge(
223 (array) glob( XSPEED_CACHE_DIR . '/*.html' ),
224 (array) glob( XSPEED_CACHE_DIR . '/*/*.html' )
225 )
226 : array();
227 $flat = array_values(
228 array_filter(
229 $flat,
230 static function ( $f ) {
231 // min/ and rest/ are separate buckets, not page entries.
232 return ! in_array( basename( dirname( (string) $f ) ), array( 'min', 'rest', 'combined' ), true );
233 }
234 )
235 );
236 foreach ( $flat as $file ) {
237 if ( $scanned >= self::SCAN_CAP ) {
238 $capped = true;
239 break;
240 }
241 ++$scanned;
242
243 $size = (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A file purged between glob() and stat() is expected, not exceptional.
244 $rows[] = array(
245 'url' => self::read_url( $file ),
246 'path' => $file,
247 'key' => basename( $file, '.html' ),
248 'bytes' => $size,
249 'compressed' => self::sibling_size( $file . '.br' ),
250 'mtime' => (int) @filemtime( $file ), // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Same race as filesize() above.
251 'stored_in' => array( 'flat' ),
252 );
253 }
254
255 // Static tree — URL is the path, so no read is needed at all.
256 if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) && is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
257 $https = ! function_exists( 'home_url' ) || 0 === stripos( (string) home_url(), 'https://' );
258 self::walk_static( XSPEED_CACHE_STATIC_DIR, XSPEED_CACHE_STATIC_DIR, $https, $rows, $scanned, $capped );
259 }
260
261 return array(
262 'entries' => self::merge_rows( $rows ),
263 'capped' => $capped,
264 'generated' => time(),
265 );
266 }
267
268 /**
269 * Recursive walk of the static tree, mirroring Cache::rmtree_html()'s
270 * traversal so the two can't disagree about what counts as an entry.
271 *
272 * @param array<int,array<string,mixed>> $rows Collected rows, by reference.
273 * @param int $scanned Files examined so far, by reference.
274 * @param bool $capped Set when SCAN_CAP is hit, by reference.
275 */
276 private static function walk_static( string $dir, string $root, bool $https, array &$rows, int &$scanned, bool &$capped ): void {
277 if ( $capped ) {
278 return;
279 }
280 $entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- An unreadable subdir yields no rows; it must not fatal the drill-down.
281 if ( false === $entries ) {
282 return;
283 }
284
285 foreach ( $entries as $entry ) {
286 if ( '.' === $entry || '..' === $entry ) {
287 continue;
288 }
289 $path = $dir . '/' . $entry;
290
291 if ( is_dir( $path ) ) {
292 self::walk_static( $path, $root, $https, $rows, $scanned, $capped );
293 if ( $capped ) {
294 return;
295 }
296 continue;
297 }
298 if ( 'index.html' !== $entry ) {
299 continue;
300 }
301 if ( $scanned >= self::SCAN_CAP ) {
302 $capped = true;
303 return;
304 }
305 ++$scanned;
306
307 $rows[] = array(
308 'url' => self::url_from_static_path( $path, $root, $https ),
309 'path' => $path,
310 'key' => '',
311 'bytes' => (int) @filesize( $path ), // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan().
312 'compressed' => self::sibling_size( $path . '.br' ),
313 'mtime' => (int) @filemtime( $path ), // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan().
314 'stored_in' => array( 'static' ),
315 );
316 }
317 }
318
319 /** Size of a precompressed sibling, or 0 when there isn't one. */
320 private static function sibling_size( string $path ): int {
321 if ( ! is_readable( $path ) ) {
322 return 0;
323 }
324 return (int) @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan().
325 }
326
327 /**
328 * Read just enough of a cached document to find its canonical URL.
329 *
330 * Deliberately a partial read: WP_Filesystem::get_contents() would pull
331 * whole pages into memory, and at SCAN_CAP entries the difference is
332 * megabytes of pointless I/O for data we discard immediately.
333 */
334 private static function read_url( string $file ): ?string {
335 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- Partial read (16KB of head markup); WP_Filesystem has no offset/length API and would read entire pages.
336 $head = @file_get_contents( $file, false, null, 0, self::HEAD_BYTES );
337 if ( ! is_string( $head ) || '' === $head ) {
338 return null;
339 }
340 return self::extract_url_from_html( $head );
341 }
342
343 /**
344 * Where the cache's disk usage actually goes.
345 *
346 * Buckets are what a user can act on — pages, their precompressed
347 * siblings, the REST response cache, minified assets — not what the
348 * code happens to write. `compressed_bytes` is real measured bytes from
349 * `.br` siblings, never an estimate: a made-up compression ratio on a
350 * dashboard is worse than no number.
351 *
352 * @return array<string,mixed>
353 */
354 public static function size_breakdown(): array {
355 $buckets = array(
356 'pages_flat' => array(
357 'label' => __( 'Cached pages (PHP)', 'xspeed' ),
358 'bytes' => 0,
359 'files' => 0,
360 ),
361 'pages_static' => array(
362 'label' => __( 'Cached pages (server-served)', 'xspeed' ),
363 'bytes' => 0,
364 'files' => 0,
365 ),
366 'precompressed' => array(
367 'label' => __( 'Precompressed copies', 'xspeed' ),
368 'bytes' => 0,
369 'files' => 0,
370 ),
371 'metadata' => array(
372 'label' => __( 'Per-entry metadata', 'xspeed' ),
373 'bytes' => 0,
374 'files' => 0,
375 ),
376 'rest' => array(
377 'label' => __( 'REST responses', 'xspeed' ),
378 'bytes' => 0,
379 'files' => 0,
380 ),
381 'assets' => array(
382 'label' => __( 'Minified CSS/JS', 'xspeed' ),
383 'bytes' => 0,
384 'files' => 0,
385 ),
386 );
387
388 if ( defined( 'XSPEED_CACHE_DIR' ) ) {
389 // Both layouts: per-site buckets (#6) and pre-#6 top level.
390 self::add_glob( $buckets['pages_flat'], XSPEED_CACHE_DIR . '/*.html' );
391 self::add_glob( $buckets['metadata'], XSPEED_CACHE_DIR . '/*.meta' );
392 self::add_glob( $buckets['precompressed'], XSPEED_CACHE_DIR . '/*.br' );
393 self::add_glob( $buckets['pages_flat'], XSPEED_CACHE_DIR . '/*/*.html' );
394 self::add_glob( $buckets['metadata'], XSPEED_CACHE_DIR . '/*/*.meta' );
395 self::add_glob( $buckets['precompressed'], XSPEED_CACHE_DIR . '/*/*.br' );
396 self::add_glob( $buckets['rest'], XSPEED_CACHE_DIR . '/rest/*.json' );
397 self::add_glob( $buckets['assets'], XSPEED_CACHE_DIR . '/min/*.css' );
398 self::add_glob( $buckets['assets'], XSPEED_CACHE_DIR . '/min/*.js' );
399 self::add_glob( $buckets['assets'], XSPEED_CACHE_DIR . '/min/combined/*.css' );
400 self::add_glob( $buckets['assets'], XSPEED_CACHE_DIR . '/min/combined/*.js' );
401 }
402
403 if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) && is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
404 $static = self::measure_static( XSPEED_CACHE_STATIC_DIR );
405 $buckets['pages_static']['bytes'] = $static['html_bytes'];
406 $buckets['pages_static']['files'] = $static['html_files'];
407 $buckets['precompressed']['bytes'] += $static['br_bytes'];
408 $buckets['precompressed']['files'] += $static['br_files'];
409 }
410
411 $total_bytes = 0;
412 $total_files = 0;
413 $out = array();
414 foreach ( $buckets as $key => $bucket ) {
415 $total_bytes += $bucket['bytes'];
416 $total_files += $bucket['files'];
417 $out[] = array(
418 'key' => $key,
419 'label' => $bucket['label'],
420 'bytes' => $bucket['bytes'],
421 'files' => $bucket['files'],
422 );
423 }
424
425 return array(
426 'buckets' => $out,
427 'total_bytes' => $total_bytes,
428 'total_files' => $total_files,
429 // What a visitor actually downloads for the pages that have a
430 // precompressed copy. Pages without one are served compressed by
431 // the web server at request time, which we cannot measure from
432 // here — so this is a floor, and the UI labels it as one.
433 'compressed_bytes' => $buckets['precompressed']['bytes'],
434 'pages' => $buckets['pages_flat']['files'] + $buckets['pages_static']['files'],
435 );
436 }
437
438 /**
439 * Accumulate a glob into a bucket.
440 *
441 * @param array{label:string,bytes:int,files:int} $bucket By reference.
442 */
443 private static function add_glob( array &$bucket, string $pattern ): void {
444 foreach ( (array) glob( $pattern ) as $file ) {
445 if ( ! is_string( $file ) ) {
446 continue;
447 }
448 $bucket['bytes'] += (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan().
449 ++$bucket['files'];
450 }
451 }
452
453 /**
454 * Total the static tree without collecting per-file rows.
455 *
456 * @return array{html_bytes:int,html_files:int,br_bytes:int,br_files:int}
457 */
458 private static function measure_static( string $dir ): array {
459 $totals = array(
460 'html_bytes' => 0,
461 'html_files' => 0,
462 'br_bytes' => 0,
463 'br_files' => 0,
464 );
465 $entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Unreadable subdir contributes nothing rather than fataling.
466 if ( false === $entries ) {
467 return $totals;
468 }
469
470 foreach ( $entries as $entry ) {
471 if ( '.' === $entry || '..' === $entry ) {
472 continue;
473 }
474 $path = $dir . '/' . $entry;
475 if ( is_dir( $path ) ) {
476 $sub = self::measure_static( $path );
477 foreach ( $totals as $k => $v ) {
478 $totals[ $k ] = $v + $sub[ $k ];
479 }
480 continue;
481 }
482 if ( 'index.html' === $entry ) {
483 $totals['html_bytes'] += (int) @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan().
484 ++$totals['html_files'];
485 } elseif ( substr( $entry, -3 ) === '.br' ) {
486 $totals['br_bytes'] += (int) @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan().
487 ++$totals['br_files'];
488 }
489 }
490
491 return $totals;
492 }
493
494 /**
495 * Filter the activity log down to purge events.
496 *
497 * The log already carries the cause in its message ("post saved",
498 * "settings change", "manual"), which is the whole point of the
499 * drill-down: "Last purge — 4h ago" is trivia until you can see that it
500 * was a post save rather than something clearing the cache every hour.
501 *
502 * Pure with respect to the entries passed in, so the type filter is
503 * testable without a WordPress transient.
504 *
505 * @param array<int,array<string,mixed>> $entries Activity_Log::entries() output.
506 * @return array<int,array<string,mixed>>
507 */
508 public static function filter_purges( array $entries, int $limit = 25 ): array {
509 $out = array();
510 foreach ( $entries as $entry ) {
511 $type = isset( $entry['type'] ) ? (string) $entry['type'] : '';
512 if ( ! in_array( $type, self::PURGE_TYPES, true ) ) {
513 continue;
514 }
515 $out[] = array(
516 'ts' => (int) ( $entry['ts'] ?? 0 ),
517 'type' => $type,
518 'message' => (string) ( $entry['message'] ?? '' ),
519 'severity' => (string) ( $entry['severity'] ?? 'info' ),
520 );
521 if ( count( $out ) >= $limit ) {
522 break;
523 }
524 }
525 return $out;
526 }
527
528 /**
529 * The last-purge drill-down.
530 *
531 * `last_gc` / `gc_removed` / `gc_removed_total` come from the daily
532 * `xspeed_gc` sweep (Cache_GC) so the collector is verifiable from the
533 * dashboard instead of over SSH.
534 *
535 * @return array{events:array<int,array<string,mixed>>,last_purge:int,last_gc:int,gc_removed:int,gc_removed_total:int,gc_next_run:int}
536 */
537 public static function purge_log( int $limit = 25 ): array {
538 $limit = max( 1, min( 50, $limit ) );
539 $events = self::filter_purges( Activity_Log::entries(), $limit );
540 $stats = get_option( 'xspeed_stats', array() );
541 $stats = is_array( $stats ) ? $stats : array();
542
543 return array(
544 'events' => $events,
545 'last_purge' => (int) ( $stats['last_purge'] ?? 0 ),
546 'last_gc' => (int) ( $stats['last_gc'] ?? 0 ),
547 'gc_removed' => (int) ( $stats['gc_removed'] ?? 0 ),
548 'gc_removed_total' => (int) ( $stats['gc_removed_total'] ?? 0 ),
549 'gc_next_run' => (int) wp_next_scheduled( Cache_GC::CRON_HOOK ),
550 );
551 }
552
553 /** Drop the memoized scan — called after a purge so the list can't lie. */
554 public static function invalidate(): void {
555 delete_transient( self::TRANSIENT_KEY );
556 }
557 }
558