| 1 |
<?php |
| 2 |
/** |
| 3 |
* Cache garbage collection. |
| 4 |
* |
| 5 |
* Invalidation everywhere else in the plugin is event-driven: a post save, a |
| 6 |
* settings change, a theme switch, an explicit purge. A site that fires none |
| 7 |
* of those — a brochure site, a docs portal, a finished catalog — never |
| 8 |
* deletes anything. Expiry still works (both serve paths age-check before |
| 9 |
* using a file, so nobody is served a stale page), but the expired bodies sit |
| 10 |
* on disk forever and the admin's Cache Size figure only ever climbs. |
| 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. |
| 15 |
* |
| 16 |
* This adds the missing time-driven collector — a daily `xspeed_gc` cron that |
| 17 |
* sweeps in three phases: |
| 18 |
* |
| 19 |
* flat wp-content/cache/xspeed/<md5>.html per-entry TTL |
| 20 |
* static wp-content/cache/xspeed-static/**\/index.html global TTL |
| 21 |
* min wp-content/cache/xspeed/min/**\/*.css|js long max-age |
| 22 |
* |
| 23 |
* Deliberately NOT swept: `rest/*.json`. A REST entry's TTL is resolved per |
| 24 |
* request through the `xspeed_rest_cache_ttl` filter and is never written to |
| 25 |
* disk (Rest_Cache::ttl_for), so nothing on disk tells GC when one expired. |
| 26 |
* |
| 27 |
* @package XSpeed |
| 28 |
*/ |
| 29 |
|
| 30 |
declare(strict_types=1); |
| 31 |
|
| 32 |
namespace XSpeed; |
| 33 |
|
| 34 |
defined( 'ABSPATH' ) || exit; |
| 35 |
|
| 36 |
final class Cache_GC { |
| 37 |
|
| 38 |
/** Daily cron hook. */ |
| 39 |
public const CRON_HOOK = 'xspeed_gc'; |
| 40 |
|
| 41 |
/** Where the resume point between capped runs is stored. */ |
| 42 |
public const CURSOR_OPTION = 'xspeed_gc_cursor'; |
| 43 |
|
| 44 |
/** Candidate files examined per run before the sweep pauses. */ |
| 45 |
public const DEFAULT_BUDGET = 5000; |
| 46 |
|
| 47 |
/** Sweep order. A run walks these in sequence until the budget is spent. */ |
| 48 |
private const PHASES = array( 'flat', 'static', 'min' ); |
| 49 |
|
| 50 |
/** |
| 51 |
* Register the daily event if it isn't already scheduled. |
| 52 |
* |
| 53 |
* Called from both CacheModule::activate() (fresh installs) and |
| 54 |
* CacheModule::boot() (sites that upgraded into this version and will |
| 55 |
* never run the activation hook again). |
| 56 |
*/ |
| 57 |
public static function ensure_scheduled(): void { |
| 58 |
if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { |
| 59 |
// An hour out rather than immediately: activation already does |
| 60 |
// enough filesystem work, and nothing here is urgent. |
| 61 |
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK ); |
| 62 |
} |
| 63 |
} |
| 64 |
|
| 65 |
/** Drop the event. Called from CacheModule::deactivate(). */ |
| 66 |
public static function unschedule(): void { |
| 67 |
wp_clear_scheduled_hook( self::CRON_HOOK ); |
| 68 |
} |
| 69 |
|
| 70 |
/** |
| 71 |
* How long a minified asset may sit unused before collection. |
| 72 |
* |
| 73 |
* Deliberately long. These files are only rewritten when the source |
| 74 |
* asset's mtime changes, so a live, still-referenced asset keeps its |
| 75 |
* original mtime forever — a short max-age here would delete assets the |
| 76 |
* current pages still link to. 30 days means a superseded file is |
| 77 |
* collected roughly a month after the update that orphaned it. |
| 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 |
* |
| 88 |
* A filter returning <= 0 disables the min/ phase rather than deleting |
| 89 |
* everything — "no max age" is the safer reading of an unset value. |
| 90 |
*/ |
| 91 |
public static function asset_max_age(): int { |
| 92 |
/** |
| 93 |
* Filter the max-age (seconds) for minified/combined assets. |
| 94 |
* |
| 95 |
* @param int $max_age Default 30 days. |
| 96 |
*/ |
| 97 |
return (int) apply_filters( 'xspeed_gc_asset_max_age', 30 * DAY_IN_SECONDS ); |
| 98 |
} |
| 99 |
|
| 100 |
/** Candidate files a single run may examine. */ |
| 101 |
public static function budget(): int { |
| 102 |
/** |
| 103 |
* Filter the per-run cap on files examined. |
| 104 |
* |
| 105 |
* The sweep stops once this many candidates have been looked at and |
| 106 |
* resumes from the same point on the next run, so a site with |
| 107 |
* hundreds of thousands of entries can't blow the cron timeout. |
| 108 |
* |
| 109 |
* @param int $budget Default 5000. |
| 110 |
*/ |
| 111 |
return max( 1, (int) apply_filters( 'xspeed_gc_budget', self::DEFAULT_BUDGET ) ); |
| 112 |
} |
| 113 |
|
| 114 |
/** |
| 115 |
* Run one bounded sweep. |
| 116 |
* |
| 117 |
* @param string $cause Who asked, for the activity log. |
| 118 |
* @return int Files removed (parents only; .meta/.br siblings are not |
| 119 |
* counted, matching purge_all()). |
| 120 |
*/ |
| 121 |
public static function run( string $cause = 'scheduled' ): int { |
| 122 |
$budget = self::budget(); |
| 123 |
$cursor = self::read_cursor(); |
| 124 |
$removed = 0; |
| 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 |
|
| 133 |
// Resolve the global TTL once — Settings_Manager::get() is cheap but |
| 134 |
// this runs per candidate otherwise. |
| 135 |
$opts = Settings_Manager::get( 'cache' ); |
| 136 |
$default_ttl = max( 1, (int) ( $opts['cache_expiry'] ?? \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXPIRY_HOURS ) ) * HOUR_IN_SECONDS; |
| 137 |
$asset_ttl = self::asset_max_age(); |
| 138 |
$now = time(); |
| 139 |
|
| 140 |
// Start at the phase we paused in and carry on round the list. Each |
| 141 |
// completed phase resets the cursor and moves to the next; when the |
| 142 |
// last one completes we wrap back to the first, so the next run |
| 143 |
// starts a fresh cycle. |
| 144 |
$start = array_search( $cursor['phase'], self::PHASES, true ); |
| 145 |
$start = false === $start ? 0 : (int) $start; |
| 146 |
$after = (string) $cursor['after']; |
| 147 |
|
| 148 |
for ( $i = $start; $i < count( self::PHASES ); $i++ ) { |
| 149 |
$phase = self::PHASES[ $i ]; |
| 150 |
|
| 151 |
if ( 'min' === $phase && $asset_ttl <= 0 ) { |
| 152 |
$after = ''; |
| 153 |
continue; |
| 154 |
} |
| 155 |
|
| 156 |
list( $phase_removed, $stopped_at ) = self::sweep_phase( $phase, $after, $budget, $now, $default_ttl, $asset_ttl ); |
| 157 |
$removed += $phase_removed; |
| 158 |
|
| 159 |
if ( '' !== $stopped_at ) { |
| 160 |
// Budget spent mid-phase — remember where to pick up. |
| 161 |
self::write_cursor( $phase, $stopped_at ); |
| 162 |
self::finish( $removed, $cause ); |
| 163 |
return $removed; |
| 164 |
} |
| 165 |
|
| 166 |
// Phase complete. The static tree can now be pruned of the |
| 167 |
// directories the sweep emptied — safe only once the whole tree |
| 168 |
// has been walked, and bounded because it happens at most once |
| 169 |
// per full cycle. |
| 170 |
if ( 'static' === $phase && defined( 'XSPEED_CACHE_STATIC_DIR' ) ) { |
| 171 |
self::prune_empty_dirs( XSPEED_CACHE_STATIC_DIR ); |
| 172 |
} |
| 173 |
|
| 174 |
$after = ''; |
| 175 |
} |
| 176 |
|
| 177 |
// Full cycle done — rewind to the first phase. |
| 178 |
self::write_cursor( self::PHASES[0], '' ); |
| 179 |
self::finish( $removed, $cause ); |
| 180 |
return $removed; |
| 181 |
} |
| 182 |
|
| 183 |
/** |
| 184 |
* Sweep one phase. |
| 185 |
* |
| 186 |
* @param string $phase One of self::PHASES. |
| 187 |
* @param string $after Resume point (absolute path) or ''. |
| 188 |
* @param int $budget Remaining candidate budget, decremented. |
| 189 |
* @param int $now Run timestamp. |
| 190 |
* @param int $default_ttl Global page TTL in seconds. |
| 191 |
* @param int $asset_ttl Minified-asset max-age in seconds. |
| 192 |
* @return array{0:int,1:string} Removed count, and the path the sweep |
| 193 |
* stopped at ('' when the phase finished). |
| 194 |
*/ |
| 195 |
private static function sweep_phase( string $phase, string $after, int &$budget, int $now, int $default_ttl, int $asset_ttl ): array { |
| 196 |
$root = self::phase_root( $phase ); |
| 197 |
if ( null === $root || ! is_dir( $root ) ) { |
| 198 |
return array( 0, '' ); |
| 199 |
} |
| 200 |
|
| 201 |
$removed = 0; |
| 202 |
|
| 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 ) { |
| 212 |
// Cheap name test first: a non-candidate costs no stat and no |
| 213 |
// budget. Everything else in these directories (index.php, |
| 214 |
// .meta, .br, .mobile-separate, the hits log) is either a |
| 215 |
// sibling collected with its parent or must never be touched. |
| 216 |
if ( ! self::is_candidate( $phase, $path ) ) { |
| 217 |
continue; |
| 218 |
} |
| 219 |
// Skip everything already handled in an earlier run. String |
| 220 |
// compare only — self::files() yields in a stable sorted order. |
| 221 |
if ( '' !== $after && strcmp( $path, $after ) <= 0 ) { |
| 222 |
continue; |
| 223 |
} |
| 224 |
if ( $budget <= 0 ) { |
| 225 |
// Paused before examining $path. $after is the last candidate |
| 226 |
// we did examine, which is exactly where to resume. |
| 227 |
return array( $removed, $after ); |
| 228 |
} |
| 229 |
--$budget; |
| 230 |
$after = $path; |
| 231 |
|
| 232 |
$max_age = 'min' === $phase ? $asset_ttl : self::page_max_age( $phase, $path, $default_ttl ); |
| 233 |
if ( ! self::is_stale( $path, $now, $max_age ) ) { |
| 234 |
continue; |
| 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; |
| 248 |
} |
| 249 |
|
| 250 |
return array( $removed, '' ); |
| 251 |
} |
| 252 |
|
| 253 |
/** Absolute root directory for a phase, or null when undefined. */ |
| 254 |
private static function phase_root( string $phase ): ?string { |
| 255 |
switch ( $phase ) { |
| 256 |
case 'flat': |
| 257 |
return defined( 'XSPEED_CACHE_DIR' ) ? XSPEED_CACHE_DIR : null; |
| 258 |
case 'static': |
| 259 |
return defined( 'XSPEED_CACHE_STATIC_DIR' ) ? XSPEED_CACHE_STATIC_DIR : null; |
| 260 |
case 'min': |
| 261 |
return defined( 'XSPEED_CACHE_DIR' ) ? XSPEED_CACHE_DIR . '/min' : null; |
| 262 |
} |
| 263 |
return null; |
| 264 |
} |
| 265 |
|
| 266 |
/** |
| 267 |
* Is this file one the given phase collects? |
| 268 |
* |
| 269 |
* The flat phase deliberately ignores subdirectories — min/ and rest/ |
| 270 |
* live under XSPEED_CACHE_DIR and have their own rules (or none). |
| 271 |
*/ |
| 272 |
private static function is_candidate( string $phase, string $path ): bool { |
| 273 |
$name = basename( $path ); |
| 274 |
switch ( $phase ) { |
| 275 |
case 'flat': |
| 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; |
| 310 |
case 'static': |
| 311 |
return 'index.html' === $name; |
| 312 |
case 'min': |
| 313 |
return '.css' === substr( $name, -4 ) || '.js' === substr( $name, -3 ); |
| 314 |
} |
| 315 |
return false; |
| 316 |
} |
| 317 |
|
| 318 |
/** |
| 319 |
* Effective max-age for a cached page, in seconds. |
| 320 |
* |
| 321 |
* Cache::is_expired() is the read-time gate and is deliberately NOT |
| 322 |
* reused here: it resolves the per-post override from the *current* |
| 323 |
* request (Cache_Rules::current_post_id() is null in cron) and runs the |
| 324 |
* `xspeed_cache_max_age` filter, whose Pro listeners branch on |
| 325 |
* is_404()/is_feed() of the request being served. Both are meaningless |
| 326 |
* on a cron tick and would mis-age every entry. |
| 327 |
* |
| 328 |
* The authoritative per-entry value is the `ttl` written into the .meta |
| 329 |
* sidecar at store time (Cache::write_meta), which is exactly the |
| 330 |
* resolved max-age for that entry — that is what feeds and 404s carry. |
| 331 |
* Entries with the default TTL write no sidecar, hence the fallback. |
| 332 |
* |
| 333 |
* The static tree never has a .meta: store_static() only runs for plain |
| 334 |
* 200 text/html, so the global TTL is always correct there. |
| 335 |
*/ |
| 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 |
} |
| 357 |
if ( 'flat' !== $phase ) { |
| 358 |
return $default_ttl; |
| 359 |
} |
| 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 |
} |
| 375 |
return $ttl > 0 ? $ttl : $default_ttl; |
| 376 |
} |
| 377 |
|
| 378 |
/** |
| 379 |
* Age test. A file that vanished between the scan and here (a concurrent |
| 380 |
* purge, a parallel cron) is not stale — there is nothing to delete. |
| 381 |
* A future mtime (clock skew, rsync -t from a fast host) reads as age 0, |
| 382 |
* so it is kept rather than collected. |
| 383 |
*/ |
| 384 |
private static function is_stale( string $path, int $now, int $max_age ): bool { |
| 385 |
if ( $max_age <= 0 ) { |
| 386 |
return false; |
| 387 |
} |
| 388 |
clearstatcache( true, $path ); |
| 389 |
$mtime = @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- file may have been removed concurrently; false is handled below. |
| 390 |
if ( false === $mtime ) { |
| 391 |
return false; |
| 392 |
} |
| 393 |
return ( $now - (int) $mtime ) > $max_age; |
| 394 |
} |
| 395 |
|
| 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 |
/** |
| 473 |
* 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) |
| 479 |
*/ |
| 480 |
private static function delete_entry( string $path ): void { |
| 481 |
wp_delete_file( $path ); |
| 482 |
|
| 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 |
// 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 |
} |
| 543 |
} |
| 544 |
} |
| 545 |
} |
| 546 |
|
| 547 |
/** |
| 548 |
* Yield every file under $dir, depth-first, in a stable order. |
| 549 |
* |
| 550 |
* Stable matters: the resume cursor is a path comparison, so two runs |
| 551 |
* must agree on the sequence. scandir() sorts by default; the explicit |
| 552 |
* recursion keeps directories and files interleaved in that same order. |
| 553 |
* |
| 554 |
* @param string $dir Directory to walk. |
| 555 |
* @param bool $recursive Descend into subdirectories. |
| 556 |
* @return \Generator<string> |
| 557 |
*/ |
| 558 |
private static function files( string $dir, bool $recursive = true ): \Generator { |
| 559 |
$entries = @scandir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable directory is not fatal; empty walk is the right answer. |
| 560 |
if ( false === $entries ) { |
| 561 |
return; |
| 562 |
} |
| 563 |
foreach ( $entries as $entry ) { |
| 564 |
if ( '.' === $entry || '..' === $entry ) { |
| 565 |
continue; |
| 566 |
} |
| 567 |
$path = $dir . '/' . $entry; |
| 568 |
if ( is_dir( $path ) ) { |
| 569 |
if ( $recursive ) { |
| 570 |
yield from self::files( $path ); |
| 571 |
} |
| 572 |
continue; |
| 573 |
} |
| 574 |
yield $path; |
| 575 |
} |
| 576 |
} |
| 577 |
|
| 578 |
/** |
| 579 |
* Remove directories the sweep emptied, bottom-up. Returns true when |
| 580 |
* $dir itself is now gone. The root is kept — nginx's access_log target |
| 581 |
* and the silence file live beside it and callers assume it exists. |
| 582 |
*/ |
| 583 |
private static function prune_empty_dirs( string $root ): void { |
| 584 |
$entries = @scandir( $root ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see files(). |
| 585 |
if ( false === $entries ) { |
| 586 |
return; |
| 587 |
} |
| 588 |
foreach ( $entries as $entry ) { |
| 589 |
if ( '.' === $entry || '..' === $entry ) { |
| 590 |
continue; |
| 591 |
} |
| 592 |
$path = $root . '/' . $entry; |
| 593 |
if ( is_dir( $path ) ) { |
| 594 |
self::prune_empty_dirs( $path ); |
| 595 |
// Best-effort: a non-empty directory simply refuses. |
| 596 |
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- mirrors Cache::rmtree_html(); WP_Filesystem needs admin credentials unavailable on a cron tick. |
| 597 |
@rmdir( $path ); |
| 598 |
} |
| 599 |
} |
| 600 |
} |
| 601 |
|
| 602 |
/** Persisted resume point: which phase, and the last path examined. */ |
| 603 |
private static function read_cursor(): array { |
| 604 |
$stored = get_option( self::CURSOR_OPTION, array() ); |
| 605 |
if ( ! is_array( $stored ) ) { |
| 606 |
$stored = array(); |
| 607 |
} |
| 608 |
$phase = isset( $stored['phase'] ) && in_array( $stored['phase'], self::PHASES, true ) |
| 609 |
? (string) $stored['phase'] |
| 610 |
: self::PHASES[0]; |
| 611 |
|
| 612 |
return array( |
| 613 |
'phase' => $phase, |
| 614 |
'after' => isset( $stored['after'] ) && is_string( $stored['after'] ) ? $stored['after'] : '', |
| 615 |
); |
| 616 |
} |
| 617 |
|
| 618 |
private static function write_cursor( string $phase, string $after ): void { |
| 619 |
$value = array( |
| 620 |
'phase' => $phase, |
| 621 |
'after' => $after, |
| 622 |
); |
| 623 |
if ( false === get_option( self::CURSOR_OPTION, false ) ) { |
| 624 |
add_option( self::CURSOR_OPTION, $value, '', 'no' ); |
| 625 |
return; |
| 626 |
} |
| 627 |
update_option( self::CURSOR_OPTION, $value ); |
| 628 |
} |
| 629 |
|
| 630 |
/** |
| 631 |
* Record the run so the Cache section can show it without SSH, and drop |
| 632 |
* the memoized inventory when anything actually went away. |
| 633 |
*/ |
| 634 |
private static function finish( int $removed, string $cause ): void { |
| 635 |
$stats = Cache::get_stats_option(); |
| 636 |
Cache::update_stats( |
| 637 |
array( |
| 638 |
'last_gc' => time(), |
| 639 |
'gc_removed' => $removed, |
| 640 |
'gc_removed_total' => (int) ( $stats['gc_removed_total'] ?? 0 ) + $removed, |
| 641 |
) |
| 642 |
); |
| 643 |
|
| 644 |
if ( $removed < 1 ) { |
| 645 |
return; |
| 646 |
} |
| 647 |
|
| 648 |
Cache_Inventory::invalidate(); |
| 649 |
|
| 650 |
Activity_Log::record( |
| 651 |
'cache_purged', |
| 652 |
sprintf( |
| 653 |
/* translators: 1: cause of the sweep, 2: number of files removed. */ |
| 654 |
__( 'Cache garbage collection (%1$s) — %2$d expired file(s) removed', 'xspeed' ), |
| 655 |
$cause, |
| 656 |
$removed |
| 657 |
), |
| 658 |
Activity_Log::INFO |
| 659 |
); |
| 660 |
} |
| 661 |
} |
| 662 |
|