PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
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.2.0, at includes/class-cache-gc.php

642 lines 23.0 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'] ?? 24 ) ) * 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 ( 'flat' !== $phase ) {
338 return $default_ttl;
339 }
340 // Read the sidecar NEXT TO THE FILE. Cache::read_meta() rebuilds the
341 // path from the key via cache_meta_for(), which resolves against the
342 // CURRENT request's site bucket — wrong for a cron sweep walking
343 // every site's entries, and wrong for the legacy top-level layout.
344 // The sidecar is always `<file>.meta`, so derive it directly. (#6)
345 $meta_file = substr( $path, 0, -5 ) . '.meta';
346 $ttl = 0;
347 if ( is_file( $meta_file ) ) {
348 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache sidecar; WP_Filesystem needs admin credentials unavailable during cron.
349 $raw = (string) @file_get_contents( $meta_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable sidecar just means "use the global TTL".
350 $decoded = json_decode( $raw, true );
351 if ( is_array( $decoded ) && isset( $decoded['ttl'] ) ) {
352 $ttl = (int) $decoded['ttl'];
353 }
354 }
355 return $ttl > 0 ? $ttl : $default_ttl;
356 }
357
358 /**
359 * Age test. A file that vanished between the scan and here (a concurrent
360 * purge, a parallel cron) is not stale — there is nothing to delete.
361 * A future mtime (clock skew, rsync -t from a fast host) reads as age 0,
362 * so it is kept rather than collected.
363 */
364 private static function is_stale( string $path, int $now, int $max_age ): bool {
365 if ( $max_age <= 0 ) {
366 return false;
367 }
368 clearstatcache( true, $path );
369 $mtime = @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- file may have been removed concurrently; false is handled below.
370 if ( false === $mtime ) {
371 return false;
372 }
373 return ( $now - (int) $mtime ) > $max_age;
374 }
375
376 /**
377 * Asset paths (relative to `min/`) that some cached page still links to.
378 *
379 * Built once per run and memoized: a sweep examines up to `budget()`
380 * files, and re-reading every cached page for each of them would turn a
381 * cheap cron tick into an O(assets x pages) crawl.
382 *
383 * Scans BOTH cache trees. The static tree is served by nginx without ever
384 * running PHP, so a page there can outlive any invalidation we do in PHP —
385 * missing it would leave exactly the 404s this fix exists to prevent, on
386 * the fastest path.
387 *
388 * @var array<string,true>|null
389 */
390 private static $referenced = null;
391
392 /** Forget the memo — the next run rebuilds it. */
393 public static function reset_reference_index(): void {
394 self::$referenced = null;
395 }
396
397 /**
398 * Is this asset linked from any cached page?
399 *
400 * @param string $path Absolute path to a file under `min/`.
401 */
402 private static function is_referenced( string $path ): bool {
403 if ( null === self::$referenced ) {
404 self::$referenced = self::build_reference_index();
405 }
406
407 $min_root = self::phase_root( 'min' );
408 if ( null === $min_root ) {
409 return false;
410 }
411 // Compare on the path RELATIVE to min/, which is what a page's URL
412 // carries — absolute paths differ between the cache dir and the URL.
413 $rel = ltrim( str_replace( $min_root, '', $path ), '/' );
414
415 return isset( self::$referenced[ $rel ] );
416 }
417
418 /**
419 * Read every cached page once and collect the assets they reference.
420 *
421 * @return array<string,true> Keys are paths relative to `min/`.
422 */
423 private static function build_reference_index(): array {
424 $found = array();
425
426 foreach ( array( 'flat', 'static' ) as $phase ) {
427 $root = self::phase_root( $phase );
428 if ( null === $root || ! is_dir( $root ) ) {
429 continue;
430 }
431 foreach ( self::files( $root, 'flat' !== $phase ) as $file ) {
432 if ( '.html' !== substr( $file, -5 ) ) {
433 continue;
434 }
435 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading our own cache file; WP_Filesystem is unavailable in cron context.
436 $html = (string) @file_get_contents( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a concurrent purge can unlink mid-walk; '' is handled.
437 if ( '' === $html ) {
438 continue;
439 }
440 if ( ! preg_match_all( '#/cache/xspeed/min/([^"\'\s?>]+\.(?:css|js))#', $html, $m ) ) {
441 continue;
442 }
443 foreach ( $m[1] as $rel ) {
444 $found[ $rel ] = true;
445 }
446 }
447 }
448
449 return $found;
450 }
451
452 /**
453 * Delete a cache entry and every sibling that only exists because of it,
454 * so the sweep never creates the orphans it is there to remove:
455 *
456 * <md5>.html → <md5>.meta, <md5>.html.br
457 * index.html → index.html.br
458 * <key>.css/js → (none)
459 */
460 private static function delete_entry( string $path ): void {
461 wp_delete_file( $path );
462
463 $br = $path . '.br';
464 if ( file_exists( $br ) ) {
465 wp_delete_file( $br );
466 }
467
468 if ( '.html' === substr( $path, -5 ) ) {
469 $meta = substr( $path, 0, -5 ) . '.meta';
470 if ( file_exists( $meta ) ) {
471 wp_delete_file( $meta );
472 }
473 }
474
475 // Deleting an asset and invalidating the pages that embed it are ONE
476 // operation, so the two caches can never disagree. is_referenced()
477 // already keeps a linked asset alive, so this is the belt to that
478 // braces: it covers the races the index cannot see — a page written
479 // after the index was built, or a reference in a form the scan did
480 // not match. Without it, any gap between the two caches shows up as a
481 // 200 page full of 404s. (#190 AC2)
482 $min_root = self::phase_root( 'min' );
483 if ( null !== $min_root && 0 === strpos( $path, $min_root . '/' ) ) {
484 self::purge_pages_referencing( ltrim( str_replace( $min_root, '', $path ), '/' ) );
485 }
486 }
487
488 /**
489 * Remove every cached page that links to the given asset.
490 *
491 * Walks both trees: the static one is served by nginx without PHP, so a
492 * page left there keeps serving the dead link no matter what the flat
493 * cache says.
494 *
495 * @param string $rel Asset path relative to `min/`.
496 */
497 private static function purge_pages_referencing( string $rel ): void {
498 if ( '' === $rel ) {
499 return;
500 }
501
502 foreach ( array( 'flat', 'static' ) as $phase ) {
503 $root = self::phase_root( $phase );
504 if ( null === $root || ! is_dir( $root ) ) {
505 continue;
506 }
507 foreach ( self::files( $root, 'flat' !== $phase ) as $file ) {
508 if ( '.html' !== substr( $file, -5 ) ) {
509 continue;
510 }
511 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading our own cache file; WP_Filesystem is unavailable in cron context.
512 $html = (string) @file_get_contents( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- concurrent purge can unlink mid-walk.
513 if ( '' === $html || false === strpos( $html, $rel ) ) {
514 continue;
515 }
516
517 wp_delete_file( $file );
518 foreach ( array( $file . '.br', substr( $file, 0, -5 ) . '.meta' ) as $sibling ) {
519 if ( file_exists( $sibling ) ) {
520 wp_delete_file( $sibling );
521 }
522 }
523 }
524 }
525 }
526
527 /**
528 * Yield every file under $dir, depth-first, in a stable order.
529 *
530 * Stable matters: the resume cursor is a path comparison, so two runs
531 * must agree on the sequence. scandir() sorts by default; the explicit
532 * recursion keeps directories and files interleaved in that same order.
533 *
534 * @param string $dir Directory to walk.
535 * @param bool $recursive Descend into subdirectories.
536 * @return \Generator<string>
537 */
538 private static function files( string $dir, bool $recursive = true ): \Generator {
539 $entries = @scandir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable directory is not fatal; empty walk is the right answer.
540 if ( false === $entries ) {
541 return;
542 }
543 foreach ( $entries as $entry ) {
544 if ( '.' === $entry || '..' === $entry ) {
545 continue;
546 }
547 $path = $dir . '/' . $entry;
548 if ( is_dir( $path ) ) {
549 if ( $recursive ) {
550 yield from self::files( $path );
551 }
552 continue;
553 }
554 yield $path;
555 }
556 }
557
558 /**
559 * Remove directories the sweep emptied, bottom-up. Returns true when
560 * $dir itself is now gone. The root is kept — nginx's access_log target
561 * and the silence file live beside it and callers assume it exists.
562 */
563 private static function prune_empty_dirs( string $root ): void {
564 $entries = @scandir( $root ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see files().
565 if ( false === $entries ) {
566 return;
567 }
568 foreach ( $entries as $entry ) {
569 if ( '.' === $entry || '..' === $entry ) {
570 continue;
571 }
572 $path = $root . '/' . $entry;
573 if ( is_dir( $path ) ) {
574 self::prune_empty_dirs( $path );
575 // Best-effort: a non-empty directory simply refuses.
576 // 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.
577 @rmdir( $path );
578 }
579 }
580 }
581
582 /** Persisted resume point: which phase, and the last path examined. */
583 private static function read_cursor(): array {
584 $stored = get_option( self::CURSOR_OPTION, array() );
585 if ( ! is_array( $stored ) ) {
586 $stored = array();
587 }
588 $phase = isset( $stored['phase'] ) && in_array( $stored['phase'], self::PHASES, true )
589 ? (string) $stored['phase']
590 : self::PHASES[0];
591
592 return array(
593 'phase' => $phase,
594 'after' => isset( $stored['after'] ) && is_string( $stored['after'] ) ? $stored['after'] : '',
595 );
596 }
597
598 private static function write_cursor( string $phase, string $after ): void {
599 $value = array(
600 'phase' => $phase,
601 'after' => $after,
602 );
603 if ( false === get_option( self::CURSOR_OPTION, false ) ) {
604 add_option( self::CURSOR_OPTION, $value, '', 'no' );
605 return;
606 }
607 update_option( self::CURSOR_OPTION, $value );
608 }
609
610 /**
611 * Record the run so the Cache section can show it without SSH, and drop
612 * the memoized inventory when anything actually went away.
613 */
614 private static function finish( int $removed, string $cause ): void {
615 $stats = Cache::get_stats_option();
616 Cache::update_stats(
617 array(
618 'last_gc' => time(),
619 'gc_removed' => $removed,
620 'gc_removed_total' => (int) ( $stats['gc_removed_total'] ?? 0 ) + $removed,
621 )
622 );
623
624 if ( $removed < 1 ) {
625 return;
626 }
627
628 Cache_Inventory::invalidate();
629
630 Activity_Log::record(
631 'cache_purged',
632 sprintf(
633 /* translators: 1: cause of the sweep, 2: number of files removed. */
634 __( 'Cache garbage collection (%1$s) — %2$d expired file(s) removed', 'xspeed' ),
635 $cause,
636 $removed
637 ),
638 Activity_Log::INFO
639 );
640 }
641 }
642