PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.1
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 1.2.0 All 28 releases
xspeed / includes / class-cache-gc.php

class-cache-gc.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.1, 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