PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
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 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/class-cache-gc.php +238 -15 1.1.51.3.3 View file →
@@ -72,13 +72,20 @@
72 72 *
73 73 * Deliberately long. These files are only rewritten when the source
74 74 * asset's mtime changes, so a live, still-referenced asset keeps its
75 75 * original mtime forever — a short max-age here would delete assets the
76 - * current pages still link to and re-minify them on the next request,
77 - * every day. 30 days means a superseded file is collected a month after
78 - * the update that orphaned it, and a live one is regenerated (once) a
79 - * month after it was built.
76 + * current pages still link to. 30 days means a superseded file is
77 + * collected roughly a month after the update that orphaned it.
80 78 *
79 + * Age ALONE is not a liveness test, and this docblock used to claim it
80 + * was safe because "a live one is regenerated (once) a month after it was
81 + * built". That is wrong: regeneration only happens on a cache MISS, when
82 + * PHP runs the enqueue pipeline. On a HIT PHP never boots, so nothing
83 + * regenerates and the page keeps serving a dead link — the mitigation
84 + * failed precisely on the well-cached sites it was meant to protect.
85 + * `is_referenced()` is the actual guard; this max-age only decides when
86 + * an UNREFERENCED file is collected. (#190)
87 + *
81 88 * A filter returning <= 0 disables the min/ phase rather than deleting
82 89 * everything — "no max age" is the safer reading of an unset value.
83 90 */
84 91 public static function asset_max_age(): int {
@@ -115,12 +122,19 @@
115 122 $budget = self::budget();
116 123 $cursor = self::read_cursor();
117 124 $removed = 0;
118 125
126 + // Rebuild the "which assets are still linked" index per run. Memoized
127 + // within a run (a sweep examines many files), but never across runs —
128 + // pages are written and purged between ticks, and a stale index would
129 + // either protect an orphan forever or, worse, fail to protect a live
130 + // asset. (#190)
131 + self::reset_reference_index();
132 +
119 133 // Resolve the global TTL once — Settings_Manager::get() is cheap but
120 134 // this runs per candidate otherwise.
121 135 $opts = Settings_Manager::get( 'cache' );
122 - $default_ttl = max( 1, (int) ( $opts['cache_expiry'] ?? 24 ) ) * HOUR_IN_SECONDS;
136 + $default_ttl = max( 1, (int) ( $opts['cache_expiry'] ?? \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXPIRY_HOURS ) ) * HOUR_IN_SECONDS;
123 137 $asset_ttl = self::asset_max_age();
124 138 $now = time();
125 139
126 140 // Start at the phase we paused in and carry on round the list. Each
@@ -185,11 +199,17 @@
185 199 }
186 200
187 201 $removed = 0;
188 202
189 - // The flat cache is one flat directory; min/ and rest/ sit inside it
190 - // with their own rules, so don't descend for that phase.
191 - foreach ( self::files( $root, 'flat' !== $phase ) as $path ) {
203 + // Every phase descends now. The flat phase used to walk only the top
204 + // level, back when entries lived directly in XSPEED_CACHE_DIR — but
205 + // per-site buckets moved every entry one level down (or two, for a
206 + // subdirectory-multisite subsite), so a non-recursive walk stopped
207 + // seeing the only layout that exists and GC silently expired nothing.
208 + // On single sites too: their entries are bucketed under the host as
209 + // well. is_candidate() is what keeps min/ and rest/ out, so recursing
210 + // here does not pull them in. (QA B1 on #166)
211 + foreach ( self::files( $root, true ) as $path ) {
192 212 // Cheap name test first: a non-candidate costs no stat and no
193 213 // budget. Everything else in these directories (index.php,
194 214 // .meta, .br, .mobile-separate, the hits log) is either a
195 215 // sibling collected with its parent or must never be touched.
@@ -209,12 +229,23 @@
209 229 --$budget;
210 230 $after = $path;
211 231
212 232 $max_age = 'min' === $phase ? $asset_ttl : self::page_max_age( $phase, $path, $default_ttl );
213 - if ( self::is_stale( $path, $now, $max_age ) ) {
214 - self::delete_entry( $path );
215 - ++$removed;
233 + if ( ! self::is_stale( $path, $now, $max_age ) ) {
234 + continue;
216 235 }
236 +
237 + // An asset a live cached page still links to is NOT collectable,
238 + // however old it is. Age is a hint about orphanhood; this is the
239 + // fact. Without it GC deleted files every cached page pointed at
240 + // and left the pages in place, so the site served 200s full of
241 + // 404s. (#190)
242 + if ( 'min' === $phase && self::is_referenced( $path ) ) {
243 + continue;
244 + }
245 +
246 + self::delete_entry( $path );
247 + ++$removed;
217 248 }
218 249
219 250 return array( $removed, '' );
220 251 }
@@ -241,10 +272,42 @@
241 272 private static function is_candidate( string $phase, string $path ): bool {
242 273 $name = basename( $path );
243 274 switch ( $phase ) {
244 275 case 'flat':
245 - return '.html' === substr( $name, -5 )
246 - && dirname( $path ) === XSPEED_CACHE_DIR;
276 + /*
277 + * Flat entries live in a per-site bucket since #6:
278 + *
279 + * <cache>/<host>/<md5>.html single site, main blog
280 + * <cache>/<host>/<prefix>/<md5>.html subdirectory subsite
281 + *
282 + * Both depths must be accepted — the two-level form is where a
283 + * subdirectory-multisite subsite's pages live, and accepting
284 + * only one level left them uncollectable. The legacy top-level
285 + * layout stays accepted so entries written before #6 still age
286 + * out instead of lingering forever. (QA B1 on #166)
287 + *
288 + * Depth alone is not the guard against min/ and rest/: those
289 + * are excluded by name, at either level, because the sweep now
290 + * recurses and would otherwise treat their contents as pages.
291 + */
292 + if ( '.html' !== substr( $name, -5 ) ) {
293 + return false;
294 + }
295 + $parent = dirname( $path );
296 + $depth1 = $parent === XSPEED_CACHE_DIR;
297 + $depth2 = dirname( $parent ) === XSPEED_CACHE_DIR;
298 + $depth3 = dirname( dirname( $parent ) ) === XSPEED_CACHE_DIR;
299 + if ( ! $depth1 && ! $depth2 && ! $depth3 ) {
300 + return false;
301 + }
302 + // Walk up to the cache root looking for a reserved directory,
303 + // so `min/` and `rest/` are excluded however deep we are.
304 + for ( $dir = $parent; strlen( $dir ) > strlen( XSPEED_CACHE_DIR ); $dir = dirname( $dir ) ) {
305 + if ( in_array( basename( $dir ), array( 'min', 'rest' ), true ) ) {
306 + return false;
307 + }
308 + }
309 + return true;
247 310 case 'static':
248 311 return 'index.html' === $name;
249 312 case 'min':
250 313 return '.css' === substr( $name, -4 ) || '.js' === substr( $name, -3 );
@@ -270,13 +333,46 @@
270 333 * The static tree never has a .meta: store_static() only runs for plain
271 334 * 200 text/html, so the global TTL is always correct there.
272 335 */
273 336 private static function page_max_age( string $phase, string $path, int $default_ttl ): int {
337 + if ( 'static' === $phase ) {
338 + // A nonce-bearing page records its own deadline when written: the
339 + // nonce dies on WordPress's schedule, not the site's cache
340 + // lifetime, and this tree is served without PHP so nothing else
341 + // can enforce it. A site caching for a week would otherwise hand
342 + // out a dead nonce for six and a half days of it, breaking every
343 + // anonymous form on the page.
344 + $expires_file = dirname( $path ) . '/.xspeed-expires';
345 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- sidecar read on a cron sweep; WP_Filesystem is not loaded here.
346 + $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 + if ( $expires > 0 ) {
348 + $mtime = @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- false is handled below.
349 + // Express the deadline as an age, since the caller compares
350 + // against the file's own mtime. A file already past its
351 + // deadline gets 0, which expires it on this sweep.
352 + return ( false !== $mtime ) ? max( 0, $expires - $mtime ) : 0;
353 + }
354 +
355 + return $default_ttl;
356 + }
274 357 if ( 'flat' !== $phase ) {
275 358 return $default_ttl;
276 359 }
277 - $meta = Cache::read_meta( basename( $path, '.html' ) );
278 - $ttl = isset( $meta['ttl'] ) ? (int) $meta['ttl'] : 0;
360 + // Read the sidecar NEXT TO THE FILE. Cache::read_meta() rebuilds the
361 + // path from the key via cache_meta_for(), which resolves against the
362 + // CURRENT request's site bucket — wrong for a cron sweep walking
363 + // every site's entries, and wrong for the legacy top-level layout.
364 + // The sidecar is always `<file>.meta`, so derive it directly. (#6)
365 + $meta_file = substr( $path, 0, -5 ) . '.meta';
366 + $ttl = 0;
367 + if ( is_file( $meta_file ) ) {
368 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache sidecar; WP_Filesystem needs admin credentials unavailable during cron.
369 + $raw = (string) @file_get_contents( $meta_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable sidecar just means "use the global TTL".
370 + $decoded = json_decode( $raw, true );
371 + if ( is_array( $decoded ) && isset( $decoded['ttl'] ) ) {
372 + $ttl = (int) $decoded['ttl'];
373 + }
374 + }
279 375 return $ttl > 0 ? $ttl : $default_ttl;
280 376 }
281 377
282 378 /**
@@ -297,8 +393,84 @@
297 393 return ( $now - (int) $mtime ) > $max_age;
298 394 }
299 395
300 396 /**
397 + * Asset paths (relative to `min/`) that some cached page still links to.
398 + *
399 + * Built once per run and memoized: a sweep examines up to `budget()`
400 + * files, and re-reading every cached page for each of them would turn a
401 + * cheap cron tick into an O(assets x pages) crawl.
402 + *
403 + * Scans BOTH cache trees. The static tree is served by nginx without ever
404 + * running PHP, so a page there can outlive any invalidation we do in PHP —
405 + * missing it would leave exactly the 404s this fix exists to prevent, on
406 + * the fastest path.
407 + *
408 + * @var array<string,true>|null
409 + */
410 + private static $referenced = null;
411 +
412 + /** Forget the memo — the next run rebuilds it. */
413 + public static function reset_reference_index(): void {
414 + self::$referenced = null;
415 + }
416 +
417 + /**
418 + * Is this asset linked from any cached page?
419 + *
420 + * @param string $path Absolute path to a file under `min/`.
421 + */
422 + private static function is_referenced( string $path ): bool {
423 + if ( null === self::$referenced ) {
424 + self::$referenced = self::build_reference_index();
425 + }
426 +
427 + $min_root = self::phase_root( 'min' );
428 + if ( null === $min_root ) {
429 + return false;
430 + }
431 + // Compare on the path RELATIVE to min/, which is what a page's URL
432 + // carries — absolute paths differ between the cache dir and the URL.
433 + $rel = ltrim( str_replace( $min_root, '', $path ), '/' );
434 +
435 + return isset( self::$referenced[ $rel ] );
436 + }
437 +
438 + /**
439 + * Read every cached page once and collect the assets they reference.
440 + *
441 + * @return array<string,true> Keys are paths relative to `min/`.
442 + */
443 + private static function build_reference_index(): array {
444 + $found = array();
445 +
446 + foreach ( array( 'flat', 'static' ) as $phase ) {
447 + $root = self::phase_root( $phase );
448 + if ( null === $root || ! is_dir( $root ) ) {
449 + continue;
450 + }
451 + foreach ( self::files( $root, 'flat' !== $phase ) as $file ) {
452 + if ( '.html' !== substr( $file, -5 ) ) {
453 + continue;
454 + }
455 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading our own cache file; WP_Filesystem is unavailable in cron context.
456 + $html = (string) @file_get_contents( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a concurrent purge can unlink mid-walk; '' is handled.
457 + if ( '' === $html ) {
458 + continue;
459 + }
460 + if ( ! preg_match_all( '#/cache/xspeed/min/([^"\'\s?>]+\.(?:css|js))#', $html, $m ) ) {
461 + continue;
462 + }
463 + foreach ( $m[1] as $rel ) {
464 + $found[ $rel ] = true;
465 + }
466 + }
467 + }
468 +
469 + return $found;
470 + }
471 +
472 + /**
301 473 * Delete a cache entry and every sibling that only exists because of it,
302 474 * so the sweep never creates the orphans it is there to remove:
303 475 *
304 476 * <md5>.html → <md5>.meta, <md5>.html.br
@@ -316,8 +488,59 @@
316 488 if ( '.html' === substr( $path, -5 ) ) {
317 489 $meta = substr( $path, 0, -5 ) . '.meta';
318 490 if ( file_exists( $meta ) ) {
319 491 wp_delete_file( $meta );
492 + }
493 + }
494 +
495 + // Deleting an asset and invalidating the pages that embed it are ONE
496 + // operation, so the two caches can never disagree. is_referenced()
497 + // already keeps a linked asset alive, so this is the belt to that
498 + // braces: it covers the races the index cannot see — a page written
499 + // after the index was built, or a reference in a form the scan did
500 + // not match. Without it, any gap between the two caches shows up as a
501 + // 200 page full of 404s. (#190 AC2)
502 + $min_root = self::phase_root( 'min' );
503 + if ( null !== $min_root && 0 === strpos( $path, $min_root . '/' ) ) {
504 + self::purge_pages_referencing( ltrim( str_replace( $min_root, '', $path ), '/' ) );
505 + }
506 + }
507 +
508 + /**
509 + * Remove every cached page that links to the given asset.
510 + *
511 + * Walks both trees: the static one is served by nginx without PHP, so a
512 + * page left there keeps serving the dead link no matter what the flat
513 + * cache says.
514 + *
515 + * @param string $rel Asset path relative to `min/`.
516 + */
517 + private static function purge_pages_referencing( string $rel ): void {
518 + if ( '' === $rel ) {
519 + return;
520 + }
521 +
522 + foreach ( array( 'flat', 'static' ) as $phase ) {
523 + $root = self::phase_root( $phase );
524 + if ( null === $root || ! is_dir( $root ) ) {
525 + continue;
526 + }
527 + foreach ( self::files( $root, 'flat' !== $phase ) as $file ) {
528 + if ( '.html' !== substr( $file, -5 ) ) {
529 + continue;
530 + }
531 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading our own cache file; WP_Filesystem is unavailable in cron context.
532 + $html = (string) @file_get_contents( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- concurrent purge can unlink mid-walk.
533 + if ( '' === $html || false === strpos( $html, $rel ) ) {
534 + continue;
535 + }
536 +
537 + 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 + }
320 543 }
321 544 }
322 545 }
323 546