PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 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 All 36 releases
← All changes | includes/class-cache-gc.php +82 -26 1.3.3 → 1.4.2 View file →
@@ -8,11 +8,13 @@
8 8 * deletes anything. Expiry still works (both serve paths age-check before
9 9 * using a file, so nobody is served a stale page), but the expired bodies sit
10 10 * on disk forever and the admin's Cache Size figure only ever climbs.
11 11 *
12 - * Minified assets are worse: the key is md5(source path | source mtime)
13 - * (Minifier::rewrite_asset), so every plugin or theme update mints a new
14 - * min/ file and orphans the old one permanently.
12 + * Minified assets are worse: they are named by content (Minifier::
13 + * rewrite_asset), so every plugin or theme update mints a new min/ file and
14 + * orphans the old one permanently. The per-source manifests that map a
15 + * source to its current name (min/manifests/<blog>/*.json) orphan the same
16 + * way when a source goes away or its output is collected.
15 17 *
16 18 * This adds the missing time-driven collector — a daily `xspeed_gc` cron that
17 19 * sweeps in three phases:
18 20 *
@@ -18,8 +20,9 @@
18 20 *
19 21 * flat wp-content/cache/xspeed/<md5>.html per-entry TTL
20 22 * static wp-content/cache/xspeed-static/**\/index.html global TTL
21 23 * min wp-content/cache/xspeed/min/**\/*.css|js long max-age
24 + * plus min/manifests/**\/*.json long max-age, orphans only
22 25 *
23 26 * Deliberately NOT swept: `rest/*.json`. A REST entry's TTL is resolved per
24 27 * request through the `xspeed_rest_cache_ttl` filter and is never written to
25 28 * disk (Rest_Cache::ttl_for), so nothing on disk tells GC when one expired.
@@ -233,8 +236,19 @@
233 236 if ( ! self::is_stale( $path, $now, $max_age ) ) {
234 237 continue;
235 238 }
236 239
240 + // A manifest is never linked from a page, so the reference index
241 + // has nothing to say about it. It goes only when it can no longer
242 + // be used: its source is gone, or the output it names is.
243 + if ( 'min' === $phase && '.json' === substr( $path, -5 ) ) {
244 + if ( self::manifest_is_orphan( $path ) ) {
245 + wp_delete_file( $path );
246 + ++$removed;
247 + }
248 + continue;
249 + }
250 +
237 251 // An asset a live cached page still links to is NOT collectable,
238 252 // however old it is. Age is a hint about orphanhood; this is the
239 253 // fact. Without it GC deleted files every cached page pointed at
240 254 // and left the pages in place, so the site served 200s full of
@@ -309,8 +323,12 @@
309 323 return true;
310 324 case 'static':
311 325 return 'index.html' === $name;
312 326 case 'min':
327 + if ( '.json' === substr( $name, -5 ) ) {
328 + $manifests = self::phase_root( 'min' ) . '/' . Asset_Manifest::SUBDIR . '/';
329 + return 0 === strpos( $path, $manifests );
330 + }
313 331 return '.css' === substr( $name, -4 ) || '.js' === substr( $name, -3 );
314 332 }
315 333 return false;
316 334 }
@@ -315,8 +333,39 @@
315 333 return false;
316 334 }
317 335
318 336 /**
337 + * Is this manifest useless now?
338 + *
339 + * True when its source no longer exists, or when it names a minified
340 + * output that is no longer on disk. The second is safe to drop even while
341 + * the source lives: the next render rebuilds both. A combine-part
342 + * manifest names no output, so only its source decides. An unreadable
343 + * manifest is useless by definition.
344 + *
345 + * @param string $path Absolute manifest path.
346 + */
347 + private static function manifest_is_orphan( string $path ): bool {
348 + $manifest = Asset_Manifest::read( $path );
349 + if ( null === $manifest || empty( $manifest['src'] ) || ! is_string( $manifest['src'] ) ) {
350 + return true;
351 + }
352 + if ( ! file_exists( $manifest['src'] ) ) {
353 + return true;
354 + }
355 + $root = self::phase_root( 'min' );
356 + $key = isset( $manifest['key'] ) && is_string( $manifest['key'] ) ? $manifest['key'] : '';
357 + if ( '' === $key || null === $root ) {
358 + return true;
359 + }
360 + $kind = isset( $manifest['kind'] ) && is_string( $manifest['kind'] ) ? $manifest['kind'] : '';
361 + if ( 'css' === $kind || 'js' === $kind ) {
362 + return ! file_exists( $root . '/' . $key . '.' . $kind );
363 + }
364 + return false;
365 + }
366 +
367 + /**
319 368 * Effective max-age for a cached page, in seconds.
320 369 *
321 370 * Cache::is_expired() is the read-time gate and is deliberately NOT
322 371 * reused here: it resolves the per-post override from the *current*
@@ -340,9 +389,9 @@
340 389 // lifetime, and this tree is served without PHP so nothing else
341 390 // can enforce it. A site caching for a week would otherwise hand
342 391 // out a dead nonce for six and a half days of it, breaking every
343 392 // anonymous form on the page.
344 - $expires_file = dirname( $path ) . '/.xspeed-expires';
393 + $expires_file = dirname( $path ) . '/' . Cache::EXPIRES_MARKER;
345 394 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- sidecar read on a cron sweep; WP_Filesystem is not loaded here.
346 395 $expires = is_readable( $expires_file ) ? (int) @file_get_contents( $expires_file ) : 0; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- file may vanish between the check and the read.
347 396 if ( $expires > 0 ) {
348 397 $mtime = @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- false is handled below.
@@ -470,29 +519,14 @@
470 519 }
471 520
472 521 /**
473 522 * Delete a cache entry and every sibling that only exists because of it,
474 - * so the sweep never creates the orphans it is there to remove:
475 - *
476 - * <md5>.html → <md5>.meta, <md5>.html.br
477 - * index.html → index.html.br
478 - * <key>.css/js → (none)
523 + * so the sweep never creates the orphans it is there to remove.
479 524 */
480 525 private static function delete_entry( string $path ): void {
481 526 wp_delete_file( $path );
527 + self::delete_siblings( $path );
482 528
483 - $br = $path . '.br';
484 - if ( file_exists( $br ) ) {
485 - wp_delete_file( $br );
486 - }
487 -
488 - if ( '.html' === substr( $path, -5 ) ) {
489 - $meta = substr( $path, 0, -5 ) . '.meta';
490 - if ( file_exists( $meta ) ) {
491 - wp_delete_file( $meta );
492 - }
493 - }
494 -
495 529 // Deleting an asset and invalidating the pages that embed it are ONE
496 530 // operation, so the two caches can never disagree. is_referenced()
497 531 // already keeps a linked asset alive, so this is the belt to that
498 532 // braces: it covers the races the index cannot see — a page written
@@ -505,8 +539,34 @@
505 539 }
506 540 }
507 541
508 542 /**
543 + * The files that exist only because of $path:
544 + *
545 + * <md5>.html → <md5>.meta, <md5>.html.br, <md5>.html.br.size
546 + * index.html → index.html.br, index.html.br.size, the expires marker
547 + * <key>.css/js → its .br and .br.size, if any
548 + *
549 + * The marker and the size record were missed once, which left every
550 + * nonce page's folder non-empty so prune_empty_dirs() could never remove
551 + * it (#636).
552 + */
553 + private static function delete_siblings( string $path ): void {
554 + $siblings = array( $path . '.br', $path . '.br.size' );
555 + if ( '.html' === substr( $path, -5 ) ) {
556 + $siblings[] = substr( $path, 0, -5 ) . '.meta';
557 + }
558 + if ( 'index.html' === basename( $path ) ) {
559 + $siblings[] = dirname( $path ) . '/' . Cache::EXPIRES_MARKER;
560 + }
561 + foreach ( $siblings as $sibling ) {
562 + if ( file_exists( $sibling ) ) {
563 + wp_delete_file( $sibling );
564 + }
565 + }
566 + }
567 +
568 + /**
509 569 * Remove every cached page that links to the given asset.
510 570 *
511 571 * Walks both trees: the static one is served by nginx without PHP, so a
512 572 * page left there keeps serving the dead link no matter what the flat
@@ -534,13 +594,9 @@
534 594 continue;
535 595 }
536 596
537 597 wp_delete_file( $file );
538 - foreach ( array( $file . '.br', substr( $file, 0, -5 ) . '.meta' ) as $sibling ) {
539 - if ( file_exists( $sibling ) ) {
540 - wp_delete_file( $sibling );
541 - }
542 - }
598 + self::delete_siblings( $file );
543 599 }
544 600 }
545 601 }
546 602