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-cache-inventory.php

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

537 lines 18.6 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 = defined( 'XSPEED_CACHE_DIR' ) ? (array) glob( XSPEED_CACHE_DIR . '/*.html' ) : array();
219 foreach ( $flat as $file ) {
220 if ( $scanned >= self::SCAN_CAP ) {
221 $capped = true;
222 break;
223 }
224 ++$scanned;
225
226 $size = (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A file purged between glob() and stat() is expected, not exceptional.
227 $rows[] = array(
228 'url' => self::read_url( $file ),
229 'path' => $file,
230 'key' => basename( $file, '.html' ),
231 'bytes' => $size,
232 'compressed' => self::sibling_size( $file . '.br' ),
233 'mtime' => (int) @filemtime( $file ), // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Same race as filesize() above.
234 'stored_in' => array( 'flat' ),
235 );
236 }
237
238 // Static tree — URL is the path, so no read is needed at all.
239 if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) && is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
240 $https = ! function_exists( 'home_url' ) || 0 === stripos( (string) home_url(), 'https://' );
241 self::walk_static( XSPEED_CACHE_STATIC_DIR, XSPEED_CACHE_STATIC_DIR, $https, $rows, $scanned, $capped );
242 }
243
244 return array(
245 'entries' => self::merge_rows( $rows ),
246 'capped' => $capped,
247 'generated' => time(),
248 );
249 }
250
251 /**
252 * Recursive walk of the static tree, mirroring Cache::rmtree_html()'s
253 * traversal so the two can't disagree about what counts as an entry.
254 *
255 * @param array<int,array<string,mixed>> $rows Collected rows, by reference.
256 * @param int $scanned Files examined so far, by reference.
257 * @param bool $capped Set when SCAN_CAP is hit, by reference.
258 */
259 private static function walk_static( string $dir, string $root, bool $https, array &$rows, int &$scanned, bool &$capped ): void {
260 if ( $capped ) {
261 return;
262 }
263 $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.
264 if ( false === $entries ) {
265 return;
266 }
267
268 foreach ( $entries as $entry ) {
269 if ( '.' === $entry || '..' === $entry ) {
270 continue;
271 }
272 $path = $dir . '/' . $entry;
273
274 if ( is_dir( $path ) ) {
275 self::walk_static( $path, $root, $https, $rows, $scanned, $capped );
276 if ( $capped ) {
277 return;
278 }
279 continue;
280 }
281 if ( 'index.html' !== $entry ) {
282 continue;
283 }
284 if ( $scanned >= self::SCAN_CAP ) {
285 $capped = true;
286 return;
287 }
288 ++$scanned;
289
290 $rows[] = array(
291 'url' => self::url_from_static_path( $path, $root, $https ),
292 'path' => $path,
293 'key' => '',
294 'bytes' => (int) @filesize( $path ), // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan().
295 'compressed' => self::sibling_size( $path . '.br' ),
296 'mtime' => (int) @filemtime( $path ), // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan().
297 'stored_in' => array( 'static' ),
298 );
299 }
300 }
301
302 /** Size of a precompressed sibling, or 0 when there isn't one. */
303 private static function sibling_size( string $path ): int {
304 if ( ! is_readable( $path ) ) {
305 return 0;
306 }
307 return (int) @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan().
308 }
309
310 /**
311 * Read just enough of a cached document to find its canonical URL.
312 *
313 * Deliberately a partial read: WP_Filesystem::get_contents() would pull
314 * whole pages into memory, and at SCAN_CAP entries the difference is
315 * megabytes of pointless I/O for data we discard immediately.
316 */
317 private static function read_url( string $file ): ?string {
318 // 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.
319 $head = @file_get_contents( $file, false, null, 0, self::HEAD_BYTES );
320 if ( ! is_string( $head ) || '' === $head ) {
321 return null;
322 }
323 return self::extract_url_from_html( $head );
324 }
325
326 /**
327 * Where the cache's disk usage actually goes.
328 *
329 * Buckets are what a user can act on — pages, their precompressed
330 * siblings, the REST response cache, minified assets — not what the
331 * code happens to write. `compressed_bytes` is real measured bytes from
332 * `.br` siblings, never an estimate: a made-up compression ratio on a
333 * dashboard is worse than no number.
334 *
335 * @return array<string,mixed>
336 */
337 public static function size_breakdown(): array {
338 $buckets = array(
339 'pages_flat' => array(
340 'label' => __( 'Cached pages (PHP)', 'xspeed' ),
341 'bytes' => 0,
342 'files' => 0,
343 ),
344 'pages_static' => array(
345 'label' => __( 'Cached pages (server-served)', 'xspeed' ),
346 'bytes' => 0,
347 'files' => 0,
348 ),
349 'precompressed' => array(
350 'label' => __( 'Precompressed copies', 'xspeed' ),
351 'bytes' => 0,
352 'files' => 0,
353 ),
354 'metadata' => array(
355 'label' => __( 'Per-entry metadata', 'xspeed' ),
356 'bytes' => 0,
357 'files' => 0,
358 ),
359 'rest' => array(
360 'label' => __( 'REST responses', 'xspeed' ),
361 'bytes' => 0,
362 'files' => 0,
363 ),
364 'assets' => array(
365 'label' => __( 'Minified CSS/JS', 'xspeed' ),
366 'bytes' => 0,
367 'files' => 0,
368 ),
369 );
370
371 if ( defined( 'XSPEED_CACHE_DIR' ) ) {
372 self::add_glob( $buckets['pages_flat'], XSPEED_CACHE_DIR . '/*.html' );
373 self::add_glob( $buckets['metadata'], XSPEED_CACHE_DIR . '/*.meta' );
374 self::add_glob( $buckets['precompressed'], XSPEED_CACHE_DIR . '/*.br' );
375 self::add_glob( $buckets['rest'], XSPEED_CACHE_DIR . '/rest/*.json' );
376 self::add_glob( $buckets['assets'], XSPEED_CACHE_DIR . '/min/*.css' );
377 self::add_glob( $buckets['assets'], XSPEED_CACHE_DIR . '/min/*.js' );
378 self::add_glob( $buckets['assets'], XSPEED_CACHE_DIR . '/min/combined/*.css' );
379 self::add_glob( $buckets['assets'], XSPEED_CACHE_DIR . '/min/combined/*.js' );
380 }
381
382 if ( defined( 'XSPEED_CACHE_STATIC_DIR' ) && is_dir( XSPEED_CACHE_STATIC_DIR ) ) {
383 $static = self::measure_static( XSPEED_CACHE_STATIC_DIR );
384 $buckets['pages_static']['bytes'] = $static['html_bytes'];
385 $buckets['pages_static']['files'] = $static['html_files'];
386 $buckets['precompressed']['bytes'] += $static['br_bytes'];
387 $buckets['precompressed']['files'] += $static['br_files'];
388 }
389
390 $total_bytes = 0;
391 $total_files = 0;
392 $out = array();
393 foreach ( $buckets as $key => $bucket ) {
394 $total_bytes += $bucket['bytes'];
395 $total_files += $bucket['files'];
396 $out[] = array(
397 'key' => $key,
398 'label' => $bucket['label'],
399 'bytes' => $bucket['bytes'],
400 'files' => $bucket['files'],
401 );
402 }
403
404 return array(
405 'buckets' => $out,
406 'total_bytes' => $total_bytes,
407 'total_files' => $total_files,
408 // What a visitor actually downloads for the pages that have a
409 // precompressed copy. Pages without one are served compressed by
410 // the web server at request time, which we cannot measure from
411 // here — so this is a floor, and the UI labels it as one.
412 'compressed_bytes' => $buckets['precompressed']['bytes'],
413 'pages' => $buckets['pages_flat']['files'] + $buckets['pages_static']['files'],
414 );
415 }
416
417 /**
418 * Accumulate a glob into a bucket.
419 *
420 * @param array{label:string,bytes:int,files:int} $bucket By reference.
421 */
422 private static function add_glob( array &$bucket, string $pattern ): void {
423 foreach ( (array) glob( $pattern ) as $file ) {
424 if ( ! is_string( $file ) ) {
425 continue;
426 }
427 $bucket['bytes'] += (int) @filesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan().
428 ++$bucket['files'];
429 }
430 }
431
432 /**
433 * Total the static tree without collecting per-file rows.
434 *
435 * @return array{html_bytes:int,html_files:int,br_bytes:int,br_files:int}
436 */
437 private static function measure_static( string $dir ): array {
438 $totals = array(
439 'html_bytes' => 0,
440 'html_files' => 0,
441 'br_bytes' => 0,
442 'br_files' => 0,
443 );
444 $entries = @scandir( $dir, SCANDIR_SORT_NONE ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Unreadable subdir contributes nothing rather than fataling.
445 if ( false === $entries ) {
446 return $totals;
447 }
448
449 foreach ( $entries as $entry ) {
450 if ( '.' === $entry || '..' === $entry ) {
451 continue;
452 }
453 $path = $dir . '/' . $entry;
454 if ( is_dir( $path ) ) {
455 $sub = self::measure_static( $path );
456 foreach ( $totals as $k => $v ) {
457 $totals[ $k ] = $v + $sub[ $k ];
458 }
459 continue;
460 }
461 if ( 'index.html' === $entry ) {
462 $totals['html_bytes'] += (int) @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan().
463 ++$totals['html_files'];
464 } elseif ( substr( $entry, -3 ) === '.br' ) {
465 $totals['br_bytes'] += (int) @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Purge race, see scan().
466 ++$totals['br_files'];
467 }
468 }
469
470 return $totals;
471 }
472
473 /**
474 * Filter the activity log down to purge events.
475 *
476 * The log already carries the cause in its message ("post saved",
477 * "settings change", "manual"), which is the whole point of the
478 * drill-down: "Last purge — 4h ago" is trivia until you can see that it
479 * was a post save rather than something clearing the cache every hour.
480 *
481 * Pure with respect to the entries passed in, so the type filter is
482 * testable without a WordPress transient.
483 *
484 * @param array<int,array<string,mixed>> $entries Activity_Log::entries() output.
485 * @return array<int,array<string,mixed>>
486 */
487 public static function filter_purges( array $entries, int $limit = 25 ): array {
488 $out = array();
489 foreach ( $entries as $entry ) {
490 $type = isset( $entry['type'] ) ? (string) $entry['type'] : '';
491 if ( ! in_array( $type, self::PURGE_TYPES, true ) ) {
492 continue;
493 }
494 $out[] = array(
495 'ts' => (int) ( $entry['ts'] ?? 0 ),
496 'type' => $type,
497 'message' => (string) ( $entry['message'] ?? '' ),
498 'severity' => (string) ( $entry['severity'] ?? 'info' ),
499 );
500 if ( count( $out ) >= $limit ) {
501 break;
502 }
503 }
504 return $out;
505 }
506
507 /**
508 * The last-purge drill-down.
509 *
510 * `last_gc` / `gc_removed` / `gc_removed_total` come from the daily
511 * `xspeed_gc` sweep (Cache_GC) so the collector is verifiable from the
512 * dashboard instead of over SSH.
513 *
514 * @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}
515 */
516 public static function purge_log( int $limit = 25 ): array {
517 $limit = max( 1, min( 50, $limit ) );
518 $events = self::filter_purges( Activity_Log::entries(), $limit );
519 $stats = get_option( 'xspeed_stats', array() );
520 $stats = is_array( $stats ) ? $stats : array();
521
522 return array(
523 'events' => $events,
524 'last_purge' => (int) ( $stats['last_purge'] ?? 0 ),
525 'last_gc' => (int) ( $stats['last_gc'] ?? 0 ),
526 'gc_removed' => (int) ( $stats['gc_removed'] ?? 0 ),
527 'gc_removed_total' => (int) ( $stats['gc_removed_total'] ?? 0 ),
528 'gc_next_run' => (int) wp_next_scheduled( Cache_GC::CRON_HOOK ),
529 );
530 }
531
532 /** Drop the memoized scan — called after a purge so the list can't lie. */
533 public static function invalidate(): void {
534 delete_transient( self::TRANSIENT_KEY );
535 }
536 }
537