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

439 lines 14.9 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 and re-minify them on the next request,
77 * every day. 30 days means a superseded file is collected a month after
78 * the update that orphaned it, and a live one is regenerated (once) a
79 * month after it was built.
80 *
81 * A filter returning <= 0 disables the min/ phase rather than deleting
82 * everything — "no max age" is the safer reading of an unset value.
83 */
84 public static function asset_max_age(): int {
85 /**
86 * Filter the max-age (seconds) for minified/combined assets.
87 *
88 * @param int $max_age Default 30 days.
89 */
90 return (int) apply_filters( 'xspeed_gc_asset_max_age', 30 * DAY_IN_SECONDS );
91 }
92
93 /** Candidate files a single run may examine. */
94 public static function budget(): int {
95 /**
96 * Filter the per-run cap on files examined.
97 *
98 * The sweep stops once this many candidates have been looked at and
99 * resumes from the same point on the next run, so a site with
100 * hundreds of thousands of entries can't blow the cron timeout.
101 *
102 * @param int $budget Default 5000.
103 */
104 return max( 1, (int) apply_filters( 'xspeed_gc_budget', self::DEFAULT_BUDGET ) );
105 }
106
107 /**
108 * Run one bounded sweep.
109 *
110 * @param string $cause Who asked, for the activity log.
111 * @return int Files removed (parents only; .meta/.br siblings are not
112 * counted, matching purge_all()).
113 */
114 public static function run( string $cause = 'scheduled' ): int {
115 $budget = self::budget();
116 $cursor = self::read_cursor();
117 $removed = 0;
118
119 // Resolve the global TTL once — Settings_Manager::get() is cheap but
120 // this runs per candidate otherwise.
121 $opts = Settings_Manager::get( 'cache' );
122 $default_ttl = max( 1, (int) ( $opts['cache_expiry'] ?? 24 ) ) * HOUR_IN_SECONDS;
123 $asset_ttl = self::asset_max_age();
124 $now = time();
125
126 // Start at the phase we paused in and carry on round the list. Each
127 // completed phase resets the cursor and moves to the next; when the
128 // last one completes we wrap back to the first, so the next run
129 // starts a fresh cycle.
130 $start = array_search( $cursor['phase'], self::PHASES, true );
131 $start = false === $start ? 0 : (int) $start;
132 $after = (string) $cursor['after'];
133
134 for ( $i = $start; $i < count( self::PHASES ); $i++ ) {
135 $phase = self::PHASES[ $i ];
136
137 if ( 'min' === $phase && $asset_ttl <= 0 ) {
138 $after = '';
139 continue;
140 }
141
142 list( $phase_removed, $stopped_at ) = self::sweep_phase( $phase, $after, $budget, $now, $default_ttl, $asset_ttl );
143 $removed += $phase_removed;
144
145 if ( '' !== $stopped_at ) {
146 // Budget spent mid-phase — remember where to pick up.
147 self::write_cursor( $phase, $stopped_at );
148 self::finish( $removed, $cause );
149 return $removed;
150 }
151
152 // Phase complete. The static tree can now be pruned of the
153 // directories the sweep emptied — safe only once the whole tree
154 // has been walked, and bounded because it happens at most once
155 // per full cycle.
156 if ( 'static' === $phase && defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
157 self::prune_empty_dirs( XSPEED_CACHE_STATIC_DIR );
158 }
159
160 $after = '';
161 }
162
163 // Full cycle done — rewind to the first phase.
164 self::write_cursor( self::PHASES[0], '' );
165 self::finish( $removed, $cause );
166 return $removed;
167 }
168
169 /**
170 * Sweep one phase.
171 *
172 * @param string $phase One of self::PHASES.
173 * @param string $after Resume point (absolute path) or ''.
174 * @param int $budget Remaining candidate budget, decremented.
175 * @param int $now Run timestamp.
176 * @param int $default_ttl Global page TTL in seconds.
177 * @param int $asset_ttl Minified-asset max-age in seconds.
178 * @return array{0:int,1:string} Removed count, and the path the sweep
179 * stopped at ('' when the phase finished).
180 */
181 private static function sweep_phase( string $phase, string $after, int &$budget, int $now, int $default_ttl, int $asset_ttl ): array {
182 $root = self::phase_root( $phase );
183 if ( null === $root || ! is_dir( $root ) ) {
184 return array( 0, '' );
185 }
186
187 $removed = 0;
188
189 // The flat cache is one flat directory; min/ and rest/ sit inside it
190 // with their own rules, so don't descend for that phase.
191 foreach ( self::files( $root, 'flat' !== $phase ) as $path ) {
192 // Cheap name test first: a non-candidate costs no stat and no
193 // budget. Everything else in these directories (index.php,
194 // .meta, .br, .mobile-separate, the hits log) is either a
195 // sibling collected with its parent or must never be touched.
196 if ( ! self::is_candidate( $phase, $path ) ) {
197 continue;
198 }
199 // Skip everything already handled in an earlier run. String
200 // compare only — self::files() yields in a stable sorted order.
201 if ( '' !== $after && strcmp( $path, $after ) <= 0 ) {
202 continue;
203 }
204 if ( $budget <= 0 ) {
205 // Paused before examining $path. $after is the last candidate
206 // we did examine, which is exactly where to resume.
207 return array( $removed, $after );
208 }
209 --$budget;
210 $after = $path;
211
212 $max_age = 'min' === $phase ? $asset_ttl : self::page_max_age( $phase, $path, $default_ttl );
213 if ( self::is_stale( $path, $now, $max_age ) ) {
214 self::delete_entry( $path );
215 ++$removed;
216 }
217 }
218
219 return array( $removed, '' );
220 }
221
222 /** Absolute root directory for a phase, or null when undefined. */
223 private static function phase_root( string $phase ): ?string {
224 switch ( $phase ) {
225 case 'flat':
226 return defined( 'XSPEED_CACHE_DIR' ) ? XSPEED_CACHE_DIR : null;
227 case 'static':
228 return defined( 'XSPEED_CACHE_STATIC_DIR' ) ? XSPEED_CACHE_STATIC_DIR : null;
229 case 'min':
230 return defined( 'XSPEED_CACHE_DIR' ) ? XSPEED_CACHE_DIR . '/min' : null;
231 }
232 return null;
233 }
234
235 /**
236 * Is this file one the given phase collects?
237 *
238 * The flat phase deliberately ignores subdirectories — min/ and rest/
239 * live under XSPEED_CACHE_DIR and have their own rules (or none).
240 */
241 private static function is_candidate( string $phase, string $path ): bool {
242 $name = basename( $path );
243 switch ( $phase ) {
244 case 'flat':
245 return '.html' === substr( $name, -5 )
246 && dirname( $path ) === XSPEED_CACHE_DIR;
247 case 'static':
248 return 'index.html' === $name;
249 case 'min':
250 return '.css' === substr( $name, -4 ) || '.js' === substr( $name, -3 );
251 }
252 return false;
253 }
254
255 /**
256 * Effective max-age for a cached page, in seconds.
257 *
258 * Cache::is_expired() is the read-time gate and is deliberately NOT
259 * reused here: it resolves the per-post override from the *current*
260 * request (Cache_Rules::current_post_id() is null in cron) and runs the
261 * `xspeed_cache_max_age` filter, whose Pro listeners branch on
262 * is_404()/is_feed() of the request being served. Both are meaningless
263 * on a cron tick and would mis-age every entry.
264 *
265 * The authoritative per-entry value is the `ttl` written into the .meta
266 * sidecar at store time (Cache::write_meta), which is exactly the
267 * resolved max-age for that entry — that is what feeds and 404s carry.
268 * Entries with the default TTL write no sidecar, hence the fallback.
269 *
270 * The static tree never has a .meta: store_static() only runs for plain
271 * 200 text/html, so the global TTL is always correct there.
272 */
273 private static function page_max_age( string $phase, string $path, int $default_ttl ): int {
274 if ( 'flat' !== $phase ) {
275 return $default_ttl;
276 }
277 $meta = Cache::read_meta( basename( $path, '.html' ) );
278 $ttl = isset( $meta['ttl'] ) ? (int) $meta['ttl'] : 0;
279 return $ttl > 0 ? $ttl : $default_ttl;
280 }
281
282 /**
283 * Age test. A file that vanished between the scan and here (a concurrent
284 * purge, a parallel cron) is not stale — there is nothing to delete.
285 * A future mtime (clock skew, rsync -t from a fast host) reads as age 0,
286 * so it is kept rather than collected.
287 */
288 private static function is_stale( string $path, int $now, int $max_age ): bool {
289 if ( $max_age <= 0 ) {
290 return false;
291 }
292 clearstatcache( true, $path );
293 $mtime = @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- file may have been removed concurrently; false is handled below.
294 if ( false === $mtime ) {
295 return false;
296 }
297 return ( $now - (int) $mtime ) > $max_age;
298 }
299
300 /**
301 * Delete a cache entry and every sibling that only exists because of it,
302 * so the sweep never creates the orphans it is there to remove:
303 *
304 * <md5>.html → <md5>.meta, <md5>.html.br
305 * index.html → index.html.br
306 * <key>.css/js → (none)
307 */
308 private static function delete_entry( string $path ): void {
309 wp_delete_file( $path );
310
311 $br = $path . '.br';
312 if ( file_exists( $br ) ) {
313 wp_delete_file( $br );
314 }
315
316 if ( '.html' === substr( $path, -5 ) ) {
317 $meta = substr( $path, 0, -5 ) . '.meta';
318 if ( file_exists( $meta ) ) {
319 wp_delete_file( $meta );
320 }
321 }
322 }
323
324 /**
325 * Yield every file under $dir, depth-first, in a stable order.
326 *
327 * Stable matters: the resume cursor is a path comparison, so two runs
328 * must agree on the sequence. scandir() sorts by default; the explicit
329 * recursion keeps directories and files interleaved in that same order.
330 *
331 * @param string $dir Directory to walk.
332 * @param bool $recursive Descend into subdirectories.
333 * @return \Generator<string>
334 */
335 private static function files( string $dir, bool $recursive = true ): \Generator {
336 $entries = @scandir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable directory is not fatal; empty walk is the right answer.
337 if ( false === $entries ) {
338 return;
339 }
340 foreach ( $entries as $entry ) {
341 if ( '.' === $entry || '..' === $entry ) {
342 continue;
343 }
344 $path = $dir . '/' . $entry;
345 if ( is_dir( $path ) ) {
346 if ( $recursive ) {
347 yield from self::files( $path );
348 }
349 continue;
350 }
351 yield $path;
352 }
353 }
354
355 /**
356 * Remove directories the sweep emptied, bottom-up. Returns true when
357 * $dir itself is now gone. The root is kept — nginx's access_log target
358 * and the silence file live beside it and callers assume it exists.
359 */
360 private static function prune_empty_dirs( string $root ): void {
361 $entries = @scandir( $root ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see files().
362 if ( false === $entries ) {
363 return;
364 }
365 foreach ( $entries as $entry ) {
366 if ( '.' === $entry || '..' === $entry ) {
367 continue;
368 }
369 $path = $root . '/' . $entry;
370 if ( is_dir( $path ) ) {
371 self::prune_empty_dirs( $path );
372 // Best-effort: a non-empty directory simply refuses.
373 // 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.
374 @rmdir( $path );
375 }
376 }
377 }
378
379 /** Persisted resume point: which phase, and the last path examined. */
380 private static function read_cursor(): array {
381 $stored = get_option( self::CURSOR_OPTION, array() );
382 if ( ! is_array( $stored ) ) {
383 $stored = array();
384 }
385 $phase = isset( $stored['phase'] ) && in_array( $stored['phase'], self::PHASES, true )
386 ? (string) $stored['phase']
387 : self::PHASES[0];
388
389 return array(
390 'phase' => $phase,
391 'after' => isset( $stored['after'] ) && is_string( $stored['after'] ) ? $stored['after'] : '',
392 );
393 }
394
395 private static function write_cursor( string $phase, string $after ): void {
396 $value = array(
397 'phase' => $phase,
398 'after' => $after,
399 );
400 if ( false === get_option( self::CURSOR_OPTION, false ) ) {
401 add_option( self::CURSOR_OPTION, $value, '', 'no' );
402 return;
403 }
404 update_option( self::CURSOR_OPTION, $value );
405 }
406
407 /**
408 * Record the run so the Cache section can show it without SSH, and drop
409 * the memoized inventory when anything actually went away.
410 */
411 private static function finish( int $removed, string $cause ): void {
412 $stats = Cache::get_stats_option();
413 Cache::update_stats(
414 array(
415 'last_gc' => time(),
416 'gc_removed' => $removed,
417 'gc_removed_total' => (int) ( $stats['gc_removed_total'] ?? 0 ) + $removed,
418 )
419 );
420
421 if ( $removed < 1 ) {
422 return;
423 }
424
425 Cache_Inventory::invalidate();
426
427 Activity_Log::record(
428 'cache_purged',
429 sprintf(
430 /* translators: 1: cause of the sweep, 2: number of files removed. */
431 __( 'Cache garbage collection (%1$s) — %2$d expired file(s) removed', 'xspeed' ),
432 $cause,
433 $removed
434 ),
435 Activity_Log::INFO
436 );
437 }
438 }
439