| @@ -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 | |