PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.2 1.2.3
xspeed / includes / class-cache-inventory.php

class-cache-inventory.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, 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