PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.1 1.2.2 1.2.3
xspeed / includes / class-cache-gc.php

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

662 lines 24.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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